fix(3): milestone.complete header dup, bullet leakage, one-liner noise (#146)
* 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>
This commit is contained in:
5
.changeset/fix-3-milestone-complete-noise.md
Normal file
5
.changeset/fix-3-milestone-complete-noise.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 146
|
||||
---
|
||||
milestone.complete no longer produces a doubled version in the MILESTONES.md header (`v1.0 v1.0`), leaks phases declared via checkbox bullets into the "all phases" fallback, or extracts one-liner text from sections beyond the first heading boundary. (#3)
|
||||
@@ -51,11 +51,32 @@ export function parseMultiwordArg(args: string[], flag: string): string | null {
|
||||
return tokens.length > 0 ? tokens.join(' ') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a one-liner from the summary body when it is not in frontmatter.
|
||||
*
|
||||
* Scope: content from the first heading to the next heading of ANY level.
|
||||
* Per GFM ATX headings (https://github.github.com/gfm/#atx-headings),
|
||||
* a heading of any level terminates the scope -- including sub-headings
|
||||
* even when the title heading is H1.
|
||||
*
|
||||
* #3 defect-3: the original regex matched the first bold in the entire body
|
||||
* after any heading, leaking bold text from later sections (e.g. deviation
|
||||
* entries) into the one-liner.
|
||||
*/
|
||||
export function extractOneLinerFromBody(content: string): string | null {
|
||||
if (!content) return null;
|
||||
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
|
||||
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
|
||||
return match ? match[1]!.trim() : null;
|
||||
// Find the first heading of any level (GFM section ATX headings)
|
||||
const headingMatch = body.match(/^(#{1,6}\s[^\n]+\n)/m);
|
||||
if (!headingMatch || headingMatch.index === undefined) return null;
|
||||
const afterHeading = body.slice(headingMatch.index + headingMatch[0].length);
|
||||
// Bound to the first section: truncate at the next heading of any level
|
||||
const nextHeadingMatch = afterHeading.match(/^#{1,6}\s/m);
|
||||
const sectionScope = nextHeadingMatch && nextHeadingMatch.index !== undefined
|
||||
? afterHeading.slice(0, nextHeadingMatch.index)
|
||||
: afterHeading;
|
||||
const boldMatch = sectionScope.match(/\*\*([^*]+)\*\*/);
|
||||
return boldMatch ? boldMatch[1]!.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1969,3 +1969,132 @@ describe('collectDecimalSuffixesFromDirNames — CR-3267 finding 3: alphanumeric
|
||||
expect(result.has(2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3 defect-1: milestone.complete header duplication ───────────────────
|
||||
//
|
||||
// Historical evidence: `## v1.8.0 Quick Mode (Shipped: 2026-01-19)`
|
||||
// Canonical template: `## ${version}${nameOpt ? ` ${nameOpt}` : ''} (Shipped: ${today})`
|
||||
// GFM § ATX headings: https://github.github.com/gfm/#atx-headings
|
||||
|
||||
describe('milestoneComplete (#3 defect-1): header version/name rendering', () => {
|
||||
it('no --name: header is "## v1.0 (Shipped: <date>)" — version appears exactly once', async () => {
|
||||
const { milestoneComplete } = await import('./phase-lifecycle.js');
|
||||
const today = new Date().toISOString().split('T')[0]!;
|
||||
const roadmap = `# Roadmap\n\n## Current Milestone: v1.0\n\n### Phase 1: Foundation\n\n**Goal:** TBD\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01\n`;
|
||||
const state = `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# Project State\n\n## Current Position\n\nPhase: 1\nStatus: Executing\n`;
|
||||
await setupTestProject(tmpDir, { roadmap, state });
|
||||
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
|
||||
await writeFile(milestonesPath, '# Milestones\n\n', 'utf-8');
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
|
||||
await milestoneComplete(['v1.0'], tmpDir);
|
||||
|
||||
const content = await readFile(milestonesPath, 'utf-8');
|
||||
// Must match exactly "## v1.0 (Shipped: <date>)" — version must NOT be doubled
|
||||
expect(content).toContain(`## v1.0 (Shipped: ${today})`);
|
||||
expect(content).not.toMatch(/## v1\.0 v1\.0/);
|
||||
});
|
||||
|
||||
it('with --name "Foundation Release": header is "## v1.0 Foundation Release (Shipped: <date>)"', async () => {
|
||||
const { milestoneComplete } = await import('./phase-lifecycle.js');
|
||||
const today = new Date().toISOString().split('T')[0]!;
|
||||
const roadmap = `# Roadmap\n\n## Current Milestone: v1.0\n\n### Phase 1: Foundation\n\n**Goal:** TBD\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01\n`;
|
||||
const state = `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# Project State\n\n## Current Position\n\nPhase: 1\nStatus: Executing\n`;
|
||||
await setupTestProject(tmpDir, { roadmap, state });
|
||||
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
|
||||
await writeFile(milestonesPath, '# Milestones\n\n', 'utf-8');
|
||||
await mkdir(join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
|
||||
await milestoneComplete(['v1.0', '--name', 'Foundation', 'Release'], tmpDir);
|
||||
|
||||
const content = await readFile(milestonesPath, 'utf-8');
|
||||
// version + name appended: "## v1.0 Foundation Release (Shipped: ...)"
|
||||
expect(content).toContain(`## v1.0 Foundation Release (Shipped: ${today})`);
|
||||
expect(content).not.toMatch(/## v1\.0 v1\.0/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3 defect-2: bullet-declared phases leak phase 99 into milestone ─────
|
||||
//
|
||||
// ROADMAP declares phases via checkbox bullets ONLY (GFM task list items:
|
||||
// https://github.github.com/gfm/#task-list-items-extension-).
|
||||
// When there are no `### Phase N:` headings in the milestone section,
|
||||
// getMilestonePhaseFilter falls back to passAll (milestonePhaseNums.size === 0),
|
||||
// admitting unrelated phase dirs like 99-unrelated.
|
||||
// The fix: extend the phase-extraction regex to also match checkbox-bullet
|
||||
// declarations so passAll is not triggered spuriously.
|
||||
|
||||
describe('milestoneComplete (#3 defect-2): checkbox-bullet phase declarations filter correctly', () => {
|
||||
it('collects only phases 1 and 2 from bullet-only declarations — never phase 99', async () => {
|
||||
const { milestoneComplete } = await import('./phase-lifecycle.js');
|
||||
// Roadmap declares phases via GFM task-list bullets ONLY — no ### Phase N:
|
||||
// headings exist anywhere in the document. This is the exact shape that
|
||||
// triggers passAll in the current code (milestonePhaseNums.size === 0).
|
||||
const roadmap = `# Roadmap
|
||||
|
||||
## Current Milestone: v1.0 Foundation
|
||||
|
||||
- [ ] **Phase 1: Foundation**
|
||||
- [ ] **Phase 2: API**
|
||||
|
||||
---
|
||||
*Last updated: 2026-01-01*
|
||||
`;
|
||||
const state = `---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# Project State\n\n## Current Position\n\nPhase: 1\nStatus: Executing\n`;
|
||||
await setupTestProject(tmpDir, { roadmap, state });
|
||||
|
||||
const phasesDir = join(tmpDir, '.planning', 'phases');
|
||||
await mkdir(join(phasesDir, '01-foundation'), { recursive: true });
|
||||
await mkdir(join(phasesDir, '02-api'), { recursive: true });
|
||||
await mkdir(join(phasesDir, '99-unrelated'), { recursive: true });
|
||||
|
||||
const summaryContent = `# Phase Summary\n\n**Shipped the feature.**\n`;
|
||||
await writeFile(join(phasesDir, '01-foundation', 'SUMMARY.md'), summaryContent, 'utf-8');
|
||||
await writeFile(join(phasesDir, '02-api', 'SUMMARY.md'), summaryContent, 'utf-8');
|
||||
// Phase 99 has a summary too — should NOT appear in accomplishments
|
||||
await writeFile(join(phasesDir, '99-unrelated', 'SUMMARY.md'), '# Phase Summary\n\n**Should NOT appear.**\n', 'utf-8');
|
||||
|
||||
const milestonesPath = join(tmpDir, '.planning', 'MILESTONES.md');
|
||||
await writeFile(milestonesPath, '# Milestones\n\n', 'utf-8');
|
||||
|
||||
const result = await milestoneComplete(['v1.0'], tmpDir);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const accomplishments = data.accomplishments as string[];
|
||||
|
||||
// phases 1 and 2 should be counted, 99 must be excluded
|
||||
expect(data.phases).toBe(2);
|
||||
expect(accomplishments.some(a => a.includes('Should NOT appear'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3 defect-3: extractOneLinerFromBody section-scope leak ──────────────
|
||||
//
|
||||
// extractOneLinerFromBody must be bounded to the content between the first
|
||||
// heading and the next heading of ANY level (GFM § ATX headings:
|
||||
// https://github.github.com/gfm/#atx-headings).
|
||||
// A bold match inside a subsequent subsection (e.g. "### Deviation 1 — bar")
|
||||
// must NOT be returned.
|
||||
|
||||
describe('extractOneLinerFromBody (#3 defect-3): bounded to first heading section', () => {
|
||||
it('returns the real one-liner and NOT the bold inside a following sub-heading', async () => {
|
||||
const { extractOneLinerFromBody } = await import('./phase-lifecycle-policy.js');
|
||||
const body = [
|
||||
'# Phase Summary',
|
||||
'',
|
||||
'> This is a blockquote intro — not a one-liner.',
|
||||
'',
|
||||
'**Real one-liner: shipped the foo system.**',
|
||||
'',
|
||||
'Some prose.',
|
||||
'',
|
||||
'### Deviation 1 — bar',
|
||||
'',
|
||||
'**This bold should NOT be picked up.**',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = extractOneLinerFromBody(body);
|
||||
expect(result).toBe('Real one-liner: shipped the foo system.');
|
||||
expect(result).not.toContain('should NOT be picked up');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2039,7 +2039,11 @@ export const milestoneComplete: QueryHandler = async (args, projectDir, workstre
|
||||
const archiveDir = join(paths.planning, 'milestones');
|
||||
const phasesDir = paths.phases;
|
||||
const today = new Date().toISOString().split('T')[0]!;
|
||||
const milestoneName = nameOpt || version;
|
||||
// #3 defect-1: preserve version in header, append optional name only when
|
||||
// explicitly provided. Historical template: `## v1.8.0 Quick Mode (Shipped: ...)`.
|
||||
// When no --name is given, milestoneName is undefined so the header is
|
||||
// `## ${version} (Shipped: ...)` — version appears exactly once.
|
||||
const milestoneName = nameOpt ?? undefined;
|
||||
|
||||
await mkdir(archiveDir, { recursive: true });
|
||||
|
||||
@@ -2095,8 +2099,10 @@ export const milestoneComplete: QueryHandler = async (args, projectDir, workstre
|
||||
|
||||
if (existsSync(reqPath)) {
|
||||
const reqContent = await readFile(reqPath, 'utf-8');
|
||||
// #3 defect-1: canonical header: version + optional name appended only when provided.
|
||||
const reqTitle = milestoneName ? `${version} ${milestoneName}` : version;
|
||||
const archiveHeader =
|
||||
`# Requirements Archive: ${version} ${milestoneName}\n\n` +
|
||||
`# Requirements Archive: ${reqTitle}\n\n` +
|
||||
`**Archived:** ${today}\n**Status:** SHIPPED\n\n` +
|
||||
`For current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
|
||||
await writeFile(join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
|
||||
@@ -2108,8 +2114,12 @@ export const milestoneComplete: QueryHandler = async (args, projectDir, workstre
|
||||
}
|
||||
|
||||
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
|
||||
// #3 defect-1 (GFM § ATX headings: https://github.github.com/gfm/#atx-headings)
|
||||
// Canonical template: `## ${version}${nameOpt ? ` ${nameOpt}` : ''} (Shipped: ${today})`
|
||||
// Historical evidence: `## v1.8.0 Quick Mode (Shipped: 2026-01-19)`
|
||||
const milestoneTitle = milestoneName ? `${version} ${milestoneName}` : version;
|
||||
const milestoneEntry =
|
||||
`## ${version} ${milestoneName} (Shipped: ${today})\n\n` +
|
||||
`## ${milestoneTitle} (Shipped: ${today})\n\n` +
|
||||
`**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n` +
|
||||
`**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
|
||||
|
||||
|
||||
@@ -47,7 +47,13 @@ export async function getMilestonePhaseFilter(projectDir: string, workstream?: s
|
||||
try {
|
||||
const roadmapContent = await readFile(planningPaths(projectDir, workstream).roadmap, 'utf-8');
|
||||
const roadmap = await extractCurrentMilestone(roadmapContent, projectDir, workstream);
|
||||
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
// Match both heading-style (### Phase N:) and GFM task-list bullet-style
|
||||
// (- [ ] **Phase N: title**) declarations.
|
||||
// GFM § ATX headings: https://github.github.com/gfm/#atx-headings
|
||||
// GFM § Task list items: https://github.github.com/gfm/#task-list-items-extension-
|
||||
// #3 defect-2: the original regex matched only heading-style declarations;
|
||||
// bullet-only ROADMAPs caused milestonePhaseNums.size === 0 -> passAll -> phase 99 leakage.
|
||||
const phasePattern = /(?:#{2,4}\s*|-\s*(?:\[[x ]\]\s*)?\*{0,2}\s*)Phase\s+([\w][\w.-]*)\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmap)) !== null) {
|
||||
milestonePhaseNums.add(m[1]);
|
||||
|
||||
@@ -59,6 +59,29 @@ describe('summaryExtract', () => {
|
||||
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(
|
||||
|
||||
@@ -26,12 +26,30 @@ import type { QueryHandler } from './utils.js';
|
||||
/**
|
||||
* Extract a one-liner from the summary body when it is not in frontmatter.
|
||||
* Port of `extractOneLinerFromBody` from `get-shit-done/bin/lib/core.cjs`.
|
||||
*
|
||||
* Scope: content from the first heading to the next heading of ANY level.
|
||||
* Per GFM ATX headings (https://github.github.com/gfm/#atx-headings),
|
||||
* a heading of any level terminates the scope — including sub-headings
|
||||
* even when the title heading is H1.
|
||||
*
|
||||
* #3 defect-3: the original regex matched the first bold in the entire body
|
||||
* after any heading, leaking bold text from later sections (e.g. deviation
|
||||
* entries) into the one-liner.
|
||||
*/
|
||||
function extractOneLinerFromBody(content: string): string | null {
|
||||
if (!content) return null;
|
||||
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
|
||||
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
|
||||
return match ? match[1].trim() : null;
|
||||
// Find the first heading of any level (GFM section ATX headings)
|
||||
const headingMatch = body.match(/^(#{1,6}\s[^\n]+\n)/m);
|
||||
if (!headingMatch || headingMatch.index === undefined) return null;
|
||||
const afterHeading = body.slice(headingMatch.index + headingMatch[0].length);
|
||||
// Bound to the first section: truncate at the next heading of any level
|
||||
const nextHeadingMatch = afterHeading.match(/^#{1,6}\s/m);
|
||||
const sectionScope = nextHeadingMatch && nextHeadingMatch.index !== undefined
|
||||
? afterHeading.slice(0, nextHeadingMatch.index)
|
||||
: afterHeading;
|
||||
const boldMatch = sectionScope.match(/\*\*([^*]+)\*\*/);
|
||||
return boldMatch ? boldMatch[1]!.trim() : null;
|
||||
}
|
||||
|
||||
/** Normalize frontmatter list fields — scalars become single-element arrays. */
|
||||
|
||||
Reference in New Issue
Block a user