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