* 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:
@@ -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() },
|
||||
);
|
||||
|
||||
@@ -860,6 +860,7 @@ export = {
|
||||
inspectWorkstream,
|
||||
isCompletedInventory,
|
||||
listWorkstreamInventories,
|
||||
readCurrentMilestoneVersion,
|
||||
sortWorkstreamInventories,
|
||||
workstreamsRoot,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user