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:
5
.changeset/daring-deer-fly.md
Normal file
5
.changeset/daring-deer-fly.md
Normal 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)
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user