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

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