* test(#3689): failing-first coverage for the ledger table/JSON agreement guard `.planning/WINDOWS.md` renders its markdown table from the fenced JSON that is its source of truth, but nothing checks the two still agree before a write overwrites the table. `windows append` / `waive` / `fixed` therefore discard a drifted cell silently, and erase a table-only row entirely, both at exit 0. Adds to tests/broken-windows.test.cjs: - five refusal cases that fail today, covering all three write commands, a drifted cell, a table-only row, and drift on a non-first row; each asserts the typed reason via GSD_JSON_ERRORS and that the file is byte-identical after the refusal, so a guard that refuses only after writing cannot pass - six anti-tightening pins that must stay green: an agreeing ledger, the first-write ENOENT path, #2893 trailing-prose preservation, #3657 3-backtick fence tolerance, escaped pipes and backslashes in a description, and the zero-entry placeholder table - a fast-check property pinning the round trip the guard depends on — extractTableRegion(renderLedger(l)) === renderTable(l.entries) — because a false refusal on a clean ledger would be worse than the bug Fixtures are built by running the real CLI and then perturbing only the table, so frontmatter and JSON stay consistent and the pre-existing counts cross-check still passes; a hand-written ledger would pass these for the wrong reason. Refs #3689 * fix(#3689): refuse a ledger write when the rendered table disagrees with its JSON `.planning/WINDOWS.md` renders its markdown table from the fenced JSON that is its source of truth, and `writeLedgerAtomic` regenerated that table on every `windows append` / `waive` / `fixed` without ever checking the two still agreed. A hand-edited cell was silently reverted; a row that existed only in the table vanished entirely. Both at exit 0, with nothing on stdout to say so. The write seam now compares the on-disk table against `renderTable(<entries parsed from the on-disk JSON>)` before regenerating anything, and refuses with a typed `windows_ledger_table_drift` error naming the drifted row ids and the remedy. Because the check sits at the single write seam, all three commands inherit it, and the file is left byte-identical on refusal. Deliberately not enforced in `parseLedger`: hardening the read would break `windows status` and the ship gate on exactly the ledgers an operator needs to inspect to diagnose the drift. Two hazards handled explicitly, both discovered in review of the first draft: - The pre-image read now distinguishes ENOENT from every other errno, per the #1950-H2 fail-closed-on-unreadable invariant `readLedgerOrNull` already honors. A bare catch would have let an unreadable pre-image skip the guard and write anyway. - Both the entries baseline and the table extraction pass the pre-image's own frontmatter `total_count` to `locateJsonBlock`. Without that hint the no-expectation fallback binds to the LATEST fenced JSON array in the file, which is the operator's prose block whenever that prose contains one — the exact case #2893 exists for — refusing every write on a ledger that never drifted. A regression test covers it. Also extends the CONTEXT.md Broken Windows Ledger glossary entry: the table is a third projection of the same source, cross-checked at the write seam, and the frozen REASON enum gains WINDOWS_LEDGER_TABLE_DRIFT. Fixes #3689 * fix(#3689): bind prose preservation to the pre-image's own ledger block Found while reviewing the table drift guard: the #2893 trailing-prose preservation in `writeLedgerAtomic` passed `ledger.total_count` — the POST-mutation count — as the disambiguation hint for a lookup over the PRE-image. On an append the pre-image holds N entries while the hint says N+1, so the hint can never match and `locateJsonBlock` falls through to its last-array-shaped-span fallback. When the operator's trailing prose itself contains a fenced JSON array — the ordinary case #2893 was written to protect — that prose block wins the fallback. The preserved region is then computed from the prose fence rather than the ledger fence, and everything between them, including the operator's own text above the array, is silently dropped on the next write. Reproduced against the real CLI: a prose block reading "Operator notes above the array, IMPORTANT DO NOT LOSE THIS TEXT." plus a fenced 3-element array came back empty after one `windows append`. Both the prose lookup and the drift guard now share one pre-image-derived `preImageExpectedTotal`, taken from the pre-image's own frontmatter, so they bind to the same and correct block. The existing trailing-prose regression test is strengthened to assert the prose survives byte-for-byte rather than merely that the command exited 0 — asserting only the exit code is why this was invisible. Refs #3689 * fix(#3689): anchor table extraction on the header row, not a line-prefix scan Independent review found the drift guard could brick a ledger nobody had hand-edited. `validateDescription` accepts a description containing a raw newline, and `renderTable`'s cell escaping covers backslash and pipe but not newlines — so such a description renders a row that physically spans two file lines, the second of which does not begin with `|`. `extractTableRegion` bounded the table by walking backward over the contiguous run of `|`-prefixed lines, so it stopped at that split. In the common case where the row's tail is the last line before the fence it returned null, and every subsequent append/waive/fixed was refused with "table region could not be located" — permanently, with no CLI recovery path, on a ledger that never drifted. A false refusal is worse than the bug this guard exists to fix. The region is now anchored on the header row `renderTable` always emits, running from its last line-start occurrence to the end of the pre-fence text. The boundary is the fence rather than a line prefix, so a multi-line row is captured whole, re-renders byte-identically, and compares equal. The header literal is hoisted to one constant both `renderTable` branches and the extractor share, so the two surfaces cannot drift apart. Deliberately unchanged: `cell()` and `validateDescription`. The cosmetic corruption a newline causes in the rendered table is pre-existing, and either escaping it or rejecting the input would change what existing ledgers render to or what input is accepted. Also closes a coverage gap the standards review raised: the non-ENOENT pre-image read branch — the one that stops an unreadable file from bypassing the guard — now has a behavioral test that injects EACCES by monkeypatching `fs.readFileSync` for that one path and restoring it in a `finally`, never by `chmod 0o000` (root ignores mode bits, so that would pass with zero coverage). The #3689 property generator no longer strips newlines out of descriptions, which is why this was invisible to it. Refs #3689 * chore(changeset): backfill PR number for #3689 fragment * chore(changeset): backfill PR number for #3689 fragment * fix(#3689): terminate the header scan when the match sits at index 0 `extractTableRegion`'s backward search for the table header could loop forever. On a rejected match at index 0 it set `searchFrom = idx - 1`, i.e. `-1`; `String.prototype.lastIndexOf` clamps its position argument into `[0, length]`, so the next iteration searched from 0, found the same match, rejected it identically, and set `-1` again. The loop made no progress. Reachable only through the exported `extractTableRegion` — `writeLedgerAtomic` reaches it after `parseFrontmatterStrict` has already succeeded, so the candidate region begins with the `---` frontmatter fence and a match at index 0 is impossible. Latent rather than live, but an exported `for(;;)` that can fail to advance is not something to ship. Confirmed by running the pre-fix compiled function on `TABLE_HEADER_LINE + 'X\n' + <a valid json fence>` as a backgrounded child: it was still alive after five seconds having printed nothing, and had to be killed. Post-fix the same input returns `null` promptly — correct, since the sole header occurrence fails the end-of-line test and no valid header exists. A regression here would stall the suite rather than fail it, so the new test also asserts the returned value rather than relying on termination alone. No wall-clock assertion is involved. Refs #3689 * test(#3034): publish the lane trace before the done-file that releases dependents `preservesSelectionOrderParallelDespiteCompletionOrder` forces a reverse completion order with a dependency chain rather than sleeps: each stub lane waits on `done-<dep>` before finishing. It then ended with touch "$RUN_DIR/done-$slug" echo "end:$slug" >> "$TRACE" Those are two unsynchronized operations in separate shell processes. A dependent's `wait_for_file` unblocks the instant the upstream's `touch` lands, but the upstream's own `echo` has not necessarily run — so if the upstream is descheduled between the two, the dependent can run its whole body and append its `end:` line first. The done-file was published before the state it signals. Observed on the remote runner as `[end:claude, end:codex, end:gemini]` where selection order demands `[end:claude, end:gemini, end:codex]`. The failure was in the fixture's own self-check, before it reached the assertion #3034 exists to make. Not a flake and not a wall-clock margin: this branch passed the full suite twice at 14f494644 and 90c5d7a03, and the only delta in the failing run was one added test in tests/broken-windows.test.cjs — an unrelated module. Adding load elsewhere in the suite was enough to invert it, which is what a real race does. Swapping the pair establishes a genuine happens-before: anything a dependent can observe is written before the file that releases it. A comment records why, so the order is not tidied back. The production path is unaffected and was independently confirmed correct — `invoke_reviewers` joins every lane with `wait`, then aggregates by iterating DISPATCH_SLUGS in selection order, reading per-slug result files. It consumes no completion-order signal at all. Refs #3034 --------- Co-authored-by: sim <sim@local>
1247 lines
47 KiB
TypeScript
1247 lines
47 KiB
TypeScript
/**
|
|
* 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 ONLY: node:fs, node:path. 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: <ISO-8601>
|
|
* ---
|
|
* # Broken Windows Ledger
|
|
* <human-readable prose>
|
|
* ````json
|
|
* [ <entries array, canonical 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: <Ledger>, ... }
|
|
* failure: { ok: false, reason: <REASON.*>, message: <string> }
|
|
* 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';
|
|
|
|
// ─── 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',
|
|
});
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** Input shape for appendWindow — id/status/timestamps are assigned by the fn. */
|
|
export type WindowInput = Pick<WindowEntry, 'kind' | 'phase' | 'description'> &
|
|
Partial<Pick<WindowEntry, 'file' | 'line'>>;
|
|
|
|
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): NOT safe for concurrent writers. Two
|
|
* parallel `gsd_run windows append` invocations both read the same snapshot,
|
|
* both compute the same nextId, both write — the second atomic rename wins
|
|
* and the first append (and the entry it added) is silently lost. This is
|
|
* acceptable in the current single-executor-per-phase model; document if the
|
|
* executor ever gains parallel wave-level append.
|
|
*/
|
|
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,
|
|
};
|
|
|
|
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: <number|string>` per line. Throws on any
|
|
* structural deviation — fail-closed on drift.
|
|
*/
|
|
function parseFrontmatterStrict(raw: string): Record<string, number | string> {
|
|
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<string, number | string> = {};
|
|
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<string, unknown>;
|
|
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,
|
|
};
|
|
}
|
|
|
|
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 <id> "<reason>"` (reason required).',
|
|
'> Mark fixed with `gsd-tools windows fixed <id>`.',
|
|
'',
|
|
].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(<on-disk JSON entries>)` 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<string, string> => {
|
|
const lines = table.split('\n');
|
|
const map = new Map<string, string>();
|
|
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<string>();
|
|
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);
|
|
}
|
|
|
|
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.<pid>.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'],
|
|
});
|
|
|
|
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 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'] ?? '',
|
|
},
|
|
{ now: nowIso() },
|
|
);
|
|
writeLedgerAtomic(cwd, result.ledger);
|
|
emit({ ok: true, ledger: result.ledger, entry: result.entry });
|
|
}
|
|
|
|
/** `gsd-tools windows waive <id> "<reason>"`. */
|
|
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);
|
|
|
|
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 <id>`. */
|
|
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]);
|
|
|
|
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<string, string | undefined>; positionals: string[] } {
|
|
const values: Record<string, string | undefined> = {};
|
|
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 };
|
|
}
|