fix(#628): read snake_case requirements_completed in summary-extract (#640)

* 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 <noreply@anthropic.com>

* chore(#628): add changeset for summary-extract snake-key fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-03 08:06:31 -04:00
committed by GitHub
parent 3cf112b3de
commit 56a815c722
3 changed files with 65 additions and 1 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 });