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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user