fix(#4487): stamp broken-windows ledger entries with the resolved milestone (#4583)

* enhance(#4487): stamp windows-ledger entries with the resolved milestone

Broken-windows ledger entries (`.planning/WINDOWS.md`) carry `phase` as
a bare number. 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 sharing
the same phase value with nothing distinguishing them. Since
`/gsd-ship` blocks while any entry is open, an already-archived
milestone's open entries could silently block shipping the CURRENT
milestone, with no supported way to attribute which entry belonged to
which milestone short of manually cross-referencing MILESTONES.md
timestamps against decision IDs that happened to appear in description
prose.

Added an optional `milestone: string | null` field to WindowEntry,
stamped by `windows append` (cmdWindowsAppend, which already does file
I/O) from the workstream's resolved milestone version. Reused the
existing `readCurrentMilestoneVersion` (workstream-inventory.cts --
STATE.md `milestone:` frontmatter first, ROADMAP.md in-progress marker
as fallback) rather than writing a parallel implementation: exported it
via that module's existing `export = {...}` CJS-interop convention
(matching the `import ... = require(...)` pattern already used in
workstream.cts/init.cts). appendWindow itself stays pure -- it accepts
milestone as an optional input field and passes it through; only the
CLI-facing cmdWindowsAppend resolves it from disk.

Backward compatible by construction: validateEntryShape does NOT add
`milestone` to its required fields, so an existing ledger entry with no
milestone key at all parses without error and reads back as null --
exactly "recorded before this change," no migration needed. The
rendered markdown table is deliberately left unchanged (the issue's own
words: "the JSON is the source of truth"); adding a table column would
be a separate, larger change than adding an optional JSON field.

Two smaller gaps the issue itself flags as separable ("happy to split
them out") are explicitly NOT addressed here: no verb to amend an
entry's description, and the table/JSON drift-repair advice that can
destroy table-only edits on a parse failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4487): preserve absent-vs-null milestone through parse/render roundtrip

validateEntryShape stamped an explicit `milestone: null` onto every
entry lacking the key, so a pre-#4487 ledger entry gained permanent
JSON churn ("milestone": null) the first time ANY entry in the ledger
was touched -- breaking the pure parse/render roundtrip-identity
property test (render(parse(render(ledger))) must equal render(ledger))
and, in real usage, contaminating unrelated entries' diffs on every
append/waive/fixed of an old ledger.

Fixed by distinguishing "key genuinely absent" (undefined -- JSON.
stringify drops it, matching pre-#4487 behavior exactly) from "recorded
but unresolvable" (explicit null, the real signal appendWindow stamps
on brand-new entries). WindowEntry.milestone is now optional
(`milestone?: string | null`) so returning undefined type-checks.

Updated tests/broken-windows.test.cjs's roundtrip property generator to
exercise all three states (absent/null/string) -- its prior silence on
this field is exactly what let the regression through. Also corrected
the earlier backward-compatibility test's assertion: a pre-#4487 entry
reads as milestone: undefined, not null, and re-rendering it must not
introduce a milestone key at all.

Also ran npm run regen:derived: docs/features/broken-windows-ledger.md
(edited in an earlier commit) had never been propagated to its
generated docs/FEATURES.md projection, which is what was independently
failing tests/features-index-gate.test.cjs and, as a side effect of
staleness, tripping tests/fragment-single-edit-propagation.install.
test.cjs's second-source-surface check.

Manually verified via the compiled lib (500 fast-check iterations plus
direct legacy/new-entry roundtrip checks) before wiring the test file,
since this repo blocks local node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4487): materialize milestone via conditional spread, not undefined assignment

An object literal property set to `milestone: undefined` is still an
OWN property -- `'milestone' in entry` reads true regardless of the
assigned value, only JSON.stringify treats undefined specially. My
prior commit's own new backward-compat test asserted `'milestone' in
entry === false` for a pre-#4487 entry and failed on exactly this.
Switched to conditionally spreading the key in only when the source
object actually had it, so a genuinely absent milestone is not
materialized at all -- matching both the `in` check and JSON
serialization. Re-verified via the compiled lib (500 fast-check
roundtrip iterations, plus the specific in/undefined/JSON assertions
the failing test makes) before re-running gsd-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4487): backfill changeset pr number to 4583

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-09 15:37:50 -04:00
committed by GitHub
parent 8deb40722a
commit 385ed619f1
6 changed files with 194 additions and 1 deletions

View File

@@ -45,6 +45,8 @@
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 ─────────────────────────────────────────────────────────────
@@ -115,11 +117,25 @@ export interface WindowEntry {
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'>>;
Partial<Pick<WindowEntry, 'file' | 'line' | 'milestone'>>;
export interface Ledger {
schema_version: number;
@@ -302,6 +318,7 @@ export function appendWindow(
reason: '',
recorded_at: opts.now,
resolved_at: null,
milestone: input.milestone ?? null,
};
const entries = [...ledger.entries, entry];
@@ -614,6 +631,19 @@ function validateEntryShape(e: unknown, i: number): WindowEntry {
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 } : {}),
};
}
@@ -1107,6 +1137,15 @@ export function cmdWindowsAppend(
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,
{
@@ -1115,6 +1154,7 @@ export function cmdWindowsAppend(
file: parsed.values['--file'] ?? '',
line: parsed.values['--line'] == null ? null : Number(parsed.values['--line']),
description: parsed.values['--description'] ?? '',
milestone,
},
{ now: nowIso() },
);

View File

@@ -860,6 +860,7 @@ export = {
inspectWorkstream,
isCompletedInventory,
listWorkstreamInventories,
readCurrentMilestoneVersion,
sortWorkstreamInventories,
workstreamsRoot,
};