diff --git a/.changeset/daring-deer-fly.md b/.changeset/daring-deer-fly.md new file mode 100644 index 000000000..2ae88cf89 --- /dev/null +++ b/.changeset/daring-deer-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4050 +--- +the STATE.md Quick Tasks log accepts milestone-suffixed section headings (Quick Tasks Completed (v1.1+)) — the exact-anchored lookup never matched them, so every /gsd:fast append and milestone reset silently failed before the columns were even checked; among several matching sections the one with a recognized table schema wins (#3860) diff --git a/src/markdown-table.cts b/src/markdown-table.cts index 0a532054d..a9fa61e6c 100644 --- a/src/markdown-table.cts +++ b/src/markdown-table.cts @@ -10,7 +10,8 @@ * {ok,data|kind}; the two never mix (different modules). */ -import { collectSection, replaceSection } from './markdown-sectionizer.cjs'; +import { collectSection, collectSections, replaceSection } from './markdown-sectionizer.cjs'; +import type { HeadingToken, Section } from './markdown-sectionizer.cjs'; import type { Result } from './write-set.cjs'; export type { Result } from './write-set.cjs'; @@ -732,6 +733,57 @@ export function escapeCell(value: string): string { */ export const QUICK_TASKS_SECTION_ABSENT = 'no Quick Tasks Completed section'; +/** + * #3860: heading predicate for STATE.md's Quick Tasks Completed section(s). + * The heading is a section LABEL, not data — milestone-scoped files + * legitimately carry suffixed headings (`### Quick Tasks Completed (v1.1+)` + * beside an archived `(v1.0)`), so the match is prefix-anchored with a word + * boundary, never exact: `Quick Tasks Completedness` must NOT match. Hoisted + * beside QUICK_TASKS_SECTION_ABSENT so `appendQuickTaskRow` and + * `resetQuickTaskRows` cannot drift apart again. + */ +const isQuickTasksHeading = (h: HeadingToken): boolean => + /^quick tasks completed\b/i.test(h.text.trim()); + +/** + * #3860: among ALL heading-matching sections, pick the first whose body is a + * table with a recognized Quick Tasks schema — a legacy/unparseable table + * first in document order no longer shadows a usable one further down. When + * NO section is usable, return the FIRST match so the caller's downstream + * error describes the real problem (unparseable/legacy table) instead of a + * false QUICK_TASKS_SECTION_ABSENT. + * + * Bounding: `collectSections` ends a candidate's body only at the NEXT + * matching heading — far too wide for splicing (it would swallow an + * intervening `## Deferred Items` table into the Quick Tasks body, and + * `appendQuickTaskRow`'s last-table-line scan would then splice the new row + * into that WRONG table). Each candidate is therefore re-collected through + * `collectSection` with an offset-precise predicate, whose default + * level-bounded stop (next heading of the same or higher level) is exactly + * the semantics the pre-#3860 single-section lookup had. + * + * Convention: "first" is document order — the newest-on-top layout the issue + * itself demonstrates (`(v1.1+)` above an archived `(v1.0)`). When several + * suffixed sections all carry recognized schemas, this layer has no signal + * for which milestone is active, so document order is the pinned tie-break. + */ +function selectQuickTasksSection(stateContent: string): Section | null { + const candidates = collectSections(stateContent, isQuickTasksHeading); + if (candidates.length === 0) return null; + + const boundedOf = (cand: Section): Section | null => + collectSection(stateContent, (h: HeadingToken) => h.offset === cand.heading.offset); + + const firstBounded = boundedOf(candidates[0]); + for (const cand of candidates) { + const bounded = boundedOf(cand); + if (!bounded) continue; + const parsed = parseMarkdownTable(bounded.body); + if (parsed.ok && matchTableSchema(parsed.value.columns)?.id === 'QuickTasks') return bounded; + } + return firstBounded; +} + /** Fields needed to render one "Quick Tasks Completed" row (schema-driven). */ export interface QuickTaskFields { description: string; @@ -775,7 +827,7 @@ export function appendQuickTaskRow( stateContent: string, fields: QuickTaskFields, ): Result<{ content: string; row: string; variant: string }> { - const section = collectSection(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim())); + const section = selectQuickTasksSection(stateContent); if (!section) { return { ok: false, reason: QUICK_TASKS_SECTION_ABSENT }; } @@ -842,7 +894,7 @@ export function appendQuickTaskRow( * directories out from under the table (see `src/milestone.cts`'s * `archiveQuickTaskDirectories` / `cmdMilestoneComplete` wiring). * - * Mirrors `appendQuickTaskRow`'s exact contract (same `collectSection` -> + * Mirrors `appendQuickTaskRow`'s exact contract (same `selectQuickTasksSection` -> * `parseMarkdownTable` -> `matchTableSchema` pipeline, same fail-loud posture, * same EOL-detect-before-split handling) rather than inventing a second one: * - no "Quick Tasks Completed" heading -> `{ok:false, reason: @@ -867,7 +919,7 @@ export function resetQuickTaskRows( return { ok: false, reason: 'empty or non-string input' }; } - const section = collectSection(stateContent, (h) => /^quick tasks completed$/i.test(h.text.trim())); + const section = selectQuickTasksSection(stateContent); if (!section) { return { ok: false, reason: QUICK_TASKS_SECTION_ABSENT }; } diff --git a/tests/markdown-table.test.cjs b/tests/markdown-table.test.cjs index 45ebea55d..496ea5a73 100644 --- a/tests/markdown-table.test.cjs +++ b/tests/markdown-table.test.cjs @@ -1136,6 +1136,215 @@ describe('TABLE_SCHEMAS parity: registry headers must appear verbatim in their s }); }); +// ─── Quick Tasks heading tolerance (#3860) ──────────────────────────────────── + +describe('Quick Tasks heading tolerance (#3860)', () => { + // Byte-identical table body to the bare-heading control — only the heading + // suffix differs, which is exactly the reporter's isolation. + const suffixedState = [ + '# STATE', + '', + '### Quick Tasks Completed (v1.1+)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | fix typo | 2026-01-01 | abc1234 | — |', + '', + '### Blockers/Concerns', + 'None', + ].join('\n'); + + const multiMilestoneState = [ + '# STATE', + '', + '### Quick Tasks Completed (v1.1+)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 250101-abc | first v1.1 task | 2025-01-01 | 0123abc | [dir](./quick/250101-abc/) |', + '', + '### Quick Tasks Completed (v1.0)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | legacy v1.0 task | 2024-06-01 | old1234 | — |', + ].join('\n'); + + // v1.0 first in document order with a LEGACY (unrecognized) schema — the + // usable v1.1+ table below it must not be shadowed (#3860 "worth deciding + // deliberately rather than inheriting from document order"). + const legacyFirstState = [ + '# STATE', + '', + '### Quick Tasks Completed (v1.0)', + '', + '| Date | Slug | Scope | Artifacts |', + '|------|------|-------|-----------|', + '| 2024-06-01 | old | full | — |', + '', + '### Quick Tasks Completed (v1.1+)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | fix typo | 2026-01-01 | abc1234 | — |', + ].join('\n'); + + test('appendQuickTaskRow locates a milestone-suffixed heading', () => { + const result = appendQuickTaskRow(suffixedState, { + description: 'probe', + date: '2026-08-25', + commit: 'deadbee', + }); + assert.equal(result.ok, true, `reason: ${result.reason}`); + assert.ok(result.value.content.includes('| 2 | probe | 2026-08-25 | deadbee | — |')); + assert.ok(result.value.content.includes('### Quick Tasks Completed (v1.1+)'), 'the suffixed heading itself is untouched'); + }); + + test('resetQuickTaskRows clears a milestone-suffixed heading', () => { + const result = resetQuickTaskRows(suffixedState); + assert.equal(result.ok, true, `reason: ${result.reason}`); + assert.equal(result.value.cleared, 1); + assert.ok(!result.value.content.includes('fix typo')); + assert.ok(result.value.content.includes('### Quick Tasks Completed (v1.1+)')); + }); + + test('bare "Quick Tasks Completedness" is NOT matched (word boundary holds)', () => { + const notQuick = suffixedState.replace( + '### Quick Tasks Completed (v1.1+)', + '### Quick Tasks Completedness (v1.1+)', + ); + const result = appendQuickTaskRow(notQuick, { description: 'x', date: '2026-08-25', commit: 'abc' }); + assert.equal(result.ok, false); + assert.equal(result.reason, QUICK_TASKS_SECTION_ABSENT, 'a different section name must still be absent'); + }); + + test('multiple milestone sections: appends to the one holding the canonical table', () => { + const result = appendQuickTaskRow(multiMilestoneState, { + description: 'second v1.1 task', + date: '2025-02-02', + commit: '0456def', + }); + assert.equal(result.ok, true, `reason: ${result.reason}`); + assert.ok(result.value.content.includes('second v1.1 task')); + const v11 = result.value.content.indexOf('### Quick Tasks Completed (v1.1+)'); + const v10 = result.value.content.indexOf('### Quick Tasks Completed (v1.0)'); + assert.ok( + result.value.content.indexOf('second v1.1 task') > v11 && result.value.content.indexOf('second v1.1 task') < v10, + 'the row lands in the v1.1+ section (the one whose table matched the canonical schema), not the v1.0 section' + ); + assert.ok( + result.value.content.includes('| 1 | legacy v1.0 task | 2024-06-01 | old1234 | — |'), + 'the v1.0 row is present byte-identical (only the v1.1+ table gained a row)' + ); + }); + + test('a legacy-schema section first in document order does not shadow a usable one', () => { + const result = appendQuickTaskRow(legacyFirstState, { + description: 'new task', + date: '2026-08-25', + commit: 'deadbee', + }); + assert.equal(result.ok, true, `reason: ${result.reason}`); + assert.ok( + result.value.content.includes('| 2 | new task | 2026-08-25 | deadbee | — |'), + 'appends to the v1.1+ canonical table below the legacy v1.0 one' + ); + assert.ok(!result.value.content.includes('unrecognized'), 'no schema complaint when a usable section exists'); + }); + + test('when NO matching section is usable, the error names the real problem (first section\'s)', () => { + const onlyLegacy = legacyFirstState.slice(0, legacyFirstState.indexOf('### Quick Tasks Completed (v1.1+)')); + const result = appendQuickTaskRow(onlyLegacy, { description: 'x', date: '2026-08-25', commit: 'abc' }); + assert.equal(result.ok, false); + assert.ok( + result.reason.includes('unrecognized Quick Tasks schema'), + `the failure must describe the legacy table, not claim the section is absent; got: ${result.reason}` + ); + }); + + test('#3860 review: a later ## Deferred Items pipe table is NOT the splice target', () => { + // The canonical STATE.md layout (templates/state.md + workflows/quick.md) + // puts a pipe table AFTER the Quick Tasks section. A section-body + // collection that only stops at the next matching heading would swallow + // it, and the append's last-table-line scan would splice the quick-task + // row into the Deferred Items table. + const canonicalLayout = [ + '# STATE', + '', + '### Quick Tasks Completed (v1.1+)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | fix typo | 2026-01-01 | abc1234 | — |', + '', + '### Blockers/Concerns', + 'None', + '', + '## Deferred Items', + '', + '| Category | Item | Status | Deferred At | Milestone |', + '|----------|------|--------|-------------|-----------|', + '| scope | extra thing | deferred | 2026-01-02 | v1.1 |', + '', + ].join('\n'); + const result = appendQuickTaskRow(canonicalLayout, { + description: 'probe', + date: '2026-08-25', + commit: 'deadbee', + }); + assert.equal(result.ok, true, `reason: ${result.reason}`); + const content = result.value.content; + const quickRow = '| 2 | probe | 2026-08-25 | deadbee | — |'; + assert.ok(content.includes(quickRow), 'the new row exists'); + assert.ok( + content.indexOf(quickRow) < content.indexOf('## Deferred Items'), + 'the row lands INSIDE the Quick Tasks table, before the Deferred Items section' + ); + assert.ok( + content.includes('| scope | extra thing | deferred | 2026-01-02 | v1.1 |'), + 'the Deferred Items table is byte-identical (no row spliced after its last line)' + ); + const deferred = content.slice(content.indexOf('## Deferred Items')); + assert.ok(!deferred.includes(quickRow), 'the Deferred Items section contains no quick-task row'); + }); + + test('#3860 convention: several schema-valid sections — document order (newest-on-top) wins', () => { + // The archive flow preserves a recognized header-only table under the old + // heading (workflows/complete-milestone.md), so both sections can be + // schema-valid. This layer has no active-milestone signal; the pinned + // tie-break is document order — first section — matching the issue's own + // newest-on-top layout. + const bothValid = [ + '# STATE', + '', + '### Quick Tasks Completed (v1.1+)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 1 | current task | 2026-01-01 | abc1234 | — |', + '', + '### Quick Tasks Completed (v1.0)', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '| 99 | archived task | 2025-01-01 | old1234 | — |', + ].join('\n'); + const result = appendQuickTaskRow(bothValid, { + description: 'new task', + date: '2026-08-25', + commit: 'deadbee', + }); + assert.equal(result.ok, true, `reason: ${result.reason}`); + const v11 = result.value.content.indexOf('### Quick Tasks Completed (v1.1+)'); + const v10 = result.value.content.indexOf('### Quick Tasks Completed (v1.0)'); + assert.ok( + result.value.content.indexOf('| 2 | new task | 2026-08-25 | deadbee | — |') > v11 + && result.value.content.indexOf('| 2 | new task | 2026-08-25 | deadbee | — |') < v10, + 'the row lands in the FIRST (newest-on-top) section' + ); + }); +}); + // ─── resetQuickTaskRows (#2142) ───────────────────────────────────────────── describe('resetQuickTaskRows (#2142)', () => {