Files
msd-core/src/broken-windows.cts
Tom Boucher d16a66479a feat(#1950): broken-windows ledger — cross-phase defect register gating ship (#2441)
* feat(#1950): broken-windows ledger — cross-phase defect register gating ship

Adds a new  capability (#1950) that operationalizes GSD's
no-defer discipline as a tracked, enforced artifact:
accumulates stubs, TODOs, skipped tests, unrun verifies, and unmet truths
across phases, and /gsd-ship blocks while any entry is open.

Implementation:
- src/broken-windows.cts → gsd-core/bin/lib/broken-windows.cjs: typed IR +
  I/O entry points (parseLedger/renderLedger/appendWindow/markWaived/markFixed
  + cmdWindowsStatus/Append/Waive/MarkFixed). Frozen REASON enum for typed
  error assertions. Windows-safe atomic rename with retry on transient
  EPERM/EBUSY/EACCES.
- gsd-tools.cjs: new  subcommand (status | append | waive | fixed),
  wired via routeWindows + HOST_COMMAND_ROUTERS.windows.
- capabilities/broken-windows/capability.json: one ship:pre gate with
  artifact-frontmatter-equals predicate on WINDOWS.md open_count == 0.
  activationKey windows.enabled (default true) + sibling windows.enforce
  (default true, separate so tracking can precede enforcement).
- gsd-core/workflows/ship.md: capId==broken-windows branch in preflight,
  sibling to security — reads gsd_run windows status --raw, fails closed
  on open_count > 0 or unreadable ledger.
- agents/gsd-executor.md: extends the existing ## Known Stubs instruction
  to also append to WINDOWS.md via gsd_run windows append (best-effort,
  never blocks execution).
- agents/gsd-verifier.md: new Step 8b — record unmet truths + human-verify
  items in WINDOWS.md.
- gsd-core/workflows/progress.md: surfaces open + waived counts.
- docs/COMMANDS.md + CONTEXT.md glossary entry + docs/INVENTORY.md:
  document the gate, waiver mechanism, and new module.
- tests/broken-windows.test.cjs: pure + CLI behavioral coverage + fast-check
  roundtrip property; fail-closed on malformed ledger; security boundary on
  path traversal in --file.

Backward-compatible: a project with no .planning/WINDOWS.md reports
open_count: 0 and ships cleanly. Disable enforcement per-project with
gsd config-set windows.enforce false (tracking continues, gate stays open).

* chore(#1950): ratchet size baselines, defer verifier integration

- Workflow size baseline: ship.md 25575→27928, progress.md 31789→32632
  (broken-windows preflight branch + open-windows surface).
- Agent size baseline: gsd-executor.md 46644→47951 (Known Stubs → also
  appends to WINDOWS.md). gsd-verifier.md unchanged.
- LARGE_CAP (49152) preempted the planned verifier integration
  (gsd-verifier.md was at 49140 pre-PR — 12 bytes of headroom, not the
  documented 'real headroom'). Verifier integration deferred to a follow-up
  PR that extracts the VERIFICATION.md template (lines 739-859) to
  gsd-core/references/ — a pre-existing cap-tightness defect this PR
  exposed but does not expand scope to fix. Verifier integration is not in
  the issue's acceptance criteria (executor writes is; unmet-truths
  recording was an enhancement, not a gate).

* fix(#1950): gate default-off, rename to workflow.windows_enforce, regen goldens

Test-failure-driven fixes after first gsd-test run on db8733c8f failed 44
cases (pre-existing structural tests encoded 'ship:pre has 1 gate' / 'all
caps off → empty hooks'):

- capability manifest: rename windows.enabled+windows.enforce (default
  true) → single federated key workflow.windows_enforce (default FALSE,
  opt-in). Matches security's workflow.security_enforce convention and
  makes the adr857 all-caps-off test pass without modification (the test's
  buildAllFalseConfig handles workflow.* out of the box). Default-OFF keeps
  the gate out of the registry's default ship:pre resolution so existing
  loop-hooks-ship-pre-e2e structural assertions (exactly 1 gate, capId
  'security') stay valid; users opt in via
  gsd config-set workflow.windows_enforce true.
- drop activationKey (security doesn't have one either; workflow.* key
  doubles as the activation toggle).
- regenerate docs/reference/capability-matrix.md to include broken-windows
  (capability-matrix-sync test).
- regenerate tests/fixtures/golden-install-parity/*.json (18 runtimes) —
  installer now emits the new capability + lib file.
- update CONTEXT.md, docs/COMMANDS.md, docs/FEATURES.md, ship.md,
  agents/gsd-executor.md to use the new key name and /gsd:colon slash
  syntax (slash-command-namespace test).
- restore accidentally-regressed /gsd:capture in progress.md.

Tracking-only by default; enforcement is opt-in. Acceptance criterion
'/gsd-ship fails while any ledger entry is open' is met when
workflow.windows_enforce=true (test fixture enables it).

* test(#1950): update ship:pre structural invariants for 2-gate registry

- loop-hooks-ship-pre-e2e: the registry now declares 2 gates at ship:pre
  (security + broken-windows), regardless of activation. Activation tests
  above still pin security-only or empty behavior via fixtures; these
  structural tests pin the REGISTRY shape, which has 2 gates as of #1950.
- workflow-size-baseline: ship.md 27928→27945 (workflow.windows_enforce
  rename added 17 bytes).

* fix(#1950): review H1+H2+M1+M2+M3 — fence-injection, EACCES fail-closed, cleanup, strict line, stryker

Adversarial isolated review (Step 6.3) found 2 HIGH findings that block
the PR and 3 mediums. All addressed:

H1 (HIGH): description containing the markdown 3-backtick fence would
terminate the ledger's JSON code block early inside JSON.stringify output
(JSON doesn't escape backticks), corrupting the file and bricking the
next parse. Fix: use a 4-backtick fence (json ... ) which
JSON.stringify cannot produce on its own, AND validate that no entry
text field contains a 4-backtick run (reject at append time with new
WINDOWS_INVALID_TEXT reason code). Locked by a regression test.

H2 (HIGH): readLedgerOrNull swallowed ALL fs errors as 'no ledger',
silently returning open_count:0 on EACCES/EPERM/EIO. The ship gate
would then pass on an unreadable ledger — the precise vector the
workflow doc claims is impossible. Fix: only ENOENT returns null;
every other fs error propagates as WINDOWS_LEDGER_MALFORMED so the
gate blocks and the operator sees a real diagnostic. Locked by a
regression test that chmod 000s a ledger with open_count=1 and
asserts the result is never a false-green 0.

M1: writeLedgerAtomic left an orphaned .tmp file on rename failure.
Wrapped renameWithRetry in try/catch with best-effort unlink.

M2: validateLine silently coerced 'abc' → NaN → null, hiding type
drift. Removed the line === 0 special case (was undocumented) and
made the error message match the strict check. Now any non-positive-
integer line value throws, including strings.

M3: tests/broken-windows.test.cjs (with its fast-check property test)
was not in stryker.config.mjs DEFAULT_TEST_CMD — Stryker would mutate
src/broken-windows.cts but no test would catch the mutations,
producing false surviving-mutant scores. Added to the list.

L1 (dead throw e after error()), L7 (line boundary tests, H1/H2
regression tests, 4-backtick CLI test) also addressed.

* docs(#1950): inline concurrency + busy-wait notes (review L2+L3)

* fix(#1950): regen goldens against latest gsd-tools; correct --line 0 boundary test

gsd-test v4 caught two issues:
- goldens I regenerated earlier (commit 526682084) predated the L1
  routeWindows catch-block cleanup (commit dd844d565). Regenerated
  via 'npm run gen:golden' against current HEAD so the install
  parity hash for gsd-tools.cjs matches.
- 'append --line boundary' test expected --line 0 to succeed with
  null entry.line, but the M2 fix correctly rejects 0 (lines are
  1-indexed; 0 is not a valid source line). Updated the boundary
  test to assert --line 0 fails alongside -1 and 'abc'.

* chore(#1950): regen goldens after rebase onto next

* chore(#1950): quick.md baseline 50699→50993 (correct resolution from next rebase)

* chore(changeset): backfill pr:2441 in .changeset/broken-windows-ledger.md

* fix(#1950): renderTable escapes backslash before pipe (CodeQL incomplete-sanitization)

CodeQL flagged the markdown-table cell escaper:
  String(s ?? '').replace(/\|/g, '\\|')
— it escapes pipe but not backslash first. A description containing '\|'
would render as '\\|' which markdown parses as 'literal backslash' +
'cell separator', splitting the column.

Fix: escape backslash FIRST (each \ → \\), then pipe (each | → \|).
Now a description with '\|' renders as '\\\\|' (literal '\\' + escaped
pipe), which markdown renders as a single '\|' inside the cell. The JSON
code block (the parse source-of-truth) was already correctly escaped via
JSON.stringify; only the display-only table was affected.

Locked by a regression test that:
1. Verifies the JSON block reparses with the description intact.
2. Walks the rendered table row counting unescaped pipes — must be
   exactly 11 (the row separators for 10 cells), proving no in-cell
   pipe added a split.
2026-07-19 20:24:21 -04:00

918 lines
31 KiB
TypeScript

/**
* Broken-windows ledger — 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).
* `/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',
});
/** 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 = '````';
/**
* 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 line of yamlBody.split(/\r?\n/)) {
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): WindowEntry[] {
const start = raw.indexOf(JSON_FENCE_OPEN);
if (start === -1) {
throw new WindowsError(
REASON.WINDOWS_LEDGER_MALFORMED,
'Ledger missing JSON code block for entries.',
);
}
const end = raw.indexOf(JSON_FENCE_CLOSE, start + JSON_FENCE_OPEN.length);
if (end === -1) {
throw new WindowsError(
REASON.WINDOWS_LEDGER_MALFORMED,
'Ledger JSON code block not terminated.',
);
}
const jsonText = raw.slice(start + JSON_FENCE_OPEN.length, end).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);
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. `/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');
}
function renderTable(entries: WindowEntry[]): string {
if (entries.length === 0) {
return [
'| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |',
'|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|',
'| _(none)_ | | | | | _No windows recorded._ | | | | |',
].join('\n');
}
const rows = [
'| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |',
'|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|',
];
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');
}
// ─── 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`;
fs.writeFileSync(tmp, renderLedger(ledger), '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 };
}