* test(3): add failing tests for milestone.complete header-dup, bullet-leakage, one-liner-noise Three RED tests for confirmed-bug #3 in sdk/src/query/phase-lifecycle.test.ts: Defect 1 (header-dup): milestoneComplete produces "## v1.0 v1.0 (Shipped: ...)" when no --name is given because `milestoneName = nameOpt || version` is always truthy, so the template `## ${version} ${milestoneName}` duplicates the version. Two sub-cases: no --name, and --name "Foundation Release". Defect 2 (bullet-leakage): getMilestonePhaseFilter falls back to passAll (milestonePhaseNums.size === 0) when the milestone section declares phases via GFM task-list bullets only (https://github.github.com/gfm/#task-list-items-extension-) with no ### Phase N: headings, admitting unrelated phase 99. Defect 3 (one-liner-noise): extractOneLinerFromBody matches the first bold in the entire document body after the heading, regardless of section boundaries. Per GFM § ATX headings (https://github.github.com/gfm/#atx-headings), the scope must be bounded to "first heading until next heading of ANY level". CJS bundle `bin/lib/*.cjs` intentionally NOT updated; bundle sync covered by #4 (first generator-introducing PR) and #26. * fix(3-1): preserve version in milestone header, append name only when provided Bug: `milestoneName = nameOpt || version` was always truthy (falling back to version), so the template `## ${version} ${milestoneName}` rendered as `## v1.0 v1.0 (Shipped: ...)` when no --name was given. Fix: separate `milestoneName = nameOpt ?? undefined` from `version`, then build the title with `milestoneName ? \`${version} ${milestoneName}\` : version`. Canonical template (GFM § ATX headings: https://github.github.com/gfm/#atx-headings): No --name: `## v1.0 (Shipped: 2026-05-23)` With --name "Foo": `## v1.0 Foo (Shipped: 2026-05-23)` Historical evidence: `## v1.8.0 Quick Mode (Shipped: 2026-01-19)` Also applies the same fix to the Requirements Archive header. Files changed: sdk/src/query/phase-lifecycle.ts #3 CJS bundle `bin/lib/*.cjs` intentionally NOT updated; bundle sync covered by #4 (first generator-introducing PR) and #26. * fix(3-2): recognize checkbox-bullet phase declarations in milestone phase filter Bug: getMilestonePhaseFilter in sdk/src/query/state.ts used the regex `/#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi` which only matched heading-style phase declarations (### Phase N: title). When a ROADMAP declares phases via GFM task-list bullets only: - [ ] **Phase 1: Foundation** - [ ] **Phase 2: API** the Set remained empty → passAll fired → all phase directories (including unrelated 99-*) were admitted into the milestone accomplishments. Fix: extend the regex to also match bullet-style declarations: /(?:#{2,4}\s*|-\s*(?:\[[x ]\]\s*)?\*{0,2}\s*)Phase\s+([\w][\w.-]*)\s*:/gi GFM § ATX headings: https://github.github.com/gfm/#atx-headings GFM § Task list items: https://github.github.com/gfm/#task-list-items-extension- Files changed: sdk/src/query/state.ts #3 CJS bundle `bin/lib/*.cjs` intentionally NOT updated; bundle sync covered by #4 (first generator-introducing PR) and #26. * fix(3-3): bound extractOneLinerFromBody to first heading until next heading of any level Bug: extractOneLinerFromBody in sdk/src/query/phase-lifecycle-policy.ts used the regex /^#[^\n]*\n+\*\*([^*]+)\*\*/m which matched the first bold in the entire document body after any heading. This leaked bold text from later sub-sections (e.g. "### Deviation 1 -- bar") into the one-liner. Fix: bound the search scope to the first section only. Implementation follows D3 spec: 1. Find the first heading of any level (GFM ATX headings: https://github.github.com/gfm/#atx-headings). 2. Extract content from after that heading to the next heading of ANY level (not "same or higher") so ### Deviation terminates scope even when title is H1. 3. Match the first **bold** within that bounded scope only. Note: TypeScript 5.x rejects backtick-containing regex patterns in JSDoc comments (TS1443: template literal parse error); comments use plain text instead. Files changed: sdk/src/query/phase-lifecycle-policy.ts #3 CJS bundle `bin/lib/*.cjs` intentionally NOT updated; bundle sync covered by #4 (first generator-introducing PR) and #26. * fix(3-3): apply bounded one-liner extraction to summary.ts duplicate Sibling copy of extractOneLinerFromBody in sdk/src/query/summary.ts had the same pre-fix logic flagged in commit d1eab690. Same bug, same fix — "Fix Everything You Find" applies. DRY-extracting to a shared module is left as a follow-up refactor; out of scope for this bug fix. Refs #3 * chore(3): add changeset fragment for milestone.complete noise fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(3): update changeset fragment with PR number 146 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
3.6 KiB
TypeScript
119 lines
3.6 KiB
TypeScript
/**
|
|
* Tests for summary / history digest handlers.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
import { summaryExtract, historyDigest } from './summary.js';
|
|
|
|
describe('summaryExtract', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sum-'));
|
|
await mkdir(join(tmpDir, '.planning', 'phases', '01-x'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns structured fields from SUMMARY frontmatter (CJS parity)', async () => {
|
|
const rel = '.planning/phases/01-x/01-SUMMARY.md';
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'phases', '01-x', '01-SUMMARY.md'),
|
|
[
|
|
'---',
|
|
'phase: "01"',
|
|
'name: Test Phase',
|
|
'one-liner: From YAML',
|
|
'key-files:',
|
|
' - a.ts',
|
|
'key-decisions:',
|
|
' - "Choice: because reasons"',
|
|
'patterns-established:',
|
|
' - "Pattern one"',
|
|
'tech-stack:',
|
|
' added:',
|
|
' - vitest',
|
|
'requirements-completed:',
|
|
' - R1',
|
|
'---',
|
|
'',
|
|
'# Summary',
|
|
'',
|
|
'**Body one-liner ignored when FM has one-liner**',
|
|
'',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
const r = await summaryExtract([rel], tmpDir);
|
|
const data = r.data as Record<string, unknown>;
|
|
expect(data.path).toBe(rel);
|
|
expect(data.one_liner).toBe('From YAML');
|
|
expect(data.key_files).toEqual(['a.ts']);
|
|
expect(data.requirements_completed).toEqual(['R1']);
|
|
expect(Array.isArray(data.decisions)).toBe(true);
|
|
});
|
|
|
|
it('summaryExtract: one-liner extraction is bounded to first heading scope', async () => {
|
|
const rel = '.planning/phases/01-x/01-SUMMARY.md';
|
|
const fixture = [
|
|
'# Phase Summary',
|
|
'',
|
|
'> Blockquote intro — should NOT be picked up.',
|
|
'',
|
|
'**Real one-liner: shipped the foo system.**',
|
|
'',
|
|
'### Deviation 1 — bar',
|
|
'',
|
|
'**This bold MUST NOT be picked up by the bounded extractor.**',
|
|
].join('\n');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'phases', '01-x', '01-SUMMARY.md'),
|
|
fixture,
|
|
'utf-8',
|
|
);
|
|
const r = await summaryExtract([rel], tmpDir);
|
|
const data = r.data as Record<string, unknown>;
|
|
expect(data.one_liner).toBe('Real one-liner: shipped the foo system.');
|
|
});
|
|
|
|
it('filters with --fields', async () => {
|
|
const rel = '.planning/phases/01-x/01-SUMMARY.md';
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'phases', '01-x', '01-SUMMARY.md'),
|
|
['---', 'phase: "01"', 'one-liner: X', 'key-files:', ' - z.ts', '---', ''].join('\n'),
|
|
'utf-8',
|
|
);
|
|
const r = await summaryExtract([rel, '--fields', 'path,one_liner'], tmpDir);
|
|
const data = r.data as Record<string, unknown>;
|
|
expect(Object.keys(data).sort()).toEqual(['one_liner', 'path'].sort());
|
|
expect(data.one_liner).toBe('X');
|
|
});
|
|
});
|
|
|
|
describe('historyDigest', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-hist-'));
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns digest object for project without phases', async () => {
|
|
const r = await historyDigest([], tmpDir);
|
|
const data = r.data as Record<string, unknown>;
|
|
expect(data.phases).toEqual({});
|
|
expect(data.decisions).toEqual([]);
|
|
expect(data.tech_stack).toEqual([]);
|
|
});
|
|
});
|