/// /** * Every escape in a repository, listed (Katerina's ruling B1, 25 September): * where it is, which rules it keeps off, why, and how old it is. * * The count (`.gates-count.json`) holds numbers per rule; the plan asked for * escapes "listed in a report, reviewed", and the hand-written debt pages had * drifted from the count in all three repos. This reads the markers themselves, * both forms: * * - the gate's, `// @estiva-escape(): ` (or in a JSX comment); * - the token lint's, `// eslint-disable-next-line -- @estiva-escape: ` * (ruling A2, 15 September), which no count sees. * * Age comes from git (the day the marker's line was last written), so it cannot * be forgotten or faked. Review is by age, monthly: `gates:status` shows every * escape older than {@link REVIEW_AFTER_DAYS} days, and Katerina keeps it (with * a new reason) or it gets fixed. */ import { execFileSync } from 'node:child_process' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join, relative } from 'node:path' import { readMarker } from '../eslint/escape' /** An escape older than this many days is shown for review. */ export const REVIEW_AFTER_DAYS = 30 export interface Escape { file: string line: number /** The rules it keeps off, as the marker names them. */ rules: string[] reason: string /** Whole days since the marker's line was written, or null outside git. */ days: number | null /** `gate` for the gate's marker, `token` for the token lint's directive. */ kind: 'gate' | 'token' } const SOURCE = /\.[cm]?[jt]sx?$/ const SKIP = new Set(['node_modules', 'dist', '.git', 'storybook-static', 'coverage']) const DIRECTIVE = /eslint-disable(?:-next-line|-line)?\s+(.*?)\s+--\s+@estiva-escape\s*:?\s*(.*)$/ function sourceFiles(dir: string, out: string[] = []): string[] { if (!existsSync(dir)) return out for (const name of readdirSync(dir)) { if (SKIP.has(name)) continue const path = join(dir, name) if (statSync(path).isDirectory()) sourceFiles(path, out) else if (SOURCE.test(name) && !/\.test\.[cm]?[jt]sx?$/.test(name)) out.push(path) } return out } /** The day each line of a file was last written, from `git blame`, in seconds since 1970. */ function lineTimes(root: string, file: string): Map { const times = new Map() try { const out = execFileSync('git', ['blame', '--line-porcelain', '--', relative(root, file)], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 }).toString() let line = 0 for (const row of out.split('\n')) { const header = /^[0-9a-f]{40} \d+ (\d+)/.exec(row) if (header) line = Number(header[1]) else if (row.startsWith('author-time ')) times.set(line, Number(row.slice(12))) } } catch { // Not in git, or a file git does not track yet: its escapes have no age. } return times } /** Every escape under `src`, and in any other folder named, oldest first. */ export function listEscapes(root: string, { folders = ['src'], now = Date.now() }: { folders?: string[]; now?: number } = {}): Escape[] { const found: Escape[] = [] for (const file of folders.flatMap((f) => sourceFiles(join(root, f)))) { const lines = readFileSync(file, 'utf8').split(/\r?\n/) const hits: Omit[] = [] lines.forEach((text, i) => { if (!text.includes('@estiva-escape')) return const rel = relative(root, file).split('\\').join('/') // A real marker opens its comment; documentation mentions one mid-sentence, or with a placeholder. if (/<(reason|why|rule)\b/.test(text)) return const directive = /^\s*(?:\/\/|\{?\/\*)\s*eslint-disable/.test(text) ? DIRECTIVE.exec(text) : null if (directive) { hits.push({ file: rel, line: i + 1, rules: directive[1].split(/[\s,]+/).filter(Boolean), reason: directive[2].replace(/\*\/\s*}?\s*$/, '').trim(), kind: 'token' }) return } if (!/^\s*(?:\/\/+|\{?\/\*+)?\s*@estiva-escape(?:\s*\(|\s*:)/.test(text)) return const marker = readMarker(text) if (!marker) return hits.push({ file: rel, line: i + 1, rules: marker.rules, reason: marker.reason.replace(/\*\/\s*}?\s*$/, '').trim(), kind: 'gate' }) }) if (!hits.length) continue const times = lineTimes(root, file) for (const hit of hits) { const at = times.get(hit.line) found.push({ ...hit, days: at === undefined ? null : Math.floor((now - at * 1000) / 86_400_000) }) } } return found.sort((a, b) => (b.days ?? -1) - (a.days ?? -1) || a.file.localeCompare(b.file) || a.line - b.line) } /** The lines `gates:status` prints: a total, and every escape due for review (or every one, with `all`). */ export function escapeLines(escapes: Escape[], { all = false }: { all?: boolean } = {}): string[] { const due = escapes.filter((e) => (e.days ?? 0) > REVIEW_AFTER_DAYS) const shown = all ? escapes : due const gate = escapes.filter((e) => e.kind === 'gate').length const out = [`Escapes: ${escapes.length} (${gate} of the gate's, ${escapes.length - gate} of the token lint's) · ${due.length} older than ${REVIEW_AFTER_DAYS} days, to review: keep with a new reason, or fix${all ? '' : ' · every escape: add -- --escapes'}`] for (const e of shown) out.push(` ${e.file}:${e.line} ${e.rules.join(', ') || '(no rule named)'} · ${e.days === null ? 'not in git yet' : `${e.days} days`}\n ${e.reason}`) return out }