fix(#3860): Quick Tasks lookup accepts milestone-suffixed headings; schema-aware section selection (#4050)

* test(#3860): quick-tasks heading tolerance for milestone suffixes (failing first)

* fix(#3860): prefix-match Quick Tasks heading + schema-aware section selection

The exact-anchored predicate (^quick tasks completed$) never matched a
milestone-suffixed heading ('Quick Tasks Completed (v1.1+)'), so every
/gsd-fast append AND every reset failed with QUICK_TASKS_SECTION_ABSENT
before the columns were ever looked at — the reported table was already
canonical. A heading is a section label, not data: the predicate is now
prefix-anchored with a word boundary (Completedness still excluded),
hoisted to one shared isQuickTasksHeading beside QUICK_TASKS_SECTION_ABSENT
so the two call sites cannot drift.

Section selection is now schema-aware (the issue's deliberate-choice
ask): among ALL matching sections, the first whose table parses with a
recognized Quick Tasks schema wins — a legacy (v1.0) table first in
document order no longer shadows a usable (v1.1+) one below it. When no
section is usable, the FIRST is returned so the error names the real
problem (unrecognized schema) instead of a false 'no section'.

Fixes one over-assertion in the new tests (v1.1+'s own next ordinal IS 2;
pins v1.0 rows byte-identical instead).

* fix(#3860): review fold-ins — level-bounded bodies, splice guard, convention pin

Adversarial review caught that collectSections ends a candidate's body
only at the next MATCHING heading: an intervening '## Deferred Items'
table (canonical STATE.md layout — templates/state.md puts it after the
Quick Tasks section) would be swallowed into the body, and the append's
last-table-line scan would splice the quick-task row into that WRONG
table — silent corruption on the primary /gsd-fast path. Each candidate
is now re-collected through collectSection with an offset-precise
predicate, restoring the pre-#3860 level-bounded stop (next same-or-
higher heading) for both probing and splicing.

Also pins the both-schema-valid tie-break (document order, newest-on-top
— the layout the issue itself demonstrates; this layer has no
active-milestone signal) with a test, guards the Deferred Items layout
with a dedicated splice-target test, and updates resetQuickTaskRows's
doc comment to name the new pipeline.

* chore(#3860): changeset fragment (pr number backfilled after PR creation)

* chore(#3860): backfill changeset PR number (4050)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 14:52:24 -04:00
committed by GitHub
parent fd63889d1f
commit 4048ba2e80
3 changed files with 270 additions and 4 deletions

View File

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

View File

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

View File

@@ -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)', () => {