/** * Broken-windows ledger — optionally enforced cross-phase defect register (issue #1950). * * Manages `.planning/WINDOWS.md`: a cross-phase ledger of small defects (stubs, * TODOs, skipped tests, lint warnings, unrun verifies, unmet truths, deviations). * When `workflow.windows_enforce` is true, `/gsd-ship` blocks while any entry is * `open`; an entry can be `waived` only with a recorded reason or `fixed`. * * LEAF MODULE — imports node:fs + node:path, plus two compiled sibling lib * modules require()d at runtime: workstream-inventory.cjs (the #4487 * milestone stamp) and capability-lock.cjs (the #3780 cross-process ledger * lock). No other src/ imports. * * Storage format (`.planning/WINDOWS.md`): * --- * schema_version: 1 * open_count: N * waived_count: N * fixed_count: N * total_count: N * last_updated: * --- * # Broken Windows Ledger * * ````json * [ ] * ```` * * Frontmatter holds scalar counts (the FAST path the ship gate reads via jq * without parsing JSON). The JSON code block is the AUTHORITATIVE entries * source. The two must agree; read paths cross-check and fail closed on drift. * * Exports: * Constants: REASON, LEDGER_FILE_NAME, SCHEMA_VERSION, KINDS * Pure: emptyLedger, parseLedger, renderLedger, appendWindow, * markWaived, markFixed, openCount, findByStatus * I/O: cmdWindowsStatus, cmdWindowsAppend, cmdWindowsWaive, * cmdWindowsMarkFixed * * Reasoning shape — every cmd* function returns JSON suitable for `--raw`: * success: { ok: true, ledger: , ... } * failure: { ok: false, reason: , message: } * Failure throws an ExitError-shaped error carrying REASON so the gsd-tools * dispatcher's `--json-errors` mode emits it as a structured code (CONTRIBUTING.md * "Prohibited: Raw Text Matching"). The frozen REASON enum is the typed surface * tests assert against. */ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import workstreamInventory = require('./workstream-inventory.cjs'); // ─── Constants ───────────────────────────────────────────────────────────── export const LEDGER_FILE_NAME = 'WINDOWS.md'; export const SCHEMA_VERSION = 1; /** * Frozen reason enum. Tests assert against these — they are the typed surface * per CONTRIBUTING.md. Adding a new code requires updating this enum, the I/O * entry point that emits it, AND the test that locks Object.keys(REASON).sort() * — three coordinated changes that keep code and tests from drifting. */ export const REASON = Object.freeze({ WINDOWS_OK: 'windows_ok', WINDOWS_LEDGER_MISSING: 'windows_ledger_missing', WINDOWS_LEDGER_MALFORMED: 'windows_ledger_malformed', WINDOWS_ID_NOT_FOUND: 'windows_id_not_found', WINDOWS_ALREADY_RESOLVED: 'windows_already_resolved', WINDOWS_WAIVE_REASON_EMPTY: 'windows_waive_reason_empty', WINDOWS_INVALID_KIND: 'windows_invalid_kind', WINDOWS_INVALID_FILE: 'windows_invalid_file', WINDOWS_INVALID_TEXT: 'windows_invalid_text', WINDOWS_INVALID_ID: 'windows_invalid_id', WINDOWS_APPEND_MISSING_FIELD: 'windows_append_missing_field', WINDOWS_USAGE: 'windows_usage', // #3689: the rendered markdown table disagreed with the fenced JSON (the // sole source of truth) at the pre-write seam — refuse rather than silently // reconcile by overwriting the operator's hand-edit or dropping a row. WINDOWS_LEDGER_TABLE_DRIFT: 'windows_ledger_table_drift', // #3780: the read-compute-write cycle is serialized on a cross-process // ledger lock; this fires only when another writer held the lock past the // whole bounded retry budget — a typed, actionable refusal instead of a // silently-lost mutation reported as success. WINDOWS_LEDGER_LOCK: 'windows_ledger_lock', }); /** Allowed window kinds. Aligned with the issue's enumerated sources. */ export const KINDS = Object.freeze([ 'stub', 'todo', 'fixme', 'skipped-test', 'lint-warning', 'unmet-truth', 'unrun-verify', 'deviation', ]); const KIND_SET = new Set(KINDS); // ─── Types ───────────────────────────────────────────────────────────────── export type WindowKind = | 'stub' | 'todo' | 'fixme' | 'skipped-test' | 'lint-warning' | 'unmet-truth' | 'unrun-verify' | 'deviation'; export type WindowStatus = 'open' | 'waived' | 'fixed'; export interface WindowEntry { id: number; kind: WindowKind; phase: string; file: string; // '' when not applicable line: number | null; // null when not applicable description: string; status: WindowStatus; reason: string; // '' unless status === 'waived' recorded_at: string; // ISO-8601 resolved_at: string | null; // #4487: the workstream's resolved milestone version (e.g. "v2.0") at the // moment the entry was recorded, or null when it could not be resolved. // Phase numbers are unique only within one active phases/ directory -- // `milestone complete` archives phases and frees their numbers for reuse, // so two milestones routinely produce entries under the same `phase` // value with nothing to distinguish them. Absence (on an entry recorded // before this field existed) reads as "recorded before this change" -- // validateEntryShape below does NOT require this key, so existing ledgers // stay valid with no migration. Optional (not `| null` alone): a genuinely // absent key must survive parseLedger -> renderLedger as absent, not as an // explicit `null`, or every legacy entry picks up permanent JSON noise // the first time ANY entry in the ledger is touched (JSON.stringify drops // an `undefined` property but keeps an explicit `null` one). milestone?: string | null; } /** Input shape for appendWindow — id/status/timestamps are assigned by the fn. */ export type WindowInput = Pick & Partial>; export interface Ledger { schema_version: number; open_count: number; waived_count: number; fixed_count: number; total_count: number; last_updated: string; entries: WindowEntry[]; } // ─── Errors ──────────────────────────────────────────────────────────────── /** * Error carrying a REASON code. gsd-tools.cjs's `--json-errors` mode catches * this and emits `{ ok: false, reason: err.reason, message: err.message }` to * stderr; otherwise the message goes to stderr as plain text and the exit * code is non-zero. */ export class WindowsError extends Error { reason: string; constructor(reason: string, message: string) { super(message); this.name = 'WindowsError'; this.reason = reason; } } // ─── Pure: constructors + counts ─────────────────────────────────────────── export function emptyLedger(now: string): Ledger { return { schema_version: SCHEMA_VERSION, open_count: 0, waived_count: 0, fixed_count: 0, total_count: 0, last_updated: now, entries: [], }; } export function openCount(ledger: Ledger): number { return ledger.open_count; } export function findByStatus(ledger: Ledger, status: WindowStatus): WindowEntry[] { return ledger.entries.filter((e) => e.status === status); } function recomputeCounts(ledger: Ledger): Ledger { let open = 0, waived = 0, fixed = 0; for (const e of ledger.entries) { if (e.status === 'open') open++; else if (e.status === 'waived') waived++; else if (e.status === 'fixed') fixed++; } return { ...ledger, open_count: open, waived_count: waived, fixed_count: fixed, total_count: ledger.entries.length, }; } function validateKind(kind: unknown): asserts kind is WindowKind { if (typeof kind !== 'string' || !KIND_SET.has(kind)) { throw new WindowsError( REASON.WINDOWS_INVALID_KIND, `Invalid window kind: ${JSON.stringify(kind)}. Allowed: ${KINDS.join(', ')}.`, ); } } function validateDescription(description: unknown): string { if (typeof description !== 'string' || description.trim() === '') { throw new WindowsError( REASON.WINDOWS_APPEND_MISSING_FIELD, 'Window description must be a non-empty string.', ); } rejectBacktickRun(description, 'description'); return description; } /** * Reject any string field that contains a 4-backtick run. The ledger's JSON * code block uses a 4-backtick fence; a 4-backtick run inside stringified * entry text would terminate the fence early and brick the next parse * (issue #1950 review H1). JSON.stringify does not escape backticks, so we * must catch them at validate time. */ function rejectBacktickRun(value: string, field: string): void { if (value.includes(FORBIDDEN_BACKTICK_RUN)) { throw new WindowsError( REASON.WINDOWS_INVALID_TEXT, `Window ${field} contains a 4-backtick run, which would corrupt the ledger's JSON code fence.`, ); } } function validateFile(file: unknown): string { if (file == null || file === '') return ''; if (typeof file !== 'string') { throw new WindowsError( REASON.WINDOWS_INVALID_FILE, 'Window file must be a string when provided.', ); } // Reject path traversal — the ledger is a project-local artifact; absolute or // parent-escaping paths serve no legitimate purpose and could mislead a human // reviewer into investigating the wrong location. Reject NUL bytes too. if (file.includes('\0')) { throw new WindowsError( REASON.WINDOWS_INVALID_FILE, 'Window file contains a NUL byte.', ); } if (path.isAbsolute(file) || /(^|[/\\])\.\.([/\\]|$)/.test(file)) { throw new WindowsError( REASON.WINDOWS_INVALID_FILE, `Window file rejects path traversal/absolute paths: ${file}`, ); } return file; } function validateLine(line: unknown): number | null { if (line == null || line === '') return null; // Strict: number or numeric string only; reject garbage like "abc" (which // Number() would silently coerce to NaN → null, hiding type drift). Issue // #1950 review M2. const n = typeof line === 'number' ? line : Number(line); if (!Number.isInteger(n) || n < 1) { throw new WindowsError( REASON.WINDOWS_APPEND_MISSING_FIELD, `Window line must be a positive integer when provided (got: ${JSON.stringify(line)}).`, ); } return n; } function nextId(entries: WindowEntry[]): number { let max = 0; for (const e of entries) if (e.id > max) max = e.id; return max + 1; } /** * Append a window to the ledger. Assigns the next dense id (max+1), sets * status=open, timestamps via opts.now. * * Concurrency (issue #1950 review L2, superseded by #3780): as a PURE * function this operates on whatever ledger snapshot it is passed and cannot * see concurrent writers — serialization is the CALLER's job. The I/O entry * points below (cmdWindowsAppend/Waive/MarkFixed) now discharge that duty by * holding the cross-process ledger lock across their whole * read-compute-write cycle, so the previously-documented loss (two parallel * writers, second rename wins, first entry silently gone) can no longer * occur through the CLI. */ export function appendWindow( ledger: Ledger, input: WindowInput, opts: { now: string } = { now: new Date().toISOString() }, ): { ledger: Ledger; entry: WindowEntry } { validateKind(input.kind); const description = validateDescription(input.description); const file = validateFile(input.file); const line = validateLine(input.line); const id = nextId(ledger.entries); const entry: WindowEntry = { id, kind: input.kind, phase: String(input.phase ?? ''), file, line, description, status: 'open', reason: '', recorded_at: opts.now, resolved_at: null, milestone: input.milestone ?? null, }; const entries = [...ledger.entries, entry]; const result = recomputeCounts({ ...ledger, entries, last_updated: opts.now }); return { ledger: result, entry }; } function findEntryOrFail(ledger: Ledger, id: number): WindowEntry { const entry = ledger.entries.find((e) => e.id === id); if (!entry) { throw new WindowsError( REASON.WINDOWS_ID_NOT_FOUND, `No window with id ${id}.`, ); } return entry; } function assertOpen(entry: WindowEntry): void { if (entry.status !== 'open') { throw new WindowsError( REASON.WINDOWS_ALREADY_RESOLVED, `Window ${entry.id} is already ${entry.status} (resolved_at=${entry.resolved_at}).`, ); } } export function markWaived( ledger: Ledger, id: number, reason: string, opts: { now: string } = { now: new Date().toISOString() }, ): Ledger { if (typeof reason !== 'string' || reason.trim() === '') { throw new WindowsError( REASON.WINDOWS_WAIVE_REASON_EMPTY, 'Waive requires a non-empty recorded reason.', ); } const entry = findEntryOrFail(ledger, id); assertOpen(entry); const newStatus: WindowStatus = 'waived'; const entries = ledger.entries.map((e) => e.id === id ? { ...e, status: newStatus, reason, resolved_at: opts.now } : e, ); return recomputeCounts({ ...ledger, entries, last_updated: opts.now }); } export function markFixed( ledger: Ledger, id: number, opts: { now: string } = { now: new Date().toISOString() }, ): Ledger { const entry = findEntryOrFail(ledger, id); assertOpen(entry); const newStatus: WindowStatus = 'fixed'; const entries = ledger.entries.map((e) => e.id === id ? { ...e, status: newStatus, resolved_at: opts.now } : e, ); return recomputeCounts({ ...ledger, entries, last_updated: opts.now }); } // ─── Pure: parse / render ────────────────────────────────────────────────── // JSON-FENCE strategy (issue #1950 review H1): a description containing the // 3-backtick markdown fence sequence would terminate the code block early // inside JSON.stringify output (which does not escape backticks), corrupting // the file and bricking the next parse. We use a 4-backtick fence which // cannot collide with anything JSON.stringify can emit on its own (JSON has // no 4-backtick operator), AND validate that no entry's text fields contain // a 4-backtick run, so the rendered file is provably reparseable. const JSON_FENCE_OPEN = '````json'; const JSON_FENCE_CLOSE = '````'; const FORBIDDEN_BACKTICK_RUN = '````'; /** * #3689: the ledger table's fixed header row literal. `renderTable` emits it * on both the empty and non-empty branches; `extractTableRegion` anchors on * it to bound the table region. Hoisted to one constant so the two surfaces * cannot drift (see "Generative Fix Divergence" — CONTRIBUTING.md). */ const TABLE_HEADER_LINE = '| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |'; // Reader-side fence tolerance (#3657): CommonMark formatters (Prettier et al.) // normalize the written 4-backtick fence down to the shortest legal width (3) // whenever the block body holds no backtick run — and a canonical-JSON ledger // body never does. Both widths are valid CommonMark, so the reader locates the // block by a line-anchored 3+ fence and closes on a run at least as wide as // the opening one (CommonMark: a shorter run does not close). The writer above // is unchanged — 4 backticks stay what renderLedger emits (#1950 review H1). type JsonBlockSpan = { bodyStart: number; bodyEnd: number; afterClose: number }; type JsonBlockLookup = | { ok: true; span: JsonBlockSpan } | { ok: false; reason: 'missing-open' | 'unterminated' }; /** * Locate the entries JSON block by CommonMark fence rules rather than a fixed * literal width. Both parseJsonBlock (strict) and writeLedgerAtomic's #2893 * prose preservation (lenient) go through this one function so read tolerance * and splice tolerance cannot drift (#3657). A backtick-only line can never * occur inside a body: JSON.stringify renders strings single-line-escaped, so * an inline run inside a description is never a close-fence candidate. * * Disambiguation (#3657 security review): an entry description may contain * newlines and 3-backtick runs (append validation rejects only 4+ runs), and * renderTable renders descriptions into the prose ABOVE the JSON block — so * hostile or accidental text can plant a second json fence above the real * one. renderLedger always emits the entries block as the FINAL fenced * section, so spans are scanned in REVERSE: prefer the latest span whose * entries length equals the frontmatter total_count (the real block always * satisfies it — parseLedger cross-checks that invariant), else the latest * span whose body is a JSON array, else the first span so corrupt bodies keep * their fail-closed parse errors. A mirror planted below with identical * length and identical entries is indistinguishable by construction — and * harmless. */ function locateJsonBlock(raw: string, expectedTotal?: number): JsonBlockLookup { const spans: JsonBlockSpan[] = []; for (const open of raw.matchAll(/^(`{3,})json[ \t]*\r?$/gm)) { const width = open[1].length; const bodyStart = (open.index ?? 0) + open[0].length; for (const close of raw.slice(bodyStart).matchAll(/^(`{3,})[ \t]*\r?$/gm)) { if (close[1].length < width) continue; const bodyEnd = bodyStart + (close.index ?? 0); const closeLineEnd = raw.indexOf('\n', bodyEnd); spans.push({ bodyStart, bodyEnd, afterClose: closeLineEnd === -1 ? raw.length : closeLineEnd + 1, }); break; // CommonMark: the first qualifying close ends this fence block } } if (spans.length === 0) { const sawOpen = /^(`{3,})json[ \t]*\r?$/m.test(raw); return { ok: false, reason: sawOpen ? 'unterminated' : 'missing-open' }; } const parseBody = (s: JsonBlockSpan): unknown => { try { return JSON.parse(raw.slice(s.bodyStart, s.bodyEnd).trim()); } catch { return undefined; } }; if (expectedTotal !== undefined) { for (let i = spans.length - 1; i >= 0; i--) { const body = parseBody(spans[i]); if (Array.isArray(body) && body.length === expectedTotal) { return { ok: true, span: spans[i] }; } } } for (let i = spans.length - 1; i >= 0; i--) { if (Array.isArray(parseBody(spans[i]))) { return { ok: true, span: spans[i] }; } } return { ok: true, span: spans[0] }; } /** * Minimal strict frontmatter parser for flat scalar keys. Only supports the * shape this module emits: `key: ` per line. Throws on any * structural deviation — fail-closed on drift. */ function parseFrontmatterStrict(raw: string): Record { if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger missing frontmatter opening ---', ); } const headerEnd = raw.startsWith('---\r\n') ? 5 : 4; const closeIdx = raw.indexOf('\n---', headerEnd); if (closeIdx === -1) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger missing frontmatter closing ---', ); } const yamlBody = raw.slice(headerEnd, closeIdx); const out: Record = {}; for (const rawLine of yamlBody.split(/\r?\n/)) { // #3116: the `\n---` scan leaves the final line's CR attached on a CRLF // ledger, and `.` never matches CR, so the key: value regex below fails on // it. Strip the trailing CR per line so the rest of `raw` (which // parseJsonBlock also slices by byte offset) is unaffected. const line = rawLine.replace(/\r$/, ''); if (line.trim() === '') continue; const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/); if (!m) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger frontmatter line is not key: value: ${JSON.stringify(line)}`, ); } const [, key, valueStr] = m; const trimmed = valueStr.trim(); if (/^-?\d+$/.test(trimmed)) { out[key] = Number(trimmed); } else if (/^-?\d+\.\d+$/.test(trimmed)) { out[key] = Number(trimmed); } else { // String — strip surrounding quotes if present. out[key] = (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ? trimmed.slice(1, -1) : trimmed; } } return out; } function parseJsonBlock(raw: string, expectedTotal?: number): WindowEntry[] { const span = locateJsonBlock(raw, expectedTotal); if (!span.ok) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, span.reason === 'missing-open' ? 'Ledger missing JSON code block for entries.' : 'Ledger JSON code block not terminated.', ); } const jsonText = raw.slice(span.span.bodyStart, span.span.bodyEnd).trim(); let parsed: unknown; try { parsed = JSON.parse(jsonText); } catch (e) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger JSON block failed to parse: ${(e as Error).message}`, ); } if (!Array.isArray(parsed)) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, 'Ledger JSON block must be an array.', ); } return parsed.map(validateEntryShape); } function validateEntryShape(e: unknown, i: number): WindowEntry { if (typeof e !== 'object' || e === null) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} is not an object.`, ); } const o = e as Record; const required = ['id', 'kind', 'phase', 'file', 'description', 'status', 'reason', 'recorded_at']; for (const k of required) { if (!(k in o)) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} missing required field: ${k}`, ); } } if (typeof o.id !== 'number' || !Number.isInteger(o.id) || o.id < 1) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has invalid id.`, ); } if (typeof o.kind !== 'string' || !KIND_SET.has(o.kind)) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has invalid kind: ${JSON.stringify(o.kind)}`, ); } if (typeof o.status !== 'string' || !['open', 'waived', 'fixed'].includes(o.status)) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has invalid status: ${JSON.stringify(o.status)}`, ); } if (typeof o.description !== 'string' || typeof o.reason !== 'string') { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger entry ${i} has non-string description/reason.`, ); } const phaseStr = typeof o.phase === 'string' ? o.phase : (o.phase == null ? '' : typeof o.phase === 'number' || typeof o.phase === 'boolean' ? String(o.phase) : ''); const recordedStr = typeof o.recorded_at === 'string' ? o.recorded_at : (o.recorded_at == null ? '' : typeof o.recorded_at === 'number' || typeof o.recorded_at === 'boolean' ? String(o.recorded_at) : ''); const resolvedStr = typeof o.resolved_at === 'string' ? o.resolved_at : (o.resolved_at == null ? null : typeof o.resolved_at === 'number' || typeof o.resolved_at === 'boolean' ? String(o.resolved_at) : null); return { id: o.id, kind: o.kind as WindowKind, phase: phaseStr, file: typeof o.file === 'string' ? o.file : '', line: o.line == null ? null : (Number(o.line) || null), description: o.description, status: o.status as WindowStatus, reason: o.reason, recorded_at: recordedStr, resolved_at: resolvedStr, // #4487: NOT in `required` above -- an entry recorded before this field // existed has no `milestone` key at all, and that must parse cleanly. // Preserve the absence itself -- by not materializing the property at // all, via the conditional spread below, rather than assigning it // `milestone: undefined` (an object literal property set to `undefined` // is still an OWN property; `'milestone' in entry` reads true either // way) -- so a genuinely absent key stays absent both to `in` and to // JSON.stringify on re-render. Collapsing absence to an explicit null // instead would stamp every legacy entry with permanent // `"milestone": null` noise the moment the ledger is next touched. A // genuinely-recorded-but-unresolvable milestone (set by appendWindow) // is still an explicit null and round-trips as one. ...('milestone' in o ? { milestone: typeof o.milestone === 'string' ? o.milestone : null } : {}), }; } export function parseLedger(raw: string): Ledger { const fm = parseFrontmatterStrict(raw); if (fm.schema_version !== SCHEMA_VERSION) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger schema_version must be ${SCHEMA_VERSION}; got ${JSON.stringify(fm.schema_version)}.`, ); } const requiredCounts = ['open_count', 'waived_count', 'fixed_count', 'total_count']; for (const k of requiredCounts) { const v = fm[k]; if (typeof v !== 'number' || !Number.isInteger(v)) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger ${k} must be an integer; got ${JSON.stringify(v)}.`, ); } } if (typeof fm.last_updated !== 'string') { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger last_updated must be a string; got ${JSON.stringify(fm.last_updated)}.`, ); } const entries = parseJsonBlock(raw, typeof fm.total_count === 'number' ? fm.total_count : undefined); const ledger: Ledger = { schema_version: SCHEMA_VERSION, open_count: typeof fm.open_count === 'number' ? fm.open_count : 0, waived_count: typeof fm.waived_count === 'number' ? fm.waived_count : 0, fixed_count: typeof fm.fixed_count === 'number' ? fm.fixed_count : 0, total_count: typeof fm.total_count === 'number' ? fm.total_count : 0, last_updated: typeof fm.last_updated === 'string' ? fm.last_updated : '', entries, }; // Cross-check: frontmatter counts must agree with entries-derived counts. const recomputed = recomputeCounts(ledger); if ( recomputed.open_count !== ledger.open_count || recomputed.waived_count !== ledger.waived_count || recomputed.fixed_count !== ledger.fixed_count || recomputed.total_count !== ledger.total_count ) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger counts disagree with entries: frontmatter open/waived/fixed/total=` + `${ledger.open_count}/${ledger.waived_count}/${ledger.fixed_count}/${ledger.total_count}` + ` but entries yield ${recomputed.open_count}/${recomputed.waived_count}/${recomputed.fixed_count}/${recomputed.total_count}.`, ); } return ledger; } export function renderLedger(ledger: Ledger): string { const fm = [ '---', `schema_version: ${ledger.schema_version}`, `open_count: ${ledger.open_count}`, `waived_count: ${ledger.waived_count}`, `fixed_count: ${ledger.fixed_count}`, `total_count: ${ledger.total_count}`, `last_updated: ${ledger.last_updated}`, '---', '', ].join('\n'); const header = [ '# Broken Windows Ledger', '', '> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/gsd-ship` blocks while `open_count > 0`.', '> Waive with `gsd-tools windows waive ""` (reason required).', '> Mark fixed with `gsd-tools windows fixed `.', '', ].join('\n'); const table = renderTable(ledger.entries); const jsonBlock = [JSON_FENCE_OPEN, JSON.stringify(ledger.entries, null, 2), JSON_FENCE_CLOSE, ''].join('\n'); return [fm, header, table, '', jsonBlock].join('\n'); } export function renderTable(entries: WindowEntry[]): string { if (entries.length === 0) { return [ TABLE_HEADER_LINE, '|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|', '| _(none)_ | | | | | _No windows recorded._ | | | | |', ].join('\n'); } const rows = [ TABLE_HEADER_LINE, '|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|', ]; for (const e of entries) { // Escape backslash FIRST, then pipe — markdown table cells treat `\` as // the escape introducer, so a description containing `\|` would render // as an escaped pipe (i.e. a literal `|` inside the cell) and split the // column. Escaping `\` → `\\` first makes the subsequent `\|` replacement // unambiguous. (CodeQL: js/incomplete-sanitization — issue #1950 PR #2441.) const cell = (s: string | number | null) => String(s ?? '') .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|'); rows.push( [ '|', cell(e.id), '|', cell(e.phase), '|', cell(e.kind), '|', cell(e.file), '|', cell(e.line ?? ''), '|', cell(e.description), '|', cell(e.status), '|', cell(e.reason), '|', cell(e.recorded_at), '|', cell(e.resolved_at), '|', ].join(' '), ); } return rows.join('\n'); } /** * #3689: extract the exact markdown table region a rendered ledger emits — * the text `renderTable` produced, byte-for-byte — from a raw ledger file. * Used by `writeLedgerAtomic`'s drift guard to compare the on-disk table * against `renderTable()` without a table parser. * * Locates the JSON block with the same tolerant `locateJsonBlock` helper the * rest of the module uses (#3657), so a formatter-normalized 3-backtick * fence still resolves. Everything before the opening fence line, with * trailing blank lines dropped, is the candidate region. * * #3689: the region is bounded by finding the LAST occurrence of the fixed * `TABLE_HEADER_LINE` literal (anchored at a line start) within that * candidate text, then taking everything from there through its end — NOT * by scanning backward for a contiguous run of `|`-prefixed lines. A `|` * prefix scan cannot bound the region: `validateDescription` rejects only * empty strings and 4-backtick runs, so a description may contain a raw * `\n`, and `renderTable`'s `cell()` escapes `\` and `|` but not newlines. * Such a description renders a row that physically spans multiple file * lines, and the continuation line does not start with `|` — a prefix scan * either truncates the table or, when the row's tail is the last pre-fence * line, returns null immediately, bricking every subsequent write with * `WINDOWS_LEDGER_TABLE_DRIFT` on a ledger nobody hand-edited. Anchoring on * the header instead includes any such row whole, so `renderTable` * regenerates byte-identical text for it and the drift comparison passes. * * Returns null when the JSON block cannot be located, or no header line is * present. * * `expectedTotal` (#3689 review finding 2) is threaded straight into * `locateJsonBlock` so callers with trailing prose can disambiguate the real * ledger block from an unrelated fenced JSON array a user pasted below the * closing fence — without it, `locateJsonBlock`'s no-hint fallback picks the * LATEST array-shaped span, which is the prose block, not the ledger, and * every drift comparison then binds to the wrong table/JSON pairing. */ export function extractTableRegion(raw: string, expectedTotal?: number): string | null { const span = locateJsonBlock(raw, expectedTotal); if (!span.ok) return null; // bodyStart sits right before the newline (or CR) ending the opening fence // line; walk back to the start of that line. const fenceLineStart = raw.lastIndexOf('\n', span.span.bodyStart - 1) + 1; const before = raw.slice(0, fenceLineStart).replace(/\r\n/g, '\n'); let trimmedEnd = before.length; while (trimmedEnd > 0 && before[trimmedEnd - 1] === '\n') { trimmedEnd--; } const candidate = before.slice(0, trimmedEnd); // Find the LAST occurrence of TABLE_HEADER_LINE anchored at a line start — // a plain string scan rather than a regex, since the module is a leaf // (imports only node:fs/node:path) and cannot pull in the shared // escapeRegex() helper for a one-off fixed-literal search. let headerIndex = -1; let searchFrom = candidate.length; for (;;) { const idx = candidate.lastIndexOf(TABLE_HEADER_LINE, searchFrom); if (idx === -1) break; const atLineStart = idx === 0 || candidate[idx - 1] === '\n'; const atLineEnd = idx + TABLE_HEADER_LINE.length === candidate.length || candidate[idx + TABLE_HEADER_LINE.length] === '\n'; if (atLineStart && atLineEnd) { headerIndex = idx; break; } // #3689: lastIndexOf clamps a negative position into [0, length] per // spec, so `searchFrom = -1` would re-search from 0 and re-find the same // rejected match at idx===0 forever. Stop explicitly once there is // nowhere left to search — this makes the bound strictly decrease each // iteration, so the loop terminates within candidate.length steps. if (idx === 0) break; searchFrom = idx - 1; } if (headerIndex === -1) return null; return candidate.slice(headerIndex); } /** * #3689: diff two `renderTable` outputs by row id (the first cell of each * data row), skipping the header + separator lines (always exactly two). * A row whose line text differs between the two tables, or that is present * in only one of them, contributes its id to the result — this is what lets * the drift-guard error message name the specific drifted/table-only row(s) * rather than just saying "the table disagrees". */ function diffTableRowIds(expectedTable: string, actualTable: string): string[] { const rowId = (line: string): string => (line.split('|')[1] ?? '').trim(); const dataRows = (table: string): Map => { const lines = table.split('\n'); const map = new Map(); for (let i = 2; i < lines.length; i++) { const line = lines[i]; if (!line.startsWith('|')) continue; map.set(rowId(line), line); } return map; }; const expectedRows = dataRows(expectedTable); const actualRows = dataRows(actualTable); const ids = new Set(); for (const [id, line] of expectedRows) { if (actualRows.get(id) !== line) ids.add(id); } for (const id of actualRows.keys()) { if (!expectedRows.has(id)) ids.add(id); } return Array.from(ids).sort(); } // ─── I/O entry points ────────────────────────────────────────────────────── function ledgerPath(cwd: string): string { return path.join(cwd, '.planning', LEDGER_FILE_NAME); } // ─── #3780: cross-process ledger mutation lock ───────────────────────────── interface LedgerLockHandle { path: string; token: string; dev: number | null; ino: number | null } interface LockModule { acquireLock: ( lockPath: string, opts?: { maxAttempts?: number; waitForFresh?: boolean }, ) => LedgerLockHandle | null; releaseLock: (handle: LedgerLockHandle | null) => void; } /** * The SHARED hardened cross-process lock primitive (single source of truth * for capability-lifecycle + capability-consent, extracted so locks cannot * diverge). Required LAZILY: capability-lock captures the process start time * at module load — a `ps` subprocess on macOS, PowerShell on win32 — and * this module is loaded by lock-free readers (`windows status`, the * /gsd-ship gate) that must not pay that per-invocation cost (#3780 * review). `require` is cached, so writers pay it once per process. */ let _lockMod: LockModule | null = null; function ledgerLock(): LockModule { if (_lockMod === null) { /* eslint-disable @typescript-eslint/no-require-imports */ _lockMod = require('./capability-lock.cjs') as LockModule; /* eslint-enable @typescript-eslint/no-require-imports */ } return _lockMod; } /** * Budget mirrors capability-consent's CONSENT_LOCK_MAX_ATTEMPTS: two * genuinely-racing writers must SERIALIZE, not fail. The ledger's critical * section is sub-millisecond and the primitive backs off ~25-50ms per * attempt, so 50 attempts is orders of magnitude beyond any real contention * while keeping the worst case (a holder that never releases until the * primitive's own liveness/deadman protocol reclaims it) bounded at ~2s * before the typed refusal below. */ const LEDGER_LOCK_MAX_ATTEMPTS = 50; function ledgerLockPath(cwd: string): string { return path.join(cwd, '.planning', '.WINDOWS.lock'); } function acquireLedgerLock(cwd: string): LedgerLockHandle | null { return ledgerLock().acquireLock(ledgerLockPath(cwd), { maxAttempts: LEDGER_LOCK_MAX_ATTEMPTS, // A contended fresh/live holder is WAITED FOR (back off + retry), not // failed-fast — racing wave-level executors serialize (issue #3780). waitForFresh: true, }); } function releaseLedgerLock(handle: LedgerLockHandle | null): void { ledgerLock().releaseLock(handle); } /** * Run `fn` (a full ledger read-compute-write cycle) while holding the * cross-process ledger lock. Throws a typed WindowsError — never falls back * to an unlocked mutation — when the lock cannot be acquired within the * budget, mirroring capability-consent finding 3: a locked store must refuse * the write rather than silently race for it. Readers (cmdWindowsStatus, the * ship gate) deliberately do NOT take this lock: the atomic rename already * gives them a whole-file snapshot. * * EXPORTED (#3780) because `withLedgerLock` is the ONE serialization seam * for WINDOWS.md: every writer of the ledger — the cmd* entry points here * and any sibling module with its own read-compute-write cycle on the same * file (refactor-trigger-command-router's strict-window record/resolve) — * must hold this lock, or the lost-update race #3780 fixed survives on that * path. */ export function withLedgerLock(cwd: string, fn: () => T): T { const handle = acquireLedgerLock(cwd); if (!handle) { throw new WindowsError( REASON.WINDOWS_LEDGER_LOCK, `Another writer holds the ledger lock at ${ledgerLockPath(cwd)}; WINDOWS.md ` + 'mutations are serialized per project. Re-run the command once the other ' + 'writer finishes — the lock is reclaimed automatically if its holder died.', ); } try { return fn(); } finally { releaseLedgerLock(handle); } } function readLedgerOrNull(cwd: string): Ledger | null { const p = ledgerPath(cwd); let raw: string; try { raw = fs.readFileSync(p, 'utf8'); } catch (e: unknown) { // ENOENT is the only "no ledger yet" case. Every other fs error (EACCES, // EPERM, EIO, ENOTDIR, EBADF, ...) must NOT be silently coerced to "empty // ledger" — that would fail the ship gate OPEN on an unreadable ledger, // contradicting the workflow's documented "fail closed on unreadable" // invariant (issue #1950 review H2). Propagate as malformed so the gate // blocks and the operator sees a real diagnostic. const code = (e && typeof e === 'object' && 'code' in e) ? String((e as { code?: unknown }).code) : ''; if (code === 'ENOENT') return null; throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Could not read ledger at ${p} (${code || 'unknown fs error'}): ${(e as Error).message}.`, ); } // parseLedger throws WindowsError on malformed content — caller surfaces it. return parseLedger(raw); } function ensurePlanningDir(cwd: string): void { const dir = path.join(cwd, '.planning'); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } /** * Errnos that Windows throws transiently on rename when a reader or antivirus * scanner holds the target. We retry through these; anything else propagates. * * NOTE (issue #1950 review L3): the retry uses a short busy-wait rather than * setTimeout — this is a synchronous CLI path with no event loop to yield on, * and the cumulative wait is bounded at 25+50+100+200 = 375ms across 5 attempts. * If a future caller moves this onto an async path, swap to awaitable sleeps. */ const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']); const RENAME_MAX_ATTEMPTS = 5; const RENAME_BACKOFF_MS = 25; function renameWithRetry(tmp: string, target: string): void { let lastErr: unknown; for (let attempt = 0; attempt < RENAME_MAX_ATTEMPTS; attempt++) { try { fs.renameSync(tmp, target); return; } catch (err: unknown) { lastErr = err; const code = (err && typeof err === 'object' && 'code' in err) ? String((err as { code?: unknown }).code) : ''; if (code && RENAME_RETRY_ERRNOS.has(code) && attempt < RENAME_MAX_ATTEMPTS - 1) { // Exponential-ish backoff: 25ms, 50ms, 100ms, 200ms. const delay = RENAME_BACKOFF_MS * Math.pow(2, attempt); const start = Date.now(); while (Date.now() - start < delay) { // Busy-wait a very short time — Windows transient locks usually clear in <100ms. } continue; } throw err; } } throw lastErr; } function writeLedgerAtomic(cwd: string, ledger: Ledger): void { ensurePlanningDir(cwd); const p = ledgerPath(cwd); const tmp = `${p}.${process.pid}.tmp`; // #2893: preserve any prose below the JSON ledger block. renderLedger // reconstructs frontmatter + header + table + JSON — it does not include // trailing prose that users may have written below the closing fence. // Without this, every append/waive/fixed silently destroys that prose. let trailingProse = ''; // #3689: read the pre-image once into `existing` outside the catch, rather // than doing every subsequent step inside a bare try/catch, so that a // WindowsError thrown by the drift guard below propagates instead of being // swallowed by the ENOENT handler meant only for "no ledger yet". let existing: string | null = null; try { existing = fs.readFileSync(p, 'utf8'); } catch (e: unknown) { // #1950-H2 / #3689: ENOENT is the only "no ledger yet" case — mirror // readLedgerOrNull's discipline exactly. A bare catch here would let // EACCES/EIO/ENOTDIR/etc. fall through as "no pre-image", silently // skipping BOTH the #2893 prose preservation and the drift guard below // and proceeding to overwrite an unreadable file — a guard bypassable by // making the pre-image unreadable is not a guard. const code = (e && typeof e === 'object' && 'code' in e) ? String((e as { code?: unknown }).code) : ''; if (code !== 'ENOENT') { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Could not read ledger at ${p} (${code || 'unknown fs error'}): ${(e as Error).message}.`, ); } // File doesn't exist yet (first write) — no prose to preserve, and // nothing on disk to disagree with, so the drift guard below is skipped. } if (existing !== null) { // #3689 review finding 2 / #3689 bug discovery: both the #2893 prose // span AND the drift guard below must disambiguate `locateJsonBlock` // against the SAME pre-image ledger block, so this is computed ONCE, // hoisted above both uses. expectedTotal MUST be derived from the // PRE-IMAGE's own frontmatter (never `ledger.total_count`, which is // already post-mutation — e.g. N+1 on an append): #2893 exists precisely // because operators may paste prose below the closing fence, and that // prose can itself contain a fenced JSON array of a different length. // Passing the post-mutation total here (as a since-fixed #3689 review // pass once did for the guard alone) makes locateJsonBlock's expectedTotal // scan find nothing against the pre-image — no span has N+1 entries yet — // so it silently falls through to the no-hint fallback, which binds to // the LATEST array-shaped span: the prose block, not the ledger. Left // unfixed, that means the #2893 prose-preservation span itself would // resolve to the prose fence's `afterClose`, silently dropping // everything between the real ledger block and the prose block — // including the operator's own prose ABOVE that array — on every // append. This is exactly the failure #2893 was written to prevent, // reintroduced through the disambiguation hint; it is caught here by // deriving the hint from the pre-image, not the post-mutation ledger, // for BOTH call sites below. If the pre-image frontmatter cannot be // parsed unambiguously, that is itself the ambiguous case — fail closed // rather than falling back to the no-hint scan. let preImageExpectedTotal: number; try { const preFm = parseFrontmatterStrict(existing); if (typeof preFm.total_count !== 'number' || !Number.isInteger(preFm.total_count)) { throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger frontmatter total_count in ${p} is not an integer; refusing to write — ` + 'the ledger JSON block cannot be identified unambiguously.', ); } preImageExpectedTotal = preFm.total_count; } catch (e) { if (e instanceof WindowsError) throw e; throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Ledger frontmatter in ${p} could not be parsed (${(e as Error).message}); refusing ` + 'to write — the ledger JSON block cannot be identified unambiguously.', ); } // #2893: search for the CLOSING fence starting AFTER the opening fence. // The span is located with the same tolerant + disambiguated fence rules // parseJsonBlock uses (#3657), so a formatter-normalized 3-backtick ledger // keeps its prose too — a literal-width search here would find no block // and silently drop everything below the ledger on the next write. The // hint passed here is `preImageExpectedTotal` (pre-image derived, see // above) — NOT `ledger.total_count` — so this binds to the same span the // drift guard below does. const span = locateJsonBlock(existing, preImageExpectedTotal); if (span.ok) { const afterFence = existing.slice(span.span.afterClose); // Drop leading newlines; keep the rest as prose. trailingProse = afterFence.replace(/^(?:\r?\n)+/, ''); } // #3689 review finding 2: refuse the write if the on-disk table has // drifted from the on-disk JSON — the source of truth — BEFORE anything // is regenerated. Baseline is the ON-DISK entries, not `ledger` (already // the post-mutation state: an appended entry or a changed status); // comparing against `ledger` would report drift on every legitimate // write. const onDiskEntries = parseJsonBlock(existing, preImageExpectedTotal); const expectedTable = renderTable(onDiskEntries); const actualTable = extractTableRegion(existing, preImageExpectedTotal); if (actualTable === null) { throw new WindowsError( REASON.WINDOWS_LEDGER_TABLE_DRIFT, `Ledger table region could not be located in ${p}; refusing to write. Edit the ` + 'fenced JSON block directly — the sole source of truth — or delete the corrupted ' + 'table region and let gsd-tools regenerate it; never hand-edit the rendered table.', ); } if (actualTable !== expectedTable) { const driftedIds = diffTableRowIds(expectedTable, actualTable); // #3689 review finding 3: a header/separator-only drift (e.g. a // hand-edited column name or mangled separator) produces no data-row // diffs, so driftedIds is empty — naming nothing would read "...for // row id(s): .". Say what actually differs instead. const driftDescription = driftedIds.length > 0 ? `for row id(s): ${driftedIds.join(', ')}` : "in its header or separator row (no data row differs from the expected rendering)"; throw new WindowsError( REASON.WINDOWS_LEDGER_TABLE_DRIFT, `Ledger table in ${p} disagrees with the fenced JSON entries (the sole source of ` + `truth) ${driftDescription}. Edit the fenced JSON block ` + 'directly, or discard the table edit and re-run the command so gsd-tools ' + 'regenerates the table; never hand-edit the rendered table.', ); } } const rendered = renderLedger(ledger); const content = trailingProse ? `${rendered}${trailingProse}` : rendered; fs.writeFileSync(tmp, content, 'utf8'); try { renameWithRetry(tmp, p); } catch (err) { // Clean up the orphaned tmp file so repeated failures don't accumulate // `.planning/WINDOWS.md..tmp` files (issue #1950 review M1). Best-effort: // unlink failures (e.g., already gone) are swallowed. try { fs.unlinkSync(tmp); } catch { /* best-effort cleanup */ } throw err; } } function nowIso(): string { return new Date().toISOString(); } /** Emit a JSON result to stdout in the canonical shape. */ function emit(obj: unknown): void { process.stdout.write(JSON.stringify(obj, null, 2)); } /** `gsd-tools windows status [--raw]`. */ export function cmdWindowsStatus(cwd: string, opts: { raw?: boolean } = {}): void { let ledger: Ledger; try { ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); } catch (e) { if (e instanceof WindowsError) throw e; throw new WindowsError( REASON.WINDOWS_LEDGER_MALFORMED, `Unexpected error reading ledger: ${(e as Error).message}`, ); } void opts; // status output is JSON in both human and raw modes (single shape) emit({ ok: true, ledger }); } /** `gsd-tools windows append --kind K --phase N [--file F] [--line L] --description D`. */ export function cmdWindowsAppend( cwd: string, args: string[], opts: { raw?: boolean } = {}, ): void { void opts; const parsed = parseArgs(args, { flags: ['--kind', '--phase', '--file', '--line', '--description'], required: ['--kind', '--phase', '--description'], }); // #3780: the whole read-compute-write cycle — snapshot, milestone stamp, // id allocation, atomic rename — holds the ledger lock, so two parallel // invocations can no longer compute the same nextId from the same snapshot // and silently lose the first append to the second rename. withLedgerLock(cwd, () => { let ledger: Ledger; try { ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); } catch (e) { if (e instanceof WindowsError) throw e; throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message); } // #4487: stamp the workstream's resolved milestone at record time -- the // same STATE.md-first, ROADMAP-fallback resolution workstream-inventory.cts // already uses. Best-effort: an unreadable/missing STATE.md or ROADMAP.md // resolves to null, same as an entry recorded before this field existed. const milestone = workstreamInventory.readCurrentMilestoneVersion( path.join(cwd, '.planning', 'STATE.md'), path.join(cwd, '.planning', 'ROADMAP.md'), ); const result = appendWindow( ledger, { kind: parsed.values['--kind'] as WindowKind, phase: parsed.values['--phase'] ?? '', file: parsed.values['--file'] ?? '', line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']), description: parsed.values['--description'] ?? '', milestone, }, { now: nowIso() }, ); writeLedgerAtomic(cwd, result.ledger); emit({ ok: true, ledger: result.ledger, entry: result.entry }); }); } /** `gsd-tools windows waive ""`. */ export function cmdWindowsWaive( cwd: string, args: string[], opts: { raw?: boolean } = {}, ): void { void opts; const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 2 }); const idStr = positionals[0]; const reason = positionals[1]; const id = parseIdOrThrow(idStr); // #3780: same serialization as append — a concurrent append holding a stale // snapshot would otherwise overwrite the waive and resurrect the entry. withLedgerLock(cwd, () => { let ledger: Ledger; try { ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); } catch (e) { if (e instanceof WindowsError) throw e; throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message); } const updated = markWaived(ledger, id, reason ?? '', { now: nowIso() }); writeLedgerAtomic(cwd, updated); emit({ ok: true, ledger: updated }); }); } /** `gsd-tools windows fixed `. */ export function cmdWindowsMarkFixed( cwd: string, args: string[], opts: { raw?: boolean } = {}, ): void { void opts; const { positionals } = parseArgs(args, { flags: [], required: [], positionals: 1 }); const id = parseIdOrThrow(positionals[0]); // #3780: same serialization as append — a concurrent writer holding a // stale snapshot would otherwise overwrite the resolved status. withLedgerLock(cwd, () => { let ledger: Ledger; try { ledger = readLedgerOrNull(cwd) ?? emptyLedger(nowIso()); } catch (e) { if (e instanceof WindowsError) throw e; throw new WindowsError(REASON.WINDOWS_LEDGER_MALFORMED, (e as Error).message); } const updated = markFixed(ledger, id, { now: nowIso() }); writeLedgerAtomic(cwd, updated); emit({ ok: true, ledger: updated }); }); } function parseIdOrThrow(raw: string | undefined): number { if (raw == null || raw === '') { throw new WindowsError( REASON.WINDOWS_INVALID_ID, 'Window id is required.', ); } const n = Number(raw); if (!Number.isInteger(n) || n < 1) { throw new WindowsError( REASON.WINDOWS_INVALID_ID, `Window id must be a positive integer (got: ${JSON.stringify(raw)}).`, ); } return n; } /** Minimal argv parser — flag values via `--flag value` or `--flag=value`. */ function parseArgs( args: string[], spec: { flags: string[]; required: string[]; positionals?: number }, ): { values: Record; positionals: string[] } { const values: Record = {}; const positionals: string[] = []; const flagSet = new Set(spec.flags); for (let i = 0; i < args.length; i++) { const a = args[i]; if (a == null) continue; if (a.startsWith('--')) { const eq = a.indexOf('='); const flagName = eq === -1 ? a : a.slice(0, eq); if (!flagSet.has(flagName)) { throw new WindowsError( REASON.WINDOWS_USAGE, `Unknown flag: ${flagName}`, ); } if (eq !== -1) { values[flagName] = a.slice(eq + 1); } else { const next = args[i + 1]; if (next == null || next.startsWith('--')) { if (!(flagName in values)) values[flagName] = undefined; } else { values[flagName] = next; i++; } } } else { positionals.push(a); } } for (const r of spec.required) { if (values[r] == null || values[r] === '') { throw new WindowsError( REASON.WINDOWS_USAGE, `Missing required flag: ${r}`, ); } } const want = spec.positionals ?? 0; if (positionals.length < want) { throw new WindowsError( REASON.WINDOWS_USAGE, `Expected ${want} positional argument(s); got ${positionals.length}.`, ); } return { values, positionals }; }