From 56a815c722498ff492787c287ac8a79d48eb3e01 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 3 Jun 2026 08:06:31 -0400 Subject: [PATCH] fix(#628): read snake_case requirements_completed in summary-extract (#640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#628): read snake_case requirements_completed in summary-extract cmdSummaryExtract read only the kebab frontmatter key `requirements-completed`, but the tool's own JSON output key and the milestone-audit `--pick` both use the snake form `requirements_completed`. A SUMMARY written in the snake form the tool itself emits was silently read back as [] — a false negative in milestone requirement traceability with no diagnostic. Make the reader tolerant of both key forms (kebab takes precedence), so a round-tripped field is no longer dropped. extractFrontmatter does no hyphen<->underscore normalization, so distinct keys had to be read explicitly. Adds regression tests: snake-only fixture now returns the IDs, and a both-forms-present fixture asserts kebab precedence. Co-Authored-By: Claude Opus 4.8 * chore(#628): add changeset for summary-extract snake-key fix Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/clever-deer-wander.md | 5 +++ src/commands.cts | 6 +++- tests/commands.test.cjs | 55 ++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .changeset/clever-deer-wander.md diff --git a/.changeset/clever-deer-wander.md b/.changeset/clever-deer-wander.md new file mode 100644 index 000000000..14709e09e --- /dev/null +++ b/.changeset/clever-deer-wander.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 640 +--- +**`gsd-tools query summary-extract` no longer drops snake_case `requirements_completed`** — the reader now accepts both the kebab `requirements-completed` and the snake `requirements_completed` key forms (the snake form is what the tool's own JSON output and the milestone audit `--pick` emit), so a round-tripped requirements field is no longer silently read back as empty. diff --git a/src/commands.cts b/src/commands.cts index b23c9ae80..a269e0a5a 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -750,7 +750,11 @@ function cmdSummaryExtract(cwd: string, summaryPath: string | undefined, fields: tech_added: (techStack && techStack['added']) || [], patterns: fm['patterns-established'] || [], decisions: parseDecisions(fm['key-decisions']), - requirements_completed: fm['requirements-completed'] || [], + // Tolerate both key forms: the template/reader use kebab `requirements-completed`, + // but the tool's own JSON output and the milestone audit `--pick` use snake + // `requirements_completed`. Reading both prevents a snake-keyed SUMMARY (the form the + // tool emits) from being silently dropped to []. See #628. + requirements_completed: fm['requirements-completed'] ?? fm['requirements_completed'] ?? [], }; // If fields specified, filter to only those fields diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index e4424fe35..96052db29 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -424,6 +424,61 @@ one-liner: Minimal summary assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty'); }); + test('reads requirements in snake_case form the tool itself emits (#628)', () => { + // Regression: the tool's JSON output key and the milestone-audit `--pick` both use the + // snake form `requirements_completed`, so operators naturally write that into SUMMARY + // frontmatter. The reader must accept it, not silently drop it to []. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Snake-keyed summary +requirements_completed: + - REQ-1 + - REQ-2 +--- + +# Summary +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.requirements_completed, ['REQ-1', 'REQ-2'], + 'snake-case requirements_completed should be read, not dropped to []'); + }); + + test('prefers kebab requirements-completed when both key forms are present (#628)', () => { + // kebab is the documented template form and must win the tolerance fallback. + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + + fs.writeFileSync( + path.join(phaseDir, '01-01-SUMMARY.md'), + `--- +one-liner: Both key forms present +requirements-completed: + - KEBAB-1 +requirements_completed: + - SNAKE-1 +--- + +# Summary +` + ); + + const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.deepStrictEqual(output.requirements_completed, ['KEBAB-1'], + 'kebab key should take precedence over snake when both are present'); + }); + test('parses key-decisions with rationale', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true });