chore(#2143): prohibition-with-teeth + migrate remaining ad-hoc table sites — Phase 4 (final) (#2253)
* chore(#2143): prohibition-with-teeth + migrate remaining table sites — Phase 4 Phase 4 of epic #2143 (ADR-2143 §7). Completes the markdown table/mutation consolidation by (a) giving the ad-hoc-parsing prohibition teeth and (b) migrating the last ad-hoc table sites onto the shared seam. - src/markdown-table.cts: new formatting-preserving `updateTableCell` primitive (self-contained, ragged-row-tolerant header/delimiter/cell-range scan; splices only the target cell's raw span, preserving all other bytes incl. padding/CRLF; no-op-preserves-padding when a transformer returns the current value). Exports splitTableRow/isDelimiterRow/findTableStartOffset for tolerant reuse. - eslint-rules/no-adhoc-markdown-parsing.cjs: TABLE-REGEX detector extended to `new RegExp(<literal|static-template>)`; new `.replace()`-mutation detector for roadmap/state/content receivers with a table/section-shaped pattern. - scripts/lint-table-schema-drift.cjs (wired into lint:ci): fails if a TABLE_SCHEMA header drifts from its authored table; tests import its logic (single source). - Migrated onto the seam (behaviour-preserving vs pre-Phase-4 HEAD, verified byte-diff old-vs-new): roadmap.cts cmdRoadmapUpdatePlanProgress, phase.cts cmdPhaseComplete + traceability, milestone.cts cmdRequirementsMarkComplete, uat.cts read path, state.cts metrics/decisions/By-Phase. - Incidental correctness gains from the migration: a decoy table can no longer swallow a phase-progress update (## Progress scoping); a ragged neighbouring row no longer silently aborts an edit; completing integer phase N no longer touches a decimal sub-phase N.x row; record-metric no longer drops trailing section content or duplicates the ## Performance Metrics section. - Kept justified allow-adhoc-markdown markers only where genuinely not a table (security.cts <|role|> token) or a loose non-GFM section (uat human-verify). Two orthogonal isolated reviews (correctness/adversarial + security) passed; correctness found 4 behaviour regressions in the first migration pass, all fixed and re-verified byte-identical-or-better vs OLD. Surfaced for maintainer (pre-existing, ambiguous domain logic, NOT changed here): templates/state.md places a By-Phase table under ## Performance Metrics while cmdStateRecordMetric assumes a Plan|Duration|Tasks|Files table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): match traceability row by first-cell value, not Requirement header Phase 4's migration matched the REQUIREMENTS.md traceability row by a column literally named `Requirement` (`row['Requirement']`), but real tables head that column `REQ-ID`. The by-name lookup found nothing, so `phase complete` and `requirements mark-complete` left the Status cell `Pending` (regressed #2769 / #2203, caught by gsd-test — 8 failures, both node 22/24). - src/phase.cts, src/milestone.cts: match the row by its FIRST cell's value (the requirement-ID column) regardless of that column's HEADER name, via `Object.values(row)[0]` (updateTableCell builds the record in header order). This mirrors OLD's first-cell `\|\s*<id>\s*\|` anchor, restoring header-name independence while keeping the seam. - src/milestone.cts hasTable: broadened from `Requirement`-only to also recognize `Requirement ID` / `REQ-ID` / `REQ ID` headers, kept in sync with the now-positional rowMatch/hasRow so a REQ-ID-headed table participates in the ADR-2143 §6 write-set and the #2140 table_unmatched drift check (it was silently omitted before — a checkbox-only partial reconcile against a REQ-ID table could report as fully reconciled). The `Requirement`-headed path is byte-identical to OLD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#2143): replace stale structural milestone guards with behavioural suite The `milestone.cjs regex global state fix` block was a source-structure guard (allow-test-rule: structural-regression-guard) — it readFileSync'd the compiled milestone.cjs and asserted removed regex idioms (`tablePattern.test`, `afterTable !== reqContent`, `doneTable = new RegExp(...)`). Phase 4's migration deleted those regexes (table update is now updateTableCell), making the assertions obsolete. Per the Test Cleanup rule, replace them in-PR with a behavioural suite driving the compiled CLI: - multi-ID mark-complete flips all IDs (guards the lastIndex/global-state class), - Pending->Complete flip under both `REQ-ID` and `Requirement` headers (#2769), - idempotent already_complete detection with no corruption, - REQ-ID-headed table participates in write_set (traceability entry, applied), - REQ-ID-headed table trips #2140 table_unmatched drift on a missing row. Pruned the now-nonexistent structural-regression-guard entry from the lint-allow-test-rule-refs allowlist (the source-text-is-the-product entry for the same file remains valid). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): backfill PR number 2253 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): record-metric targets its own metrics table, not By-Phase velocity `state record-metric` appended its per-plan row (`| Phase 1 P1 | 5min | 3 tasks | 4 files |`) into the FIRST table under `## Performance Metrics` — which on a real template-derived STATE.md is the By-Phase velocity table `| Phase | Plans | Total | Avg/Plan |`, polluting it on EVERY plan completion (execute-plan.md:414 is a per-plan call). The command's own metrics table is `| Plan | Duration | Tasks | Files |`, which the template does not ship, so the row never reached it; the scaffold branch also emitted a wrong `| Phase | Plan | Duration | Notes |` header matching neither the row nor the canonical table. Pre-existing (predates Phase 4); surfaced while migrating this site and fixed here per no-defer, on the user's explicit go-ahead. - src/state.cts cmdStateRecordMetric: locate the metrics table by its own header shape (`Plan|Duration|Tasks|Files`, via splitTableRow/isDelimiterRow) rather than "first table in the section". When the section exists but has no metrics table (only the By-Phase table), self-heal by appending a fresh **Per-Plan Metrics:** table to the END of the section body — By-Phase table, Recent Trend and footer preserved verbatim, no duplicate `## Performance Metrics` heading, created stays false. Absent-section scaffold header corrected to the canonical `| Plan | Duration | Tasks | Files |`. Ragged-tolerance + None-yet preserved. - Not touching templates/state.md (golden-install-parity hashed) — record-metric self-creates the table on first use instead. Failing-first regression test (tests/state.test.cjs) demonstrates the By-Phase pollution on the pre-fix build, then green after. Verified: no pollution, self- heal idempotency, both-tables isolation, content/heading preservation, flags, None-yet, corrected scaffold header (23-check adversarial harness + all existing record-metric scenarios). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#2143): deleteSection seam primitive (level-bounded whole-section removal) ADR-2143 §4 shipped withSection/collectSection (replace a section BODY) but no way to DELETE a section (heading + body). Phase 4 suppressed the phase-remove section delete instead of building it. deleteSection(content, predicate, opts) locates the section via the collectSection machinery and splices out from the heading's start offset to the next same-or-higher-level heading — so a level-3 `### Phase N` delete stops at a following level-2 `## Progress`, never past it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): phase remove no longer deletes ## Progress on last-phase removal updateRoadmapAfterPhaseRemoval deleted a `### Phase N` detail section with a greedy raw regex whose lazy scan, on the LAST phase, ran to EOF and destroyed the following `## Progress` heading and its entire tracking table — silent data loss, uncovered by tests (removal tests only exercised a middle phase). Migrated onto the new deleteSection seam (level-bounded, stops at `## Progress`); dropped the allow-adhoc-markdown SECTION-DELETION suppression. Failing-first regression (tests/phase.test.cjs) removes the LAST phase and asserts the ## Progress heading + table survive; middle-phase removal is byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#2143): deleteTableRow seam primitive (row removal, ragged-tolerant) Sibling of updateTableCell: locates the first GFM table, matches a DATA row by predicate (ragged-tolerant record build, header order), and splices out that row's whole line preserving every other byte. Returns {ok:false,reason} on no table / no match. Enables migrating the phase-remove Progress-table row delete off its ad-hoc regex (ADR-2143 §7 — the "future row-delete seam" Phase 4 punted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): phase remove deletes the Progress row via deleteTableRow The Progress-table row delete used a whole-document regex with two defects: (a) `\.?\s` required whitespace after the phase number, so a COMPACT row `|2|Beta|` was never deleted (stale row left behind); (b) unscoped — it could strike a row in a different table (e.g. an earlier `| Phase | Requirements |` table). Migrated onto deleteTableRow, scoped to the `## Progress` section (mirrors deriveProgressFromRoadmap), matching the row by first-cell phase number (integer zero-pad-insensitive; decimal exact; removing `2` never touches `2.5`). Both allow-adhoc-markdown suppressions removed. New behavioural tests: compact unpadded row deleted; padded byte-parity on the surviving rows (their ordinal correctly renumbers via the pre-existing renumber block). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): deleteTableRow leaves no dangling newline on last EOL-less row Deleting the final row of a table with no trailing EOL sliced from the row's start to end-of-string, stranding the newline that terminated the previous line. Back rowStart over the preceding \r?\n in that branch so the table ends cleanly. (Caught by the primitive's own unit test on gsd-test; local scenario checks missed the no-trailing-EOL edge.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): migrate read-only section-collects onto collectSection Six hand-rolled `## Section` read-extract regexes replaced by the collectSection seam (behaviour-preserving; extracted bodies feed the same downstream parsers): state.cts matchSessionSection (## Session / ## Session Continuity) + ## Blockers, smart-entry.cts ## Blockers, audit.cts ## Current Focus + ## Open Questions. Removes 6 allow-adhoc-markdown "pending #1372" suppressions. Incidental fix: the old Session regex `## Session[ \t]*\n` silently failed on a CRLF `## Session\r\n` heading (Windows STATE.md), nulling all session fields; collectSection is CRLF-safe, so session state now resolves on Windows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): fence-safe state-transition section writes + dedup stripFrontmatter - milestoneCompleteCore's `## Current Position` and `## Operator Next Steps` section resets used fence-blind raw regexes that a fenced `##` inside the body could truncate/mis-target (#2130/#2067/#2080 class). Migrated onto a fence-aware tokenizeHeadings-based helper (resetSectionVerbatim) that is byte-identical to the old output on the canonical path (9/9 fixtures) and correctly ignores a fenced fake heading (proven robustness gain). - mutateCurrentPositionFirstTime: hand-rolled locate+splice → collectSection + replaceSection (byte-parity). - stripFrontmatter was inlined byte-identically in state.cts AND state-transition.cts; hoisted the single canonical copy into frontmatter.cts (both call sites now import it) + unit tests — eliminates the divergence risk per CLAUDE.md "Generative Fix Divergence". Removes 3 allow-adhoc-markdown / #1372 markers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): name-address By-Phase sum + uat parse, eslint recall hole, catches - state.cts By-Phase "Total plans completed" sum: positional 2nd-cell regex → name-addressed splitTableRow read (correct on a reordered header, where the old code silently summed the wrong column). Marker removed. - uat.cts parseVerificationItems: loose pipe regex → splitTableRow within the existing table/numbered/bullet union scan (item list byte-identical; does NOT reintroduce the reverted strict-parseMarkdownTable item-drop). Marker removed. - eslint no-adhoc-markdown-parsing: close the `new RegExp(identifier)` recall hole — resolve a const-declared table-shaped regex identifier (mirrors the .replace() detector) + RuleTester cases; param/call args stay out (boundary). - commands.cts: delete a lying comment that claimed the scaffold date "stays on raw UTC / deferred" — #2136 already moved it to realClock.localToday(). - Empty catches (classified, not blind-swept): removed 4 dead try/catch; fixed 3 error-hiding (phase-insert decimal-dir I/O collision now fails loud; phase-remove rename partial-failure surfaced; milestone-archive true count via finally); left best-effort swallows with justification comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): extractFencedBlock seam + migrate api-coverage named fence parseCoverageMatrix extracted its ```coverage fenced block with an ad-hoc regex (the last real allow-adhoc-markdown suppression). Added extractFencedBlock to the markdown-sectionizer seam (reuses stripFencedCode's CommonMark fence engine — info-string match, ~~~/backtick, nesting, indent) and migrated onto it; byte- parity on the parsed CoverageMatrix across 8 fixtures. Only security.cts:367 (a genuine `<|role|>` protocol-token false-positive, not a GFM table) remains marked in src/ — the "prohibition with teeth" goal (nothing grandfathered but a true FP) is met. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): By-Phase row insert is name-addressed (insertTableRow seam) updatePerformanceMetricsSection's INSERT-new-row branch located the By-Phase table with a canonical-column-order-only regex + a hardcoded positional row literal, so on a reordered header it silently inserted nothing — inconsistent with the now name-addressed UPDATE and SUM halves of the same function. Added insertTableRow (markdown-table seam sibling of updateTableCell/deleteTableRow: name-addressed, header-order-agnostic, EOL-preserving) and migrated the branch onto it, mapping By-Phase values by column NAME. Canonical-order output is byte-identical; a reordered header now inserts a correctly-mapped row; a pre-existing CRLF mixed-EOL splice glitch is incidentally fixed. Retired the now-dead byPhaseTablePattern const. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): phase-list checkbox flip via updateBullet seam Added updateBullet (markdown-sectionizer): a fence-aware, offset-tracked single-bullet write primitive (GFM 1–4-space marker tolerance) — the write counterpart to read-only iterateBullets. Migrated mutateMilestonePhase's phase-list checkbox flip (`- [ ] Phase N …` → `- [x] … (completed <date>)`) off its whole-slice regex onto it, same milestone-slice scope + clock seam. Byte-identical across simple / idempotent / metachar-title / double-space / CRLF scenarios. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): scope the Progress-ordinal renumber to ## Progress via seam phase remove's integer-renumber decremented Progress-table phase ordinals with a whole-document `content.replace(/(\|\s*)(\d+)(\.\s)/g, …)` — unscoped, so it also rewrote any `| N. …` cell in an unrelated/decoy table (same class as the batch-2 row-delete scoping bug). Migrated onto updateTableCell, scoped to the ## Progress section, decrementing each affected row's leading phase ordinal by column name. Byte-identical on canonical Progress tables + multi-row + decimal-sibling cases; a decoy `| 3. … |` row before ## Progress is now correctly left untouched. The sibling heading / checkbox-bullet / PLAN.md-filename / Depends-on-prose renumbers are not GFM-table mutations (outside ADR-2143's table/section mandate) — left as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2143): review fixes — scope traceability write, restore Current Position H3-stop Adversarial review of the remediation (BLOCK verdict) — all 9 findings fixed: - F1 (BLOCKER): requirements mark-complete / phase complete flipped the checkbox but NOT the traceability row on the shipped template, because updateTableCell bound to the FIRST table (## Out of Scope, no Status column) instead of the ## Traceability table — the #2140 silent-divergence class, re-introduced by the seam migration and missed by tests (fixtures had Traceability first). Scoped the write + hasRow probe to the ## Traceability section slice (updateTraceability Cell helper) in milestone.cts + phase.cts. Failing-first tests on the Out-of-Scope-before-Traceability layout; the #2769 first-cell match preserved. - F2 (MAJOR): mutateCurrentPositionFirstTime restored to locateCurrentPosition (STOP_H2_PLUS) — collectSection's default H2-stop swallowed a level-3 subsection and the field regexes clobbered it (#2130 class). - F3/F8: Progress-ordinal renumber re-escapes via escapeCell + keys padding recovery by row index (was de-escaping `\|` and losing padding on dup values). - F4: insertTableRow escapes cell values internally. - F5: updateBullet accepts a tab after the marker (`[ \t]{1,4}`). - F7: resetSectionVerbatim consumes CRLF blank lines (byte-parity on CRLF). - F6/F9: corrected two misleading comments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): data-loss + CRLF-session user-facing fixes (#2253) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#2143): de-flake the G10 windsurf ReDoS-guard wall-clock assertion The G10 test asserted `elapsedMs < 1000` for a 200k-char payload — a wall-clock assertion (CLAUDE.md: never assert on wall-clock time) that flaked on a loaded node24 bench at ~1.1s. It was redundant: runHook's spawnSync `timeout: 10000` already SIGKILLs a catastrophic-backtracking hook, so the exit-0 assertion is the real ReDoS guard. Removed the timing assertion; kept exit-0 + documented the subprocess-timeout mechanism. Surfaced (not caused) by this branch's gsd-test runs loading the bench; unrelated to the markdown-parsing changes but fixed in place per the no-flaky-tests rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -977,4 +977,322 @@ describe('no-adhoc-markdown-parsing rule', () => {
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// ── TABLE-REGEX (ADR-2143 §7) ──────────────────────────────────────────────
|
||||
|
||||
test('invalid: table-row/cell regex with escaped pipe and negated-pipe cell class', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// /\|[^|]*\|/ — the classic hand-rolled table-row/cell scan fingerprint
|
||||
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
||||
filename: 'src/some-module.cts',
|
||||
errors: [{ messageId: 'tableRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: table-cell regex with escaped-pipe class variant [^\\|]', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: String.raw`const cellRe = /\|\s*([^\|]+)\s*\|/;`,
|
||||
filename: 'src/some-module.cts',
|
||||
errors: [{ messageId: 'tableRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: parseMarkdownTable() seam call is NOT flagged (no regex literal)', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const { parseMarkdownTable } = require('./markdown-table');
|
||||
const result = parseMarkdownTable(sectionText);
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: escaped pipe alone (no negated-pipe cell class) is NOT flagged', () => {
|
||||
// A bare delimiter probe like /^\|/ or /\|\|/ has an escaped pipe but no
|
||||
// [^|] cell-capture class — not a table-row/cell scan, so it must stay
|
||||
// conservative and not fire.
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`const isPipeDelim = /^\|/;`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
{
|
||||
code: String.raw`const orDelim = /a\|b/;`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: annotated table-regex with allow-adhoc-markdown is NOT flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`const rowRe = /\|[^|]*\|/; // allow-adhoc-markdown: not a table scan, protocol-marker probe`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: table-regex in a non-src/*.cts file is not flagged (rule is inert there)', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`const rowRe = /\|[^|]*\|/;`,
|
||||
filename: 'scripts/helper.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// ── TABLE-REGEX via new RegExp(<Literal-string | TemplateLiteral>) (#2143 Phase 4) ─
|
||||
|
||||
test('invalid: new RegExp(<string literal>) matching the table fingerprint', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// new RegExp('\|[^|]*\|') — doubled backslashes cook to a literal \|
|
||||
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
||||
filename: 'src/some-module.cts',
|
||||
errors: [{ messageId: 'tableRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: new RegExp(<template literal>) whose STATIC quasis match the table fingerprint (dynamic segment ignored)', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// new RegExp(`^(\|\s*${phase}\.?\s[^|]*(?:\|[^\n]*))$`) — the exact
|
||||
// roadmap.cts/phase.cts tableRowPattern shape, dynamic ${phase} in the middle.
|
||||
code: 'const tableRowPattern = new RegExp(`^(\\\\|\\\\s*${phase}\\\\.?\\\\s[^|]*(?:\\\\|[^\\\\n]*))$`, \'im\');',
|
||||
filename: 'src/some-module.cts',
|
||||
errors: [{ messageId: 'tableRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: new RegExp(`## Phase ${x}`) — dynamic heading pattern, static quasis are not table/section (non-table)', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const headingRe = new RegExp(`## Phase ${x}`, \'i\');',
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: new RegExp(someIdentifier) — pattern built elsewhere and referenced by variable is out of scope for this check', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const pattern = buildRowPattern();
|
||||
const rowRe = new RegExp(pattern, 'im');
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// ── new RegExp(identifier) resolved via resolveVariableInit scope walk (#2245 recall-hole fix) ─
|
||||
|
||||
test('invalid: new RegExp(identifier) resolves a const-declared identifier whose pattern matches the table fingerprint', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// const tablePattern = '\|[^|]*\|' (doubled backslashes cook to a literal \|),
|
||||
// then new RegExp(tablePattern) — the pattern is built one hop away via a
|
||||
// const-declared identifier instead of inline, which used to escape the
|
||||
// fingerprint check entirely (the recall hole this fix closes).
|
||||
code: String.raw`
|
||||
const tablePattern = '\\|[^|]*\\|';
|
||||
const rowRe = new RegExp(tablePattern);
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
errors: [{ messageId: 'tableRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: new RegExp(identifier) resolves a const-declared identifier whose pattern is NOT table-shaped', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const headingPattern = '## Phase';
|
||||
const headingRe = new RegExp(headingPattern);
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: new RegExp(identifier) does NOT resolve a function-parameter identifier (documented boundary)', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`
|
||||
function buildRowRegex(tablePattern) {
|
||||
return new RegExp(tablePattern);
|
||||
}
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: annotated new RegExp(...) table-regex with allow-adhoc-markdown is NOT flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|'); // allow-adhoc-markdown: protocol-marker probe, not a table scan`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: new RegExp(...) table-regex in a non-src/*.cts file is not flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`,
|
||||
filename: 'scripts/helper.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// ── ADHOC-REPLACE-MUTATION: .replace() on a roadmap/state receiver (#2143 Phase 4) ─
|
||||
|
||||
test('invalid: roadmapContent.replace(<inline table-fingerprint literal>, ...) trips both the CallExpression and Literal detectors', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
||||
filename: 'src/some-module.cts',
|
||||
// CallExpression is the outer/enter-first node; its Literal argument
|
||||
// (visited next, on descent) is a second, independent finding.
|
||||
errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: stateContent.replace(<variable holding a table-fingerprint regex>, ...) resolves the variable via scope', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: String.raw`
|
||||
const rowRe = /\|[^|]*\|/;
|
||||
stateContent.replace(rowRe, 'x');
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
errors: [{ messageId: 'tableRegex' }, { messageId: 'adhocReplaceMutation' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: withSection(...) call is NOT a .replace() and is never flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const { withSection } = require('./markdown-sectionizer');
|
||||
const result = withSection(content, 'x', (body) => body);
|
||||
`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: foo.replace(/x/, "y") — neither the receiver name nor the pattern match, not flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: `foo.replace(/x/, 'y');`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: state.replace(/x/, "y") — matching receiver name but non-matching pattern, not flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: `state.replace(/x/, 'y');`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: allow-adhoc-markdown suppresses an inline ad-hoc .replace() mutation', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x'); // allow-adhoc-markdown: pre-seam write path`,
|
||||
filename: 'src/some-module.cts',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: .replace() ad-hoc mutation in a non-src/*.cts file is not flagged', () => {
|
||||
ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, {
|
||||
valid: [
|
||||
{
|
||||
code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`,
|
||||
filename: 'scripts/helper.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
extractFrontmatter,
|
||||
reconstructFrontmatter,
|
||||
spliceFrontmatter,
|
||||
stripFrontmatter,
|
||||
parseMustHavesBlock,
|
||||
} = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
|
||||
@@ -604,6 +605,37 @@ must_haves:
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripFrontmatter ───────────────────────────────────────────────────────
|
||||
// #2143 audit dedup: stripFrontmatter was byte-identically duplicated in both
|
||||
// state.cts and state-transition.cts. Both call sites now import this single
|
||||
// canonical implementation from frontmatter.cts.
|
||||
|
||||
describe('stripFrontmatter', () => {
|
||||
test('strips a single frontmatter block, leaving the body', () => {
|
||||
const content = '---\nname: foo\ntype: execute\n---\nBody text.\n';
|
||||
assert.strictEqual(stripFrontmatter(content), 'Body text.\n');
|
||||
});
|
||||
|
||||
test('returns content unchanged when no frontmatter is present', () => {
|
||||
const content = '# Just a heading\n\nSome body text.\n';
|
||||
assert.strictEqual(stripFrontmatter(content), content);
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
const content = '---\r\nname: foo\r\n---\r\nBody text.\r\n';
|
||||
assert.strictEqual(stripFrontmatter(content), 'Body text.\r\n');
|
||||
});
|
||||
|
||||
test('greedily strips multiple stacked frontmatter blocks (corruption recovery)', () => {
|
||||
const content = '---\nname: foo\n---\n---\nname: bar\n---\nBody text.\n';
|
||||
assert.strictEqual(stripFrontmatter(content), 'Body text.\n');
|
||||
});
|
||||
|
||||
test('leaves a body-only "---" horizontal rule (not at byte 0) untouched', () => {
|
||||
const content = 'Body text.\n\n---\n\nMore text.\n';
|
||||
assert.strictEqual(stripFrontmatter(content), content);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Behavioral tests for markdown-sectionizer.cjs
|
||||
*
|
||||
* Module: gsd-core/bin/lib/markdown-sectionizer.cjs
|
||||
* Exports: stripFencedCode, tokenizeHeadings, collectSections, collectSection,
|
||||
* iterateBullets, extractTaggedBlocks, replaceSection, withSection
|
||||
* Exports: stripFencedCode, extractFencedBlock, tokenizeHeadings, collectSections,
|
||||
* collectSection, iterateBullets, updateBullet, extractTaggedBlocks,
|
||||
* stripTaggedBlocks, replaceSection, withSection, deleteSection
|
||||
*
|
||||
* Covers the parser QA matrix from CONTRIBUTING.md §'Parser and project-file inputs':
|
||||
* - LF vs CRLF line endings
|
||||
@@ -30,14 +31,17 @@ const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const {
|
||||
stripFencedCode,
|
||||
extractFencedBlock,
|
||||
tokenizeHeadings,
|
||||
collectSections,
|
||||
collectSection,
|
||||
iterateBullets,
|
||||
updateBullet,
|
||||
extractTaggedBlocks,
|
||||
stripTaggedBlocks,
|
||||
replaceSection,
|
||||
withSection,
|
||||
deleteSection,
|
||||
} = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
||||
|
||||
// ─── stripFencedCode ──────────────────────────────────────────────────────────
|
||||
@@ -179,6 +183,110 @@ describe('stripFencedCode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractFencedBlock ───────────────────────────────────────────────────────
|
||||
|
||||
describe('extractFencedBlock', () => {
|
||||
test('returns null for empty/non-string content', () => {
|
||||
assert.equal(extractFencedBlock('', 'coverage'), null);
|
||||
assert.equal(extractFencedBlock(null, 'coverage'), null);
|
||||
assert.equal(extractFencedBlock(undefined, 'coverage'), null);
|
||||
});
|
||||
|
||||
test('returns null when infoString is non-string', () => {
|
||||
assert.equal(extractFencedBlock('```coverage\nbody\n```', null), null);
|
||||
assert.equal(extractFencedBlock('```coverage\nbody\n```', undefined), null);
|
||||
});
|
||||
|
||||
test('extracts inner body of a ```coverage block', () => {
|
||||
const src = [
|
||||
'prose before',
|
||||
'```coverage',
|
||||
'[{"capability":"search","decision":"INTEGRATE","reason":""}]',
|
||||
'```',
|
||||
'prose after',
|
||||
].join('\n');
|
||||
const result = extractFencedBlock(src, 'coverage');
|
||||
assert.equal(result, '[{"capability":"search","decision":"INTEGRATE","reason":""}]');
|
||||
});
|
||||
|
||||
test('is case-insensitive on the info string', () => {
|
||||
const src = '```Coverage\ninner text\n```';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), 'inner text');
|
||||
assert.equal(extractFencedBlock(src, 'COVERAGE'), 'inner text');
|
||||
});
|
||||
|
||||
test('ignores a ```json block when searching for coverage', () => {
|
||||
const src = '```json\n{"a":1}\n```';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), null);
|
||||
});
|
||||
|
||||
test('returns null when no fence is present at all', () => {
|
||||
const src = 'just prose, no fences here.\n## Heading\n- bullet';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), null);
|
||||
});
|
||||
|
||||
test('returns null when the named fence is present but unterminated (EOF inside fence)', () => {
|
||||
const src = '```coverage\nunterminated body, no closing fence';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), null);
|
||||
});
|
||||
|
||||
test('handles ~~~ fences', () => {
|
||||
const src = '~~~coverage\ntilde-fenced body\n~~~';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), 'tilde-fenced body');
|
||||
});
|
||||
|
||||
test('an info string with trailing text after the target word does NOT match (exact-equality, not prefix)', () => {
|
||||
const src = '```coverage extra-stuff\nbody\n```';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), null, 'the trimmed info string is "coverage extra-stuff", not "coverage"');
|
||||
});
|
||||
|
||||
test('trailing whitespace on the opening info-string line is trimmed before comparison', () => {
|
||||
const src = '```coverage \nbody\n```';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), 'body');
|
||||
});
|
||||
|
||||
test('a fence nested inside another (longer-delimiter, differently-named) fence is not mis-extracted', () => {
|
||||
// CommonMark same-char fences only truly nest when the OUTER delimiter run
|
||||
// is longer (a bare 3-backtick closer would prematurely close a 3-backtick
|
||||
// outer fence too — there's no other way for a backtick fence to contain
|
||||
// another backtick fence). Outer uses 4 backticks, inner uses 3, so the
|
||||
// inner ```coverage ... ``` pair is entirely CONTENT of the outer fence
|
||||
// and never becomes its own open/close block — searching for 'coverage'
|
||||
// must find nothing, not the outer block's body.
|
||||
const src = [
|
||||
'````outer',
|
||||
'```coverage',
|
||||
'nested body',
|
||||
'```',
|
||||
'````',
|
||||
].join('\n');
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), null, 'nested ```coverage must not be mis-extracted as a top-level match');
|
||||
assert.equal(
|
||||
extractFencedBlock(src, 'outer'),
|
||||
'```coverage\nnested body\n```',
|
||||
'the OUTER block is the one real fence and contains the nested lines (including their literal ``` markers) as raw content',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns the FIRST matching block when multiple ```coverage blocks are present', () => {
|
||||
const src = [
|
||||
'```coverage',
|
||||
'first',
|
||||
'```',
|
||||
'prose',
|
||||
'```coverage',
|
||||
'second',
|
||||
'```',
|
||||
].join('\n');
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), 'first');
|
||||
});
|
||||
|
||||
test('an empty fence body returns an empty string, not null', () => {
|
||||
const src = '```coverage\n\n```';
|
||||
assert.equal(extractFencedBlock(src, 'coverage'), '');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── tokenizeHeadings ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('tokenizeHeadings', () => {
|
||||
@@ -552,6 +660,252 @@ describe('iterateBullets', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateBullet ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateBullet', () => {
|
||||
test('non-string / empty content is returned unchanged', () => {
|
||||
assert.equal(updateBullet(null, () => true, (l) => l), null);
|
||||
assert.equal(updateBullet(undefined, () => true, (l) => l), undefined);
|
||||
assert.equal(updateBullet('', () => true, (l) => l), '');
|
||||
});
|
||||
|
||||
test('no-match is a no-op — content is returned unchanged', () => {
|
||||
const content = '- [ ] Phase 1: Foo\n- [ ] Phase 2: Bar\n';
|
||||
const result = updateBullet(content, (text) => text.includes('Phase 9'), (l) => `${l} EDITED`);
|
||||
assert.equal(result, content);
|
||||
});
|
||||
|
||||
test('flips only the matched bullet; every other bullet/line is byte-identical', () => {
|
||||
const content = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 1: Foundation',
|
||||
'- [ ] Phase 2: API',
|
||||
'- [ ] Phase 3: Polish',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = updateBullet(
|
||||
content,
|
||||
(bulletText) => bulletText.includes('Phase 2'),
|
||||
(rawLine) => `${rawLine.replace('[ ]', '[x]')} (completed 2026-01-01)`,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'- [ ] Phase 1: Foundation',
|
||||
'- [x] Phase 2: API (completed 2026-01-01)',
|
||||
'- [ ] Phase 3: Polish',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('bulletText passed to match has the marker/checkbox stripped; rawLine is the untouched physical line', () => {
|
||||
const content = '- [ ] Phase 1: Foo\n';
|
||||
let seenText;
|
||||
let seenRaw;
|
||||
updateBullet(
|
||||
content,
|
||||
(bulletText, rawLine) => {
|
||||
seenText = bulletText;
|
||||
seenRaw = rawLine;
|
||||
return true;
|
||||
},
|
||||
(rawLine) => rawLine,
|
||||
);
|
||||
assert.equal(seenText, 'Phase 1: Foo');
|
||||
assert.equal(seenRaw, '- [ ] Phase 1: Foo');
|
||||
});
|
||||
|
||||
test('recognises dash and numbered bullets too, not only checkboxes', () => {
|
||||
const dashContent = '- Alpha\n- Beta\n';
|
||||
const dashResult = updateBullet(dashContent, (t) => t === 'Beta', (l) => `${l}!`);
|
||||
assert.equal(dashResult, '- Alpha\n- Beta!\n');
|
||||
|
||||
const numberedContent = '1. Alpha\n2. Beta\n';
|
||||
const numberedResult = updateBullet(numberedContent, (t) => t === 'Beta', (l) => `${l}!`);
|
||||
assert.equal(numberedResult, '1. Alpha\n2. Beta!\n');
|
||||
});
|
||||
|
||||
test('ignores a matching bullet inside a fenced code block — the real bullet outside the fence is flipped', () => {
|
||||
const content = [
|
||||
'- [ ] Phase 1: Foo',
|
||||
'```md',
|
||||
'- [ ] Phase 1: Foo (inside fence, must not match)',
|
||||
'```',
|
||||
'- [ ] Phase 2: Bar',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = updateBullet(
|
||||
content,
|
||||
(bulletText) => bulletText.includes('Phase 1'),
|
||||
(rawLine) => rawLine.replace('[ ]', '[x]'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'- [x] Phase 1: Foo',
|
||||
'```md',
|
||||
'- [ ] Phase 1: Foo (inside fence, must not match)',
|
||||
'```',
|
||||
'- [ ] Phase 2: Bar',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('a matching bullet that exists ONLY inside a fenced code block is a genuine no-op', () => {
|
||||
const content = [
|
||||
'```md',
|
||||
'- [ ] Phase 9: OnlyInFence',
|
||||
'```',
|
||||
'- [ ] Phase 1: Foo',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = updateBullet(
|
||||
content,
|
||||
(bulletText) => bulletText.includes('Phase 9'),
|
||||
(rawLine) => rawLine.replace('[ ]', '[x]'),
|
||||
);
|
||||
|
||||
assert.equal(result, content, 'the only matching bullet lives inside the fence — no-op');
|
||||
});
|
||||
|
||||
test('CRLF line endings are preserved — only the matched line changes; others keep \\r\\n untouched', () => {
|
||||
const content = '- [ ] Phase 1: Foo\r\n- [ ] Phase 2: Bar\r\n';
|
||||
const result = updateBullet(
|
||||
content,
|
||||
(bulletText) => bulletText.includes('Phase 2'),
|
||||
(rawLine) => rawLine.replace('[ ]', '[x]'),
|
||||
);
|
||||
assert.equal(result, '- [ ] Phase 1: Foo\r\n- [x] Phase 2: Bar\r\n');
|
||||
});
|
||||
|
||||
test('transform returning a non-string is a bounded no-op', () => {
|
||||
const content = '- [ ] Phase 1: Foo\n';
|
||||
const result = updateBullet(content, () => true, () => undefined);
|
||||
assert.equal(result, content);
|
||||
});
|
||||
|
||||
test('only the FIRST matching bullet is replaced, even when a later bullet also matches', () => {
|
||||
const content = '- [ ] Phase 1: Foo\n- [ ] Phase 1: Foo (duplicate)\n';
|
||||
const result = updateBullet(
|
||||
content,
|
||||
(bulletText) => bulletText.startsWith('Phase 1'),
|
||||
(rawLine) => rawLine.replace('[ ]', '[x]'),
|
||||
);
|
||||
assert.equal(result, '- [x] Phase 1: Foo\n- [ ] Phase 1: Foo (duplicate)\n');
|
||||
});
|
||||
|
||||
test('preserves the indentation of the matched bullet line', () => {
|
||||
const content = ' - [ ] Nested: Item\n';
|
||||
const result = updateBullet(content, (t) => t.includes('Nested'), (l) => l.replace('[ ]', '[x]'));
|
||||
assert.equal(result, ' - [x] Nested: Item\n');
|
||||
});
|
||||
|
||||
// GFM/CommonMark tolerates more than one space between a list marker and
|
||||
// its content — a checkboxRe pinned to exactly one space fails to recognise
|
||||
// `- [ ] …` (two spaces) as a CHECKBOX at all; it instead falls through to
|
||||
// the generic dash-bullet shape, leaking the literal `[ ] ` marker into
|
||||
// bulletText (` [ ] Phase 1: Foo` instead of the clean `Phase 1: Foo`) —
|
||||
// silently wrong for any caller whose `match`/`transform` assumes bulletText
|
||||
// has the checkbox marker stripped.
|
||||
test('recognises a multi-space marker (`- [ ]`, two spaces) as a proper checkbox bullet — bulletText has the marker cleanly stripped', () => {
|
||||
const content = '- [ ] Phase 1: Foo\n';
|
||||
let seenBulletText;
|
||||
const result = updateBullet(
|
||||
content,
|
||||
(bulletText) => {
|
||||
seenBulletText = bulletText;
|
||||
return bulletText.includes('Phase 1');
|
||||
},
|
||||
(rawLine) => `${rawLine.replace('[ ]', '[x]')} (completed 2026-01-01)`,
|
||||
);
|
||||
assert.equal(seenBulletText, 'Phase 1: Foo', 'bulletText must be cleanly stripped of the checkbox marker, not leak it in as dash-bullet text');
|
||||
assert.equal(result, '- [x] Phase 1: Foo (completed 2026-01-01)\n');
|
||||
});
|
||||
|
||||
test('recognises a multi-space dash/numbered marker too (not only checkboxes)', () => {
|
||||
const dashResult = updateBullet('- Alpha\n- Beta\n', (t) => t === 'Beta', (l) => `${l}!`);
|
||||
assert.equal(dashResult, '- Alpha\n- Beta!\n');
|
||||
|
||||
const numberedResult = updateBullet('1. Alpha\n2. Beta\n', (t) => t === 'Beta', (l) => `${l}!`);
|
||||
assert.equal(numberedResult, '1. Alpha\n2. Beta!\n');
|
||||
});
|
||||
|
||||
test('#2245 F5: recognises a TAB-separated marker gap (`-\\t[ ]`), not only spaces', () => {
|
||||
// checkboxRe/dashRe/numberedRe used a literal-space `[ ]{1,4}` gap,
|
||||
// narrower than the OLD hand-rolled `-\s*\[` regex it replaced (`\s`
|
||||
// matches a tab too) — a `-\t[ ]` bullet was silently never offered to
|
||||
// `match`.
|
||||
const checkboxResult = updateBullet(
|
||||
'-\t[ ] Phase 1: Foo\n',
|
||||
(bulletText) => bulletText.includes('Phase 1'),
|
||||
(rawLine) => rawLine.replace('[ ]', '[x]'),
|
||||
);
|
||||
assert.equal(checkboxResult, '-\t[x] Phase 1: Foo\n');
|
||||
|
||||
const dashResult = updateBullet('-\tAlpha\n-\tBeta\n', (t) => t === 'Beta', (l) => `${l}!`);
|
||||
assert.equal(dashResult, '-\tAlpha\n-\tBeta!\n');
|
||||
|
||||
const numberedResult = updateBullet('1.\tAlpha\n2.\tBeta\n', (t) => t === 'Beta', (l) => `${l}!`);
|
||||
assert.equal(numberedResult, '1.\tAlpha\n2.\tBeta!\n');
|
||||
});
|
||||
|
||||
test('property: flip/unflip round-trip restores the original document byte-for-byte, and every non-target line stays untouched', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 8 }).chain((n) =>
|
||||
fc.record({
|
||||
n: fc.constant(n),
|
||||
labels: fc.array(
|
||||
fc.string({ minLength: 1, maxLength: 20 }).filter(
|
||||
(s) => !/[\r\n]/.test(s) && s.trim().length > 0 && !s.includes('EDITED'),
|
||||
),
|
||||
{ minLength: n, maxLength: n },
|
||||
),
|
||||
targetIdx: fc.integer({ min: 0, max: n - 1 }),
|
||||
}),
|
||||
),
|
||||
({ n, labels, targetIdx }) => {
|
||||
const lines = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
lines.push(`- [ ] Item ${k}: ${labels[k]}`);
|
||||
}
|
||||
const content = `${lines.join('\n')}\n`;
|
||||
|
||||
const flipped = updateBullet(
|
||||
content,
|
||||
(bulletText) => bulletText.includes(`Item ${targetIdx}:`),
|
||||
(rawLine) => `${rawLine.replace('[ ]', '[x]')} EDITED`,
|
||||
);
|
||||
|
||||
const flippedLines = flipped.split('\n');
|
||||
for (let k = 0; k < n; k++) {
|
||||
if (k === targetIdx) continue;
|
||||
assert.equal(flippedLines[k], lines[k], `line ${k} must stay byte-identical`);
|
||||
}
|
||||
assert.equal(flippedLines[targetIdx], `- [x] Item ${targetIdx}: ${labels[targetIdx]} EDITED`);
|
||||
|
||||
const restored = updateBullet(
|
||||
flipped,
|
||||
(bulletText) => bulletText.includes(`Item ${targetIdx}:`),
|
||||
(rawLine) => rawLine.replace('[x]', '[ ]').replace(' EDITED', ''),
|
||||
);
|
||||
assert.equal(restored, content, 'round-trip must restore the original document byte-for-byte');
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: heading inside fenced block is ignored end-to-end ───────────
|
||||
|
||||
describe('integration: fenced heading ignored', () => {
|
||||
@@ -1260,6 +1614,169 @@ describe('withSection: property-based tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteSection ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteSection', () => {
|
||||
test('no-match is a no-op — content is returned unchanged', () => {
|
||||
const content = '## A\nBody A\n## B\nBody B\n';
|
||||
const result = deleteSection(content, (h) => h.text === 'Nonexistent');
|
||||
assert.equal(result, content, 'no matching heading -> content unchanged');
|
||||
});
|
||||
|
||||
test('non-string content is returned unchanged', () => {
|
||||
assert.equal(deleteSection(null, () => true), null);
|
||||
assert.equal(deleteSection(undefined, () => true), undefined);
|
||||
});
|
||||
|
||||
test('deleting a middle ### Phase 2 leaves its siblings intact', () => {
|
||||
const content = [
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Setup',
|
||||
'',
|
||||
'### Phase 2: Auth',
|
||||
'**Goal:** Authentication',
|
||||
'',
|
||||
'### Phase 3: Features',
|
||||
'**Goal:** Core features',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = deleteSection(content, (h) => h.text.startsWith('Phase 2'));
|
||||
|
||||
assert.ok(!result.includes('Phase 2'), 'Phase 2 heading removed');
|
||||
assert.ok(!result.includes('Authentication'), 'Phase 2 body content removed');
|
||||
assert.ok(result.includes('### Phase 1: Foundation'), 'Phase 1 heading preserved');
|
||||
assert.ok(result.includes('Setup'), 'Phase 1 body preserved');
|
||||
assert.ok(result.includes('### Phase 3: Features'), 'Phase 3 heading preserved');
|
||||
assert.ok(result.includes('Core features'), 'Phase 3 body preserved');
|
||||
assert.ok(!result.includes('\n\n\n'), 'no triple-newline (double-blank) seam left behind');
|
||||
});
|
||||
|
||||
test('deleting the LAST ### Phase N does not touch a following ## Progress heading/table', () => {
|
||||
const content = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Setup',
|
||||
'',
|
||||
'### Phase 2: Auth',
|
||||
'**Goal:** Authentication',
|
||||
'',
|
||||
'## Progress',
|
||||
'',
|
||||
'| Phase | Plans | Status | Completed |',
|
||||
'|---|---|---|---|',
|
||||
'| 1 | 0/1 | Planned | - |',
|
||||
'| 2 | 0/1 | Planned | - |',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
// "### Phase 2" is the LAST phase heading in the document — a naive scan
|
||||
// for "the next Phase heading" would run to EOF and take ## Progress with
|
||||
// it. deleteSection's level-bounded stop (any heading, level <= target's
|
||||
// level) must stop at ## Progress instead.
|
||||
const result = deleteSection(content, (h) => h.text.startsWith('Phase 2'));
|
||||
|
||||
assert.ok(!result.includes('Phase 2'), 'Phase 2 section removed');
|
||||
assert.ok(!result.includes('Authentication'), 'Phase 2 body removed');
|
||||
assert.ok(result.includes('## Progress'), 'Progress heading survives');
|
||||
assert.ok(result.includes('| Phase | Plans | Status | Completed |'), 'Progress table header survives');
|
||||
assert.ok(result.includes('| 1 | 0/1 | Planned | - |'), 'Progress table row 1 survives');
|
||||
assert.ok(result.includes('| 2 | 0/1 | Planned | - |'), 'Progress table row 2 survives');
|
||||
});
|
||||
|
||||
test('a nested #### subsection under the target is removed with it', () => {
|
||||
const content = [
|
||||
'### Phase 2: Auth',
|
||||
'**Goal:** Authentication',
|
||||
'',
|
||||
'#### Phase 2.1: Follow-up',
|
||||
'**Goal:** Nested cleanup',
|
||||
'',
|
||||
'### Phase 3: Features',
|
||||
'**Goal:** Core features',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = deleteSection(content, (h) => h.text.startsWith('Phase 2:'));
|
||||
|
||||
assert.ok(!result.includes('Phase 2:'), 'Phase 2 heading removed');
|
||||
assert.ok(!result.includes('Phase 2.1'), 'nested #### subsection removed alongside its parent');
|
||||
assert.ok(!result.includes('Nested cleanup'), 'nested subsection body removed');
|
||||
assert.ok(result.includes('### Phase 3: Features'), 'sibling Phase 3 preserved');
|
||||
assert.ok(result.includes('Core features'), 'Phase 3 body preserved');
|
||||
});
|
||||
|
||||
test('stopAtLevel option is honored, mirroring collectSection (a shallower opener also stops at a deeper heading)', () => {
|
||||
const content = [
|
||||
'## Parent',
|
||||
'Parent intro',
|
||||
'### Child',
|
||||
'Child body',
|
||||
'## Sibling',
|
||||
'Sibling body',
|
||||
].join('\n');
|
||||
|
||||
// stopAtLevel: 3 makes the level-2 "Parent" opener ALSO stop at the next
|
||||
// level-3 heading (rather than nesting it, per the default levelBounded
|
||||
// rule) — so only "Parent intro" is removed; ### Child and everything
|
||||
// after it (including ## Sibling) survives untouched.
|
||||
const result = deleteSection(content, (h) => h.text === 'Parent', { stopAtLevel: 3 });
|
||||
|
||||
assert.ok(!result.includes('Parent intro'), 'Parent heading + its own intro line removed');
|
||||
assert.ok(result.includes('### Child\nChild body'), 'stopAtLevel:3 stops BEFORE ### Child — it survives');
|
||||
assert.ok(result.includes('## Sibling\nSibling body'), 'Sibling section untouched');
|
||||
});
|
||||
|
||||
test('levelBounded: false stops at the very next heading, regardless of level (deeper headings are not nested)', () => {
|
||||
const content = [
|
||||
'## Parent',
|
||||
'Parent intro',
|
||||
'### Child',
|
||||
'Child body',
|
||||
'## Sibling',
|
||||
'Sibling body',
|
||||
].join('\n');
|
||||
|
||||
// With levelBounded:false, "Parent" stops at the IMMEDIATE next heading
|
||||
// (### Child, even though it's a deeper level than Parent) instead of
|
||||
// nesting it — the opposite of the default levelBounded:true rule, which
|
||||
// would fold ### Child into Parent's deleted range as nested content.
|
||||
const result = deleteSection(content, (h) => h.text === 'Parent', { levelBounded: false });
|
||||
|
||||
assert.ok(!result.includes('Parent intro'), 'Parent heading + its own intro line removed');
|
||||
assert.ok(result.includes('### Child\nChild body'), 'levelBounded:false stops at ### Child — it survives, not nested');
|
||||
assert.ok(result.includes('## Sibling\nSibling body'), 'Sibling section untouched');
|
||||
});
|
||||
|
||||
test('a pre-existing double-blank separator immediately before the deleted heading is collapsed to one blank line', () => {
|
||||
const content = [
|
||||
'Intro paragraph.',
|
||||
'',
|
||||
'',
|
||||
'### Phase 2: Auth',
|
||||
'**Goal:** Authentication',
|
||||
'',
|
||||
'### Phase 3: Features',
|
||||
'**Goal:** Core features',
|
||||
].join('\n');
|
||||
|
||||
const result = deleteSection(content, (h) => h.text.startsWith('Phase 2'));
|
||||
|
||||
assert.ok(!result.includes('\n\n\n'), 'no triple-newline (double-blank) seam remains');
|
||||
assert.ok(
|
||||
result.includes('Intro paragraph.\n\n### Phase 3: Features'),
|
||||
`exactly one blank line survives at the seam, got ${JSON.stringify(result)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('section at EOF (no following heading) deletes through end of string', () => {
|
||||
const content = '### Phase 1\nBody 1\n### Phase 2\nBody 2';
|
||||
const result = deleteSection(content, (h) => h.text === 'Phase 2');
|
||||
assert.equal(result, '### Phase 1\nBody 1\n');
|
||||
});
|
||||
});
|
||||
|
||||
// Parity guard removed in T5 (ADR-1372): uat-predicate now imports stripFencedCode
|
||||
// from the seam directly, so comparing the seam to itself is tautological.
|
||||
// The seam's stripFencedCode correctness is already covered by the tests above.
|
||||
|
||||
@@ -23,7 +23,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const fc = require('./helpers/fast-check-setup.cjs');
|
||||
|
||||
const { parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns } = require('../gsd-core/bin/lib/markdown-table.cjs');
|
||||
const { parseMarkdownTable, matchTableSchema, TABLE_SCHEMAS, appendQuickTaskRow, findTableBySchema, findTableWithColumns, updateTableCell, deleteTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs');
|
||||
const { buildHeader, normalize } = require('../scripts/lint-table-schema-drift.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
@@ -564,6 +565,355 @@ describe('findTableWithColumns', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── updateTableCell (ADR-2143 §7 formatting-preserving cell write) ──────────
|
||||
|
||||
describe('updateTableCell', () => {
|
||||
const fourCol = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. Beta | 1/2 | In Progress | |',
|
||||
].join('\n');
|
||||
|
||||
const fiveCol = [
|
||||
'| Phase | Milestone | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | v1.0 | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. Beta | v1.0 | 1/2 | In Progress | |',
|
||||
].join('\n');
|
||||
|
||||
test('single-cell update leaves every other byte of the table identical', () => {
|
||||
const byPhase = (row) => row['Phase'].trim() === '2. Beta';
|
||||
const result = updateTableCell(fourCol, byPhase, 'Status', ' Complete ');
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
// Every line except the touched row's Status cell must be byte-identical.
|
||||
const beforeLines = fourCol.split('\n');
|
||||
const afterLines = result.value.split('\n');
|
||||
assert.equal(afterLines.length, beforeLines.length);
|
||||
assert.equal(afterLines[0], beforeLines[0], 'header must be untouched');
|
||||
assert.equal(afterLines[1], beforeLines[1], 'delimiter row must be untouched');
|
||||
assert.equal(afterLines[2], beforeLines[2], 'unrelated data row must be untouched');
|
||||
assert.equal(
|
||||
afterLines[3],
|
||||
'| 2. Beta | 1/2 | Complete | |',
|
||||
'only the Status cell text changed, all padding/other cells preserved',
|
||||
);
|
||||
|
||||
// Re-parsing still yields a valid, name-addressable table.
|
||||
const reparsed = parseMarkdownTable(result.value);
|
||||
assert.equal(reparsed.ok, true);
|
||||
assert.equal(reparsed.value.rows[1]['Status'], 'Complete');
|
||||
assert.equal(reparsed.value.rows[0]['Status'], 'Complete', 'row 0 (untouched) keeps its own value');
|
||||
});
|
||||
|
||||
test('function transformer receives the CURRENT trimmed cell value and its return is spliced verbatim', () => {
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
let seenCurrent = null;
|
||||
const result = updateTableCell(fourCol, byPhase, 'Completed', (current) => {
|
||||
seenCurrent = current;
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(current) ? ' NEW-DATE ' : current;
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(seenCurrent, '2026-01-01');
|
||||
assert.ok(result.value.includes('NEW-DATE'));
|
||||
// The other row's Completed cell (blank) is untouched.
|
||||
assert.ok(result.value.includes('| 2. Beta | 1/2 | In Progress | |'));
|
||||
});
|
||||
|
||||
test('unknown column returns {ok:false} without mutating anything', () => {
|
||||
const result = updateTableCell(fourCol, () => true, 'Nonexistent', 'x');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /unknown column/);
|
||||
});
|
||||
|
||||
test('no matching row returns {ok:false}', () => {
|
||||
const result = updateTableCell(fourCol, (row) => row['Phase'] === 'does not exist', 'Status', 'x');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /no matching row/);
|
||||
});
|
||||
|
||||
test('no table found returns {ok:false} (delegates parseMarkdownTable\'s reason)', () => {
|
||||
const result = updateTableCell('just some prose, no table here', () => true, 'Status', 'x');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /no table found/);
|
||||
});
|
||||
|
||||
test('5-column milestone table: Milestone cell and other columns stay byte-identical', () => {
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = updateTableCell(fiveCol, byPhase, 'Plans Complete', ' 2/2 ');
|
||||
assert.equal(result.ok, true);
|
||||
const lines = result.value.split('\n');
|
||||
assert.equal(lines[0], fiveCol.split('\n')[0]);
|
||||
assert.equal(lines[1], fiveCol.split('\n')[1]);
|
||||
assert.equal(lines[3], fiveCol.split('\n')[3], 'row 2 (Beta) is fully untouched');
|
||||
assert.ok(lines[2].includes('v1.0'), 'Milestone cell preserved');
|
||||
assert.ok(lines[2].includes('Complete '), 'Status cell preserved verbatim');
|
||||
});
|
||||
|
||||
test('CRLF line endings are preserved (no mixed EOL introduced)', () => {
|
||||
const crlfTable = fourCol.replace(/\n/g, '\r\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '2. Beta';
|
||||
const result = updateTableCell(crlfTable, byPhase, 'Status', ' Complete ');
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(!/(?<!\r)\n/.test(result.value), 'no bare \\n introduced');
|
||||
assert.ok(result.value.includes('\r\n'));
|
||||
const reparsed = parseMarkdownTable(result.value);
|
||||
assert.equal(reparsed.ok, true);
|
||||
assert.equal(reparsed.value.rows[1]['Status'], 'Complete');
|
||||
});
|
||||
|
||||
test('match receives (row, index) — index is the 0-based DATA-row position', () => {
|
||||
const seenIndices = [];
|
||||
updateTableCell(fourCol, (row, i) => { seenIndices.push(i); return i === 1; }, 'Status', ' Complete ');
|
||||
assert.deepEqual(seenIndices, [0, 1]);
|
||||
});
|
||||
|
||||
// ─── Ragged-tolerance (#2245 review Fix 2) ──────────────────────────────────
|
||||
// A single sibling row whose cell count doesn't match the header must never
|
||||
// abort the whole write — updateTableCell does its own header→column→
|
||||
// per-row scan and no longer gates on parseMarkdownTable(tableText).ok.
|
||||
|
||||
test('a ragged SIBLING row (extra cell) does not block updating a well-formed row', () => {
|
||||
const raggedSibling = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. Beta | 1/2 | In Progress | | extra |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = updateTableCell(raggedSibling, byPhase, 'Status', ' Complete ');
|
||||
assert.equal(result.ok, true, `expected the ragged sibling not to block the write, got ${JSON.stringify(result)}`);
|
||||
// The ragged row itself is untouched, byte-for-byte.
|
||||
assert.ok(result.value.includes('| 2. Beta | 1/2 | In Progress | | extra |'));
|
||||
});
|
||||
|
||||
test('a ragged SIBLING row (missing cell) does not block updating a well-formed row', () => {
|
||||
const raggedSibling = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 2. Beta | 1/2 | In Progress |',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = updateTableCell(raggedSibling, byPhase, 'Status', ' Complete ');
|
||||
assert.equal(result.ok, true, `expected the ragged sibling not to block the write, got ${JSON.stringify(result)}`);
|
||||
assert.ok(result.value.includes('| 2. Beta | 1/2 | In Progress |'));
|
||||
assert.ok(result.value.includes('| 1. Alpha | 2/2 | Complete | 2026-01-01 |'));
|
||||
});
|
||||
|
||||
test('a row matching on content but too short to physically contain the target column is skipped, not selected', () => {
|
||||
// The Beta row matches `byPhase` but has only 3 cells — no "Completed" cell
|
||||
// to splice into. It must be skipped (not selected) rather than erroring or
|
||||
// splicing garbage; a LATER well-formed row for the same match predicate
|
||||
// (here none exists) would still be reachable.
|
||||
const shortRow = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 2. Beta | 1/2 | In Progress |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '2. Beta';
|
||||
const result = updateTableCell(shortRow, byPhase, 'Completed', ' 2026-01-01 ');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /no matching row/);
|
||||
});
|
||||
|
||||
// BOUNDARY coverage (limit-1 / limit / limit+1): a data row's cell count
|
||||
// relative to the 4-column header must never gate the write — only whether
|
||||
// the TARGET column is physically present in the matched row does.
|
||||
test('boundary: limit-1 (3-cell row, one short of 4 columns) still updates a present column', () => {
|
||||
const src = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = updateTableCell(src, byPhase, 'Status', ' In Progress ');
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(result.value.includes('| 1. Alpha | 2/2 | In Progress |'));
|
||||
});
|
||||
|
||||
test('boundary: limit (4-cell row, exactly matching 4 columns) updates normally', () => {
|
||||
const src = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = updateTableCell(src, byPhase, 'Completed', ' 2026-02-02 ');
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(result.value.includes('| 1. Alpha | 2/2 | Complete | 2026-02-02 |'));
|
||||
});
|
||||
|
||||
test('boundary: limit+1 (5-cell row, one over 4 columns) still updates a present column, extra cell ignored', () => {
|
||||
const src = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 | extra |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = updateTableCell(src, byPhase, 'Plans Complete', ' 3/3 ');
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(result.value.includes('| 1. Alpha | 3/3 | Complete | 2026-01-01 | extra |'));
|
||||
});
|
||||
|
||||
test('newValue as a no-op-probe function reports ok:true and the current (trimmed) value', () => {
|
||||
// The callback receives the CURRENT value already trimmed/unescaped (same
|
||||
// contract as `match`'s row argument) — returning it verbatim reproduces
|
||||
// the semantic value (re-parses to the same trimmed cell text) but is NOT
|
||||
// required to reproduce the original cell's raw padding, since the
|
||||
// callback never sees that padding. This is the documented, pre-existing
|
||||
// contract (unchanged by the #2245 ragged-tolerance rewrite) — it lets a
|
||||
// caller probe "does a row match, and what's its current value" via a
|
||||
// single updateTableCell call without a separate read-only lookup.
|
||||
const byPhase = (row) => row['Phase'].trim() === '2. Beta';
|
||||
let seenCurrent = null;
|
||||
const result = updateTableCell(fourCol, byPhase, 'Status', (current) => {
|
||||
seenCurrent = current;
|
||||
return current;
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(seenCurrent, 'In Progress');
|
||||
const reparsed = parseMarkdownTable(result.value);
|
||||
assert.equal(reparsed.ok, true);
|
||||
assert.equal(reparsed.value.rows[1]['Status'], 'In Progress', 'the probed value round-trips to the same trimmed cell text');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteTableRow (ADR-2143 §7 row-removal sibling of updateTableCell) ────
|
||||
|
||||
describe('deleteTableRow', () => {
|
||||
const fourCol = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. Beta | 1/2 | In Progress | |',
|
||||
'| 3. Gamma | 0/2 | Planned | |',
|
||||
].join('\n');
|
||||
|
||||
test('deletes only the matched row; header/delimiter/other rows byte-preserved', () => {
|
||||
const byPhase = (row) => row['Phase'].trim() === '2. Beta';
|
||||
const result = deleteTableRow(fourCol, byPhase);
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
const beforeLines = fourCol.split('\n');
|
||||
const afterLines = result.value.split('\n');
|
||||
assert.equal(afterLines.length, beforeLines.length - 1, 'exactly one line removed');
|
||||
assert.equal(afterLines[0], beforeLines[0], 'header must be untouched');
|
||||
assert.equal(afterLines[1], beforeLines[1], 'delimiter row must be untouched');
|
||||
assert.equal(afterLines[2], beforeLines[2], 'row 0 (Alpha) untouched');
|
||||
assert.equal(afterLines[3], beforeLines[4], 'row 2 (Gamma) untouched, now shifted up');
|
||||
assert.ok(!result.value.includes('2. Beta'), 'the matched row is gone');
|
||||
|
||||
const reparsed = parseMarkdownTable(result.value);
|
||||
assert.equal(reparsed.ok, true);
|
||||
assert.equal(reparsed.value.rows.length, 2);
|
||||
});
|
||||
|
||||
test('surrounding prose (before header, after last row) is preserved byte-for-byte', () => {
|
||||
const withProse = [
|
||||
'Some intro prose.',
|
||||
'',
|
||||
fourCol,
|
||||
'',
|
||||
'Some trailing prose.',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = deleteTableRow(withProse, byPhase);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(result.value.startsWith('Some intro prose.\n\n'), 'leading prose preserved');
|
||||
assert.ok(result.value.endsWith('\nSome trailing prose.'), 'trailing prose preserved');
|
||||
assert.ok(!result.value.includes('1. Alpha'), 'matched row gone');
|
||||
assert.ok(result.value.includes('2. Beta'), 'sibling row preserved');
|
||||
assert.ok(result.value.includes('3. Gamma'), 'sibling row preserved');
|
||||
});
|
||||
|
||||
test('a ragged SIBLING row (extra cell) is tolerated and does not block deleting a well-formed row', () => {
|
||||
const raggedSibling = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
'| 2. Beta | 1/2 | In Progress | | extra |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = deleteTableRow(raggedSibling, byPhase);
|
||||
assert.equal(result.ok, true, `expected the ragged sibling not to block the delete, got ${JSON.stringify(result)}`);
|
||||
assert.ok(!result.value.includes('1. Alpha'));
|
||||
assert.ok(result.value.includes('| 2. Beta | 1/2 | In Progress | | extra |'), 'ragged sibling untouched');
|
||||
});
|
||||
|
||||
test('a ragged SIBLING row (missing cell) is tolerated and does not block deleting the target row', () => {
|
||||
const raggedSibling = [
|
||||
'| Phase | Plans Complete | Status | Completed |',
|
||||
'|-------|-----------------|--------|-----------|',
|
||||
'| 2. Beta | 1/2 | In Progress |',
|
||||
'| 1. Alpha | 2/2 | Complete | 2026-01-01 |',
|
||||
].join('\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '1. Alpha';
|
||||
const result = deleteTableRow(raggedSibling, byPhase);
|
||||
assert.equal(result.ok, true, `expected the ragged sibling not to block the delete, got ${JSON.stringify(result)}`);
|
||||
assert.ok(result.value.includes('| 2. Beta | 1/2 | In Progress |'), 'ragged sibling preserved verbatim');
|
||||
assert.ok(!result.value.includes('1. Alpha'));
|
||||
});
|
||||
|
||||
test('no matching row returns {ok:false}', () => {
|
||||
const result = deleteTableRow(fourCol, (row) => row['Phase'] === 'does not exist');
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /no matching row/);
|
||||
});
|
||||
|
||||
test('no table found returns {ok:false}', () => {
|
||||
const result = deleteTableRow('just some prose, no table here', () => true);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /no table found/);
|
||||
});
|
||||
|
||||
test('match receives (row, index) — index is the 0-based DATA-row position', () => {
|
||||
const seenIndices = [];
|
||||
deleteTableRow(fourCol, (row, i) => { seenIndices.push(i); return i === 1; });
|
||||
assert.deepEqual(seenIndices, [0, 1]);
|
||||
});
|
||||
|
||||
test('CRLF line endings are preserved (no mixed EOL introduced), including the deleted row\'s own CRLF', () => {
|
||||
const crlfTable = fourCol.replace(/\n/g, '\r\n');
|
||||
const byPhase = (row) => row['Phase'].trim() === '2. Beta';
|
||||
const result = deleteTableRow(crlfTable, byPhase);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(!/(?<!\r)\n/.test(result.value), 'no bare \\n introduced');
|
||||
assert.ok(result.value.includes('\r\n'));
|
||||
const reparsed = parseMarkdownTable(result.value);
|
||||
assert.equal(reparsed.ok, true);
|
||||
assert.equal(reparsed.value.rows.length, 2);
|
||||
assert.equal(reparsed.value.rows[0]['Phase'], '1. Alpha');
|
||||
assert.equal(reparsed.value.rows[1]['Phase'], '3. Gamma');
|
||||
});
|
||||
|
||||
test('deleting the LAST row (no trailing EOL after it) still works and leaves no dangling newline', () => {
|
||||
const noTrailingEol = fourCol; // fourCol's last line has no trailing \n
|
||||
const byPhase = (row) => row['Phase'].trim() === '3. Gamma';
|
||||
const result = deleteTableRow(noTrailingEol, byPhase);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(result.value.endsWith('In Progress | |'), 'ends right after the new last row, no dangling newline');
|
||||
assert.ok(!result.value.includes('3. Gamma'));
|
||||
});
|
||||
|
||||
test('first-cell-value match works for a COMPACT unpadded row (no spaces around pipes)', () => {
|
||||
const compact = [
|
||||
'|Phase|Plans Complete|Status|Completed|',
|
||||
'|---|---|---|---|',
|
||||
'|1|Foo|0/2|Planned|',
|
||||
'|3|Foo|0/2|Planned|',
|
||||
'|4|Foo|0/2|Planned|',
|
||||
].join('\n');
|
||||
const byFirstCell = (row) => (Object.values(row)[0] ?? '').trim() === '3';
|
||||
const result = deleteTableRow(compact, byFirstCell);
|
||||
assert.equal(result.ok, true, `expected the compact row to match, got ${JSON.stringify(result)}`);
|
||||
assert.ok(!result.value.includes('|3|Foo|0/2|Planned|'), 'the compact matched row is gone');
|
||||
assert.ok(result.value.includes('|1|Foo|0/2|Planned|'), 'sibling row 1 preserved');
|
||||
assert.ok(result.value.includes('|4|Foo|0/2|Planned|'), 'sibling row 4 preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PARITY / DRIFT guard: registry <-> template/workflow source files ───────
|
||||
|
||||
describe('TABLE_SCHEMAS parity: registry headers must appear verbatim in their source templates', () => {
|
||||
@@ -571,12 +921,17 @@ describe('TABLE_SCHEMAS parity: registry headers must appear verbatim in their s
|
||||
* Build the `| a | b | c |` header line for a variant and assert the given
|
||||
* source file contains it verbatim (whitespace around pipes normalized so
|
||||
* template formatting quirks don't cause false failures).
|
||||
*
|
||||
* Delegates header-building and normalization to `buildHeader`/`normalize`
|
||||
* from scripts/lint-table-schema-drift.cjs — the SAME logic the standalone
|
||||
* `lint:table-schema-drift` check runs — rather than re-implementing it
|
||||
* here, so the two can never silently diverge (ADR-2143 §3
|
||||
* Generative-Fix-Divergence guard, applied to the guard itself).
|
||||
*/
|
||||
function assertHeaderInFile(relPath, variant) {
|
||||
const fullPath = path.join(ROOT, relPath);
|
||||
const content = fs.readFileSync(fullPath, 'utf8'); // allow-test-rule: runtime-contract-is-the-product — template/registry parity (#2242)
|
||||
const expectedHeader = `| ${variant.columns.join(' | ')} |`;
|
||||
const normalize = (s) => s.replace(/[ \t]*\|[ \t]*/g, '|').trim();
|
||||
const expectedHeader = buildHeader(variant);
|
||||
const normalizedExpected = normalize(expectedHeader);
|
||||
const found = content
|
||||
.split(/\r?\n/)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -822,6 +822,57 @@ describe('requirements mark-complete command', () => {
|
||||
assert.strictEqual(output.write_set_complete, true);
|
||||
});
|
||||
|
||||
test('#2245 F1: traceability write is NOT fooled by an earlier Out of Scope table', () => {
|
||||
// The shipped requirements template (gsd-core/templates/requirements.md)
|
||||
// puts an `## Out of Scope` table (`| Feature | Reason |`, no Status
|
||||
// column) BEFORE `## Traceability`. updateTableCell binds to the FIRST
|
||||
// GFM table in whatever text it is given — an unscoped whole-file call
|
||||
// targets the Out-of-Scope table instead and silently fails with
|
||||
// `unknown column: Status`, so the real Traceability row is never
|
||||
// flipped even though the checkbox surface flips and the command
|
||||
// reports success (the #2140 silent-divergence class one level deeper).
|
||||
writeRequirements(
|
||||
tmpDir,
|
||||
`# Requirements
|
||||
|
||||
## Coverage
|
||||
- [ ] **REQ-001**: feature one
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Foo | Bar |
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Phase | Status |
|
||||
|--------|-------|--------|
|
||||
| REQ-001 | 1 | Pending |
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runGsdTools('requirements mark-complete REQ-001', tmpDir);
|
||||
assert.ok(result.success);
|
||||
const out = JSON.parse(result.output);
|
||||
|
||||
assert.ok(out.marked_complete.includes('REQ-001'));
|
||||
assert.deepStrictEqual(out.table_unmatched, [],
|
||||
'the Traceability row for REQ-001 DOES exist — it must not be reported as unmatched');
|
||||
assert.deepStrictEqual(out.write_set, [
|
||||
{ requirement: 'REQ-001', surface: 'checkbox', applied: true },
|
||||
{ requirement: 'REQ-001', surface: 'traceability', applied: true },
|
||||
]);
|
||||
assert.strictEqual(out.write_set_complete, true,
|
||||
'both surfaces applied — the write is fully reconciled, not just the checkbox');
|
||||
|
||||
const content = readRequirements(tmpDir);
|
||||
assert.ok(content.includes('- [x] **REQ-001**'), 'checkbox should be checked');
|
||||
assert.ok(content.includes('| REQ-001 | 1 | Complete |'),
|
||||
'the Traceability row (NOT the Out of Scope table) must be flipped to Complete');
|
||||
assert.ok(content.includes('| Foo | Bar |'), 'the Out of Scope table must be left untouched');
|
||||
});
|
||||
|
||||
test('handles mixed prefixes in single call (TEST-XX, REG-XX, INFRA-XX)', () => {
|
||||
writeRequirements(tmpDir, STANDARD_REQUIREMENTS);
|
||||
|
||||
@@ -923,64 +974,191 @@ describe('requirements mark-complete command', () => {
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// milestone.cjs regex global-state fix (structural regression guard)
|
||||
// requirements mark-complete: traceability write (regression, ADR-2143 §7)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('milestone.cjs regex global state fix', () => {
|
||||
// allow-test-rule: structural-regression-guard
|
||||
// milestone.cjs must use replace()+compare, not test()+replace(), to avoid
|
||||
// regex lastIndex corruption with global flags.
|
||||
const MILESTONE_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'milestone.cjs');
|
||||
let src;
|
||||
describe('requirements mark-complete: traceability write (regression, ADR-2143 §7)', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => { src = fs.readFileSync(MILESTONE_SRC, 'utf-8'); });
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('checkbox update uses replace() + compare, not test() + replace()', () => {
|
||||
const funcBody = src.slice(
|
||||
src.indexOf('function cmdRequirementsMarkComplete'),
|
||||
src.indexOf('function cmdMilestoneComplete'),
|
||||
function writeRequirements(tmpDir, content) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), content, 'utf-8');
|
||||
}
|
||||
|
||||
function readRequirements(tmpDir) {
|
||||
return fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
||||
}
|
||||
|
||||
test('multi-ID batch: both rows flip, no regex lastIndex/global-state leak', () => {
|
||||
// Behavioural replacement for the retired structural guard: the old
|
||||
// test()+replace() idiom on a global regex would advance lastIndex on the
|
||||
// first ID's probe and silently miss the second ID's row. Two Pending
|
||||
// rows in one table, both IDs in a single invocation, both must flip.
|
||||
writeRequirements(
|
||||
tmpDir,
|
||||
`# Requirements
|
||||
|
||||
## Coverage
|
||||
- [ ] **REQ-001**: feature one
|
||||
- [ ] **REQ-002**: feature two
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Phase | Status |
|
||||
|--------|-------|--------|
|
||||
| REQ-001 | Phase 1 | Pending |
|
||||
| REQ-002 | Phase 1 | Pending |
|
||||
`,
|
||||
);
|
||||
assert.ok(!funcBody.includes('checkboxPattern.test(reqContent)'));
|
||||
|
||||
const result = runGsdTools('requirements mark-complete REQ-001,REQ-002', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(out.marked_complete.sort(), ['REQ-001', 'REQ-002']);
|
||||
|
||||
const content = readRequirements(tmpDir);
|
||||
assert.ok(
|
||||
funcBody.includes('afterCheckbox !== reqContent') ||
|
||||
funcBody.includes('afterCheckbox!==reqContent'),
|
||||
content.includes('| REQ-001 | Phase 1 | Complete |'),
|
||||
'REQ-001 row should flip to Complete',
|
||||
);
|
||||
});
|
||||
|
||||
test('table update uses replace() + compare, not test() + replace()', () => {
|
||||
const funcBody = src.slice(
|
||||
src.indexOf('function cmdRequirementsMarkComplete'),
|
||||
src.indexOf('function cmdMilestoneComplete'),
|
||||
);
|
||||
assert.ok(!funcBody.includes('tablePattern.test(reqContent)'));
|
||||
assert.ok(
|
||||
funcBody.includes('afterTable !== reqContent') ||
|
||||
funcBody.includes('afterTable!==reqContent'),
|
||||
content.includes('| REQ-002 | Phase 1 | Complete |'),
|
||||
'REQ-002 row should flip to Complete — a global-regex lastIndex leak would miss this row',
|
||||
);
|
||||
});
|
||||
|
||||
test('done-check regexes use non-global flag (only need existence check)', () => {
|
||||
const funcBody = src.slice(
|
||||
src.indexOf('function cmdRequirementsMarkComplete'),
|
||||
src.indexOf('function cmdMilestoneComplete'),
|
||||
for (const header of ['REQ-ID', 'Requirement']) {
|
||||
test(`traceability row matched regardless of ID-column header name ('${header}') (#2769/#2203)`, () => {
|
||||
// Real tables head the requirement-ID column 'REQ-ID'; some head it
|
||||
// 'Requirement'. The row must be matched by its FIRST cell's value,
|
||||
// independent of what that column is named in the header.
|
||||
writeRequirements(
|
||||
tmpDir,
|
||||
`# Requirements
|
||||
|
||||
## Coverage
|
||||
- [ ] **REQ-001**: feature one
|
||||
|
||||
## Traceability
|
||||
|
||||
| ${header} | Phase | Status |
|
||||
|${'-'.repeat(header.length + 2)}|-------|--------|
|
||||
| REQ-001 | Phase 1 | Pending |
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runGsdTools('requirements mark-complete REQ-001', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
out.marked_complete.includes('REQ-001'),
|
||||
`REQ-001 should be marked complete under '${header}' header`,
|
||||
);
|
||||
|
||||
const content = readRequirements(tmpDir);
|
||||
assert.ok(
|
||||
content.includes('| REQ-001 | Phase 1 | Complete |'),
|
||||
`REQ-001 row should flip to Complete under '${header}' header`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('REQ-ID-headed table participates in write_set (#2769/#2203)', () => {
|
||||
// hasTable must recognize a REQ-ID header (not just 'Requirement') so the
|
||||
// traceability surface is tracked in write_set, not silently omitted
|
||||
// despite the row actually flipping.
|
||||
writeRequirements(
|
||||
tmpDir,
|
||||
`# Requirements
|
||||
|
||||
## Coverage
|
||||
- [ ] **REQ-001**: feature one
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Phase | Status |
|
||||
|--------|-------|--------|
|
||||
| REQ-001 | Phase 1 | Pending |
|
||||
`,
|
||||
);
|
||||
const doneCheckboxMatch = funcBody.match(/doneCheckbox\s*=\s*new RegExp\([^)]+,\s*'([^']+)'\)/);
|
||||
const doneTableMatch = funcBody.match(/doneTable\s*=\s*new RegExp\([^)]+,\s*'([^']+)'\)/);
|
||||
assert.ok(doneCheckboxMatch, 'doneCheckbox regex should exist');
|
||||
assert.ok(doneTableMatch, 'doneTable regex should exist');
|
||||
assert.ok(!doneCheckboxMatch[1].includes('g'));
|
||||
assert.ok(!doneTableMatch[1].includes('g'));
|
||||
|
||||
const result = runGsdTools('requirements mark-complete REQ-001', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(out.write_set, [
|
||||
{ requirement: 'REQ-001', surface: 'checkbox', applied: true },
|
||||
{ requirement: 'REQ-001', surface: 'traceability', applied: true },
|
||||
]);
|
||||
assert.strictEqual(out.write_set_complete, true);
|
||||
});
|
||||
|
||||
test('no duplicate regex construction for the same pattern', () => {
|
||||
const funcBody = src.slice(
|
||||
src.indexOf('function cmdRequirementsMarkComplete'),
|
||||
src.indexOf('function cmdMilestoneComplete'),
|
||||
test('REQ-ID-headed table trips #2140 table_unmatched drift check on a missing row', () => {
|
||||
// Checkbox flips, but there is no REQ-001 row in the REQ-ID-headed table.
|
||||
// Before the hasTable fix this was silently treated as "no table
|
||||
// required" (hasTable false for REQ-ID headers) and table_unmatched never
|
||||
// fired — masking the exact #2140 partial-reconcile class for the
|
||||
// real-world table format.
|
||||
writeRequirements(
|
||||
tmpDir,
|
||||
`# Requirements
|
||||
|
||||
## Coverage
|
||||
- [ ] **REQ-001**: feature one (no traceability row)
|
||||
- [ ] **REQ-002**: feature two (has traceability row)
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Phase | Status |
|
||||
|--------|-------|--------|
|
||||
| REQ-002 | Phase 1 | Pending |
|
||||
`,
|
||||
);
|
||||
const tableConstructions = funcBody.split('\n').filter(
|
||||
line => line.includes('tablePattern') && line.includes('new RegExp'),
|
||||
|
||||
const result = runGsdTools('requirements mark-complete REQ-001', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(out.table_unmatched, ['REQ-001']);
|
||||
|
||||
const content = readRequirements(tmpDir);
|
||||
assert.ok(content.includes('- [x] **REQ-001**'), 'REQ-001 checkbox should be checked');
|
||||
assert.ok(!content.includes('REQ-001 | Phase'), 'no REQ-001 row should be invented');
|
||||
});
|
||||
|
||||
test('idempotent: already-Complete row reports already_complete, no double-write/corruption', () => {
|
||||
writeRequirements(
|
||||
tmpDir,
|
||||
`# Requirements
|
||||
|
||||
## Coverage
|
||||
- [x] **REQ-001**: feature one
|
||||
|
||||
## Traceability
|
||||
|
||||
| REQ-ID | Phase | Status |
|
||||
|--------|-------|--------|
|
||||
| REQ-001 | Phase 1 | Complete |
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runGsdTools('requirements mark-complete REQ-001', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(out.already_complete, ['REQ-001']);
|
||||
assert.deepStrictEqual(out.marked_complete, []);
|
||||
|
||||
const content = readRequirements(tmpDir);
|
||||
const rowMatches = content.match(/\|\s*REQ-001\s*\|\s*Phase 1\s*\|\s*Complete\s*\|/g) || [];
|
||||
assert.strictEqual(
|
||||
rowMatches.length, 1,
|
||||
`expected exactly one unchanged REQ-001 row, got ${rowMatches.length} (no double-write/corruption)`,
|
||||
);
|
||||
assert.ok(tableConstructions.length <= 1, `Expected ≤1 tablePattern construction, got ${tableConstructions.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2406,6 +2406,239 @@ Plans:
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('#2245 F3: Progress-ordinal renumber re-escapes an escaped pipe in the Phase cell', () => {
|
||||
// The renumber's `newValue` callback builds its replacement from the
|
||||
// CURRENT (unescaped) cell value and used to splice it back verbatim —
|
||||
// an escaped `\|` in the cell de-escapes to a literal `|` and splits the
|
||||
// cell into an extra column when spliced back unescaped.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [ ] Phase 1: Foo
|
||||
- [ ] Phase 2: Bar
|
||||
- [ ] Phase 3: Parser | Lexer
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|-----------------|--------|-----------|
|
||||
| 1. Foo | 0/1 | Pending | - |
|
||||
| 2. Bar | 0/1 | Pending | - |
|
||||
| 3. Parser \\| Lexer | 0/1 | Pending | - |
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase remove 2 --force', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(
|
||||
roadmap.includes('| 2. Parser \\| Lexer | 0/1 | Pending | - |'),
|
||||
`the escaped pipe must survive the renumber intact:\n${roadmap}`,
|
||||
);
|
||||
// Reject the de-escaped, cell-splitting shape.
|
||||
assert.ok(
|
||||
!/\|\s*2\. Parser \|\s*Lexer\s*\|\s*0\/1\s*\|/.test(roadmap),
|
||||
'the row must not have split into an extra column',
|
||||
);
|
||||
});
|
||||
|
||||
test('#2245 F8: Progress-ordinal renumber padding-recovery is keyed by row index, not trimmed value', () => {
|
||||
// Two rows with identical TRIMMED Phase text but different padding: an
|
||||
// already-rewritten row's NEW value can coincide with a still-unprocessed
|
||||
// row's PRE-edit value, so a content-keyed padding lookup can steal the
|
||||
// wrong (already-rewritten) row's padding.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [ ] Phase 1: Anchor
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|-----------------|--------|-----------|
|
||||
| 4. Foo | 0/1 | Pending | - |
|
||||
| 3. Foo | 0/1 | Pending | - |
|
||||
`,
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase remove 1 --force', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(
|
||||
roadmap.includes('| 3. Foo | 0/1 | Pending | - |'),
|
||||
`row 4->3 must keep its OWN (3-space) padding:\n${roadmap}`,
|
||||
);
|
||||
assert.ok(
|
||||
roadmap.includes('| 2. Foo | 0/1 | Pending | - |'),
|
||||
`row 3->2 must keep its OWN (1-space) padding, not borrow row 4->3's:\n${roadmap}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('#2143 audit: removing the LAST phase preserves a trailing ## Progress section and its table', () => {
|
||||
// Root cause: updateRoadmapAfterPhaseRemoval's whole-section delete used to
|
||||
// be a raw greedy regex whose lookahead only recognised ANOTHER "Phase N:"
|
||||
// heading as a stop boundary. Removing the LAST phase left no such heading
|
||||
// to stop at, so the lazy [\s\S]*? scan ran to EOF and swept away
|
||||
// everything after it — including a trailing `## Progress` heading and its
|
||||
// tracking table (data loss). Fixed by migrating the delete onto
|
||||
// deleteSection (markdown-sectionizer.cjs), whose level-bounded stop halts
|
||||
// at the next heading of the SAME-OR-HIGHER level regardless of its text.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Setup
|
||||
|
||||
### Phase 2: Auth
|
||||
**Goal:** Authentication
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans | Status | Completed |
|
||||
|---|---|---|---|
|
||||
| 1 | 0/1 | Planned | - |
|
||||
| 2 | 0/1 | Planned | - |
|
||||
`,
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase remove 2 --force', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(!roadmap.includes('Phase 2: Auth'), 'removed Phase 2 section must be gone');
|
||||
assert.ok(!roadmap.includes('Authentication'), 'removed Phase 2 body content must be gone');
|
||||
assert.ok(roadmap.includes('### Phase 1: Foundation'), 'Phase 1 (untouched) must survive');
|
||||
assert.ok(
|
||||
roadmap.includes('## Progress'),
|
||||
'trailing ## Progress heading must survive removing the LAST phase — the #2143 audit data-loss bug',
|
||||
);
|
||||
assert.ok(
|
||||
roadmap.includes('| Phase | Plans | Status | Completed |'),
|
||||
'Progress table header must survive',
|
||||
);
|
||||
assert.ok(
|
||||
roadmap.includes('| 1 | 0/1 | Planned | - |'),
|
||||
"Phase 1's own progress row must survive",
|
||||
);
|
||||
});
|
||||
|
||||
test('#2245 audit: COMPACT unpadded Progress row for the removed phase is deleted (deleteTableRow migration)', () => {
|
||||
// Root cause: the prior ROW-DELETION regex was
|
||||
// `\|\s*${escaped}\.?\s[^|]*\|` — the `\.?\s` required a WHITESPACE
|
||||
// character immediately after the phase number. A fully compact, unpadded
|
||||
// row (no spaces around any pipe, e.g. `|2|0/2|Planned|-|`) has the
|
||||
// closing `|` immediately after the digit — no whitespace to match — so
|
||||
// the row was never recognised and the removed phase's stale row was left
|
||||
// behind. Migrated onto deleteTableRow (ADR-2143 §7), which matches the
|
||||
// row by its FIRST cell's value regardless of padding.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Setup
|
||||
|
||||
### Phase 2: Beta
|
||||
**Goal:** Something
|
||||
|
||||
### Phase 3: Features
|
||||
**Goal:** Core features
|
||||
|
||||
## Progress
|
||||
|
||||
|Phase|Plans Complete|Status|Completed|
|
||||
|---|---|---|---|
|
||||
|1|0/1|Planned|-|
|
||||
|2|0/2|Planned|-|
|
||||
|3|0/1|Planned|-|
|
||||
`,
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-beta'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase remove 2', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(
|
||||
!roadmap.includes('|2|0/2|Planned|-|'),
|
||||
'the COMPACT phase-2 progress row must be deleted (it survived pre-fix)',
|
||||
);
|
||||
assert.ok(roadmap.includes('|1|0/1|Planned|-|'), "phase 1's compact progress row must survive");
|
||||
assert.ok(
|
||||
roadmap.includes('|3|0/1|Planned|-|'),
|
||||
"phase 3's compact progress row must survive (its own bare-digit renumbering is a separate, out-of-scope cross-phase-renumber concern)",
|
||||
);
|
||||
});
|
||||
|
||||
test('#2245 audit: a PADDED Progress table deletes exactly the removed row, byte-parity on the rest', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
**Goal:** Setup
|
||||
|
||||
### Phase 2: Beta
|
||||
**Goal:** Something
|
||||
|
||||
### Phase 3: Features
|
||||
**Goal:** Core features
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|-----------------|-------------|------------|
|
||||
| 1. Foundation | 2/2 | Complete | 2026-01-01 |
|
||||
| 2. Beta | 1/2 | In Progress | |
|
||||
| 3. Features | 0/2 | Planned | |
|
||||
`,
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-beta'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase remove 2', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(!roadmap.includes('2. Beta'), 'removed phase 2 progress row must be gone');
|
||||
assert.ok(
|
||||
roadmap.includes('| Phase | Plans Complete | Status | Completed |'),
|
||||
'Progress table header must be byte-identical',
|
||||
);
|
||||
assert.ok(
|
||||
roadmap.includes('|-------|-----------------|-------------|------------|'),
|
||||
'Progress table delimiter row must be byte-identical',
|
||||
);
|
||||
assert.ok(
|
||||
roadmap.includes('| 1. Foundation | 2/2 | Complete | 2026-01-01 |'),
|
||||
"phase 1's row must be byte-identical (untouched)",
|
||||
);
|
||||
// Phase 3 is renumbered to phase 2 (its heading and phases/ directory both
|
||||
// become "2" once phase 2 is removed) — the pre-existing renumber block
|
||||
// below (untouched by this migration) decrements its Progress-table
|
||||
// ordinal too, `3.` -> `2.`, so the surviving row's phase number tracks
|
||||
// its new identity consistently with the heading/directory; every OTHER
|
||||
// byte of the row (padding, Plans/Status/Completed cells) stays identical.
|
||||
assert.ok(
|
||||
!roadmap.includes('| 3. Features | 0/2 | Planned | |'),
|
||||
'the stale phase-3 ordinal must not survive once renumbered to phase 2',
|
||||
);
|
||||
assert.ok(
|
||||
roadmap.includes('| 2. Features | 0/2 | Planned | |'),
|
||||
"surviving row's Plans/Status/Completed cells stay byte-identical; only its leading ordinal renumbers 3->2",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -3034,6 +3267,68 @@ describe('phase complete command', () => {
|
||||
assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending');
|
||||
});
|
||||
|
||||
test('#2245 F1: phase complete traceability write is not fooled by an earlier Out of Scope table', () => {
|
||||
// Same class as the milestone.cts F1 regression: the shipped requirements
|
||||
// template puts an `## Out of Scope` table (`| Feature | Reason |`, no
|
||||
// Status column) BEFORE `## Traceability` — an unscoped updateTableCell
|
||||
// call binds to the FIRST table in the file (Out of Scope) and silently
|
||||
// fails, leaving the Traceability row unflipped while the checkbox still
|
||||
// flips and the command reports requirements_updated:true.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap
|
||||
|
||||
- [ ] Phase 1: Auth
|
||||
|
||||
### Phase 1: Auth
|
||||
**Goal:** User authentication
|
||||
**Requirements:** AUTH-01
|
||||
**Plans:** 1 plans
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
||||
`# Requirements
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
### Authentication
|
||||
|
||||
- [ ] **AUTH-01**: User can sign up with email
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Foo | Bar |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| AUTH-01 | Phase 1 | Pending |
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runVerifiedPhaseComplete('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
||||
assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked');
|
||||
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'),
|
||||
'the Traceability row (not the Out of Scope table) must be flipped to Complete');
|
||||
assert.ok(req.includes('| Foo | Bar |'), 'the Out of Scope table must be left untouched');
|
||||
});
|
||||
|
||||
test('handles requirements with bracket format [REQ-01, REQ-02]', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
|
||||
@@ -987,6 +987,21 @@ describe('ADR-1769 Phase 5: milestoneComplete transition — closure write', ()
|
||||
'stale Operator Next Steps tail must be replaced');
|
||||
});
|
||||
|
||||
test('#2245 F7: CRLF blank line after a reset heading is preserved (byte-parity with LF)', () => {
|
||||
// resetSectionVerbatim's post-heading blank-swallow loop recognised only
|
||||
// a bare `\n` — on a CRLF document, a `\r\n` blank line right after the
|
||||
// heading fell into the DISCARDED span instead of the kept prefix,
|
||||
// silently dropping one blank line relative to the LF-equivalent output.
|
||||
const lfResult = transitionCore(preCloseBody(), intent, deps);
|
||||
const crlfResult = transitionCore(preCloseBody().replace(/\n/g, '\r\n'), intent, deps);
|
||||
|
||||
assert.strictEqual(
|
||||
crlfResult.content.replace(/\r\n/g, '\n'),
|
||||
lfResult.content,
|
||||
'CRLF output, normalized back to LF, must match the LF output byte-for-byte',
|
||||
);
|
||||
});
|
||||
|
||||
test('Operator Next Steps section is inserted when absent', () => {
|
||||
const input = [
|
||||
'# Project State',
|
||||
|
||||
@@ -1212,6 +1212,115 @@ describe('cmdStateRecordMetric (state record-metric)', () => {
|
||||
assert.ok(updated.includes('| Phase 1 P1 | 3min | 2 tasks | 3 files |'), 'existing row should still be present');
|
||||
});
|
||||
|
||||
// #2245 Blocker 2: a RAGGED sibling data row (a hand-edited stray/extra
|
||||
// pipe) in the existing Performance Metrics table used to fail the
|
||||
// whole-table `parseMarkdownTable` gate, which fell through to the
|
||||
// "section absent (or malformed)" scaffold branch and appended a SECOND
|
||||
// "## Performance Metrics" heading — compounding on every per-plan
|
||||
// record-metric call. The append must be ragged-tolerant: it locates the
|
||||
// table's last existing row and splices the new row after it WITHOUT
|
||||
// requiring every sibling row to parse cleanly, and must never introduce a
|
||||
// duplicate heading when a (possibly ragged) table already exists.
|
||||
test('#2245 appends into a RAGGED existing table without duplicating the heading', () => {
|
||||
const raggedFixture = [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Performance Metrics',
|
||||
'',
|
||||
'| Plan | Duration | Tasks | Files |',
|
||||
'|------|----------|-------|-------|',
|
||||
'| Phase 1 P1 | 3min | 2 tasks | 3 files |',
|
||||
'| Phase 1 P2 | 4min | 3 tasks | 5 files | extra |',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
].join('\n') + '\n';
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), raggedFixture);
|
||||
|
||||
const result = runGsdTools('state record-metric --phase 2 --plan 1 --duration 5min --tasks 3 --files 4', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||||
assert.ok(!output.created, 'created must be absent/false — an existing (ragged) section must not be treated as auto-created');
|
||||
|
||||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
const headingMatches = updated.match(/^## Performance Metrics\s*$/gim) || [];
|
||||
assert.strictEqual(headingMatches.length, 1, `exactly ONE "## Performance Metrics" heading expected, got ${headingMatches.length}:\n${updated}`);
|
||||
assert.ok(updated.includes('| Phase 1 P2 | 4min | 3 tasks | 5 files | extra |'), 'existing ragged row must be preserved verbatim');
|
||||
assert.ok(updated.includes('| Phase 1 P1 | 3min | 2 tasks | 3 files |'), 'existing clean row should still be present');
|
||||
assert.ok(updated.includes('| Phase 2 P1 | 5min | 3 tasks | 4 files |'), 'new row should be appended into the existing table');
|
||||
});
|
||||
|
||||
// #2245/#2143: a live STATE.md's "## Performance Metrics" section also
|
||||
// carries the "By Phase" velocity table (gsd-core/templates/state.md:48,
|
||||
// `| Phase | Plans | Total | Avg/Plan |`), which the prior "first table in
|
||||
// the section" targeting polluted with a mismatched per-plan row on EVERY
|
||||
// plan completion (execute-plan.md:414 calls record-metric per-plan). The
|
||||
// command must target ITS OWN `| Plan | Duration | Tasks | Files |` table
|
||||
// specifically, self-creating one when the section exists but doesn't
|
||||
// carry it yet.
|
||||
test('#2245/#2143: record-metric does not pollute the By-Phase velocity table (targets its own metrics table)', () => {
|
||||
const byPhaseFixture = [
|
||||
'# Project State',
|
||||
'',
|
||||
'## Performance Metrics',
|
||||
'',
|
||||
'**Velocity:**',
|
||||
'- Total plans completed: 0',
|
||||
'- Average duration: 0 min',
|
||||
'- Total execution time: 0.0 hours',
|
||||
'',
|
||||
'**By Phase:**',
|
||||
'',
|
||||
'| Phase | Plans | Total | Avg/Plan |',
|
||||
'|-------|-------|-------|----------|',
|
||||
'| - | - | - | - |',
|
||||
'',
|
||||
'**Recent Trend:**',
|
||||
'- Last 5 plans: none',
|
||||
'- Trend: Stable',
|
||||
'',
|
||||
'*Updated after each plan completion*',
|
||||
'',
|
||||
'## Session Continuity',
|
||||
].join('\n') + '\n';
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), byPhaseFixture);
|
||||
|
||||
const result = runGsdTools('state record-metric --phase 1 --plan 1 --duration 5min --tasks 3 --files 4', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.recorded, true, 'recorded should be true');
|
||||
assert.ok(!output.created, 'created must be absent/false — the section already existed');
|
||||
|
||||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
// (a) By-Phase table unchanged — header + placeholder row intact, and no
|
||||
// metric row spliced in between the delimiter and the next blank line.
|
||||
const byPhaseIdx = updated.indexOf('| Phase | Plans | Total | Avg/Plan |');
|
||||
assert.ok(byPhaseIdx !== -1, 'By-Phase table header must still exist');
|
||||
const afterHeader = updated.slice(byPhaseIdx);
|
||||
const byPhaseBlock = afterHeader.slice(0, afterHeader.indexOf('\n\n'));
|
||||
assert.ok(byPhaseBlock.includes('|-------|-------|-------|----------|'), 'By-Phase delimiter must be intact');
|
||||
assert.ok(byPhaseBlock.includes('| - | - | - | - |'), 'By-Phase placeholder row must be intact');
|
||||
assert.ok(!byPhaseBlock.includes('Phase 1 P1'), 'the per-plan row must NOT be spliced into the By-Phase table block');
|
||||
assert.ok(!byPhaseBlock.includes('5min'), 'the per-plan duration must NOT appear in the By-Phase table block');
|
||||
|
||||
// (b) A dedicated `| Plan | Duration | Tasks | Files |` table now exists,
|
||||
// containing the new row.
|
||||
const metricsIdx = updated.indexOf('| Plan | Duration | Tasks | Files |');
|
||||
assert.ok(metricsIdx !== -1, 'a Per-Plan Metrics table must now exist');
|
||||
assert.ok(updated.includes('| Phase 1 P1 | 5min | 3 tasks | 4 files |'), 'the new metric row must be present in the Per-Plan Metrics table');
|
||||
|
||||
// (c) exactly ONE "## Performance Metrics" heading.
|
||||
const headingMatches = updated.match(/^## Performance Metrics\s*$/gim) || [];
|
||||
assert.strictEqual(headingMatches.length, 1, `exactly ONE "## Performance Metrics" heading expected, got ${headingMatches.length}:\n${updated}`);
|
||||
|
||||
// (d) Recent Trend block + footer preserved.
|
||||
assert.ok(updated.includes('**Recent Trend:**'), 'Recent Trend block must be preserved');
|
||||
assert.ok(updated.includes('*Updated after each plan completion*'), 'footer must be preserved');
|
||||
});
|
||||
|
||||
test('replaces None yet placeholder with first metric', () => {
|
||||
const noneYetFixture = [
|
||||
'# Project State',
|
||||
@@ -1807,6 +1916,45 @@ Progress: [..........] 0%
|
||||
'Progress field must be preserved in Current Position');
|
||||
});
|
||||
|
||||
test('#2245 F2: begin-phase does not clobber an H3 subsection nested under Current Position', () => {
|
||||
// A prior revision swapped `locateCurrentPosition` (stops at ANY heading
|
||||
// level >= 2) for `collectSection` with its default `levelBounded: true`
|
||||
// (stops only at H1/H2), so a `### Notes` subsection under
|
||||
// `## Current Position` was folded into the section body and the
|
||||
// field-write regexes (which use the `m` flag and match ANY line start)
|
||||
// clobbered a same-named line inside that subsection.
|
||||
const stateMd = `# Project State
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 1 (Setup) — EXECUTING
|
||||
Status: Executing Phase 1
|
||||
|
||||
### Notes
|
||||
|
||||
Plan: DO-NOT-TOUCH
|
||||
|
||||
## Next Steps
|
||||
|
||||
Do the thing.
|
||||
`;
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||||
|
||||
const result = runGsdTools(
|
||||
['state', 'begin-phase', '--phase', '2', '--name', 'Foo', '--plans', '3'],
|
||||
tmpDir,
|
||||
);
|
||||
assert.ok(result.success, `begin-phase failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('Plan: DO-NOT-TOUCH'),
|
||||
`the ### Notes subsection must not be rewritten by the Current Position field regexes:\n${content}`,
|
||||
);
|
||||
assert.ok(/^## Next Steps/m.test(content), 'the ## Next Steps section must remain untouched');
|
||||
assert.ok(/^Phase:.*EXECUTING/m.test(content), 'Phase line in Current Position should still update');
|
||||
});
|
||||
|
||||
test('advance-plan can update Status after begin-phase', () => {
|
||||
// Simulates the full workflow: begin-phase then advance through all plans
|
||||
const stateMd = `# Project State
|
||||
|
||||
122
tests/table-schema-drift-lint.test.cjs
Normal file
122
tests/table-schema-drift-lint.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Table-schema drift lint (ADR-2143 §3 Generative-Fix-Divergence guard, §7
|
||||
* "prohibition with teeth", epic #2143, Phase 4).
|
||||
*
|
||||
* scripts/lint-table-schema-drift.cjs is the standalone lint (wired into
|
||||
* `lint:ci`) that fails the moment a `TABLE_SCHEMAS` variant's header drifts
|
||||
* from the one template/workflow file that emits it — the durable fix for
|
||||
* #2137 / #2133 / #2119 (reader and writer disagreeing on a table's shape).
|
||||
* These tests exercise its pure `findTableSchemaDrift()` with a synthetic
|
||||
* drifted schema (fail-first) and confirm the live repo passes `scanRepo()`.
|
||||
*
|
||||
* Also locks in the ADR-2143 §7 zero-orphaned-marker invariant: no literal
|
||||
* "pending #1372" / "pending #2143" marker remains anywhere in src/ (Phase 4
|
||||
* asserts zero of either, so a future author cannot silently re-introduce an
|
||||
* un-burned-down deferral marker).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { findTableSchemaDrift, scanRepo, buildHeader } = require(
|
||||
path.join(ROOT, 'scripts', 'lint-table-schema-drift.cjs'),
|
||||
);
|
||||
|
||||
describe('#2143 table-schema drift lint: findTableSchemaDrift (pure)', () => {
|
||||
test('a variant whose header appears verbatim in its source file is not drift', () => {
|
||||
const schemas = { Foo: [{ label: 'default', columns: ['A', 'B'] }] };
|
||||
const readFile = (rel) => (rel === 'foo.md' ? 'intro\n| A | B |\n|---|---|\n' : null);
|
||||
assert.deepEqual(findTableSchemaDrift(schemas, readFile, { Foo: 'foo.md' }), []);
|
||||
});
|
||||
|
||||
test('a synthetic drifted schema (header absent from its source file) IS flagged (fail-first)', () => {
|
||||
// The registry claims a 3-column header but the file only ever emitted 2 —
|
||||
// exactly the writer/reader divergence ADR-2143 §3 targets.
|
||||
const schemas = { Foo: [{ label: 'default', columns: ['A', 'B', 'C'] }] };
|
||||
const readFile = (rel) => (rel === 'foo.md' ? 'intro\n| A | B |\n|---|---|\n' : null);
|
||||
const v = findTableSchemaDrift(schemas, readFile, { Foo: 'foo.md' });
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].schemaId, 'Foo');
|
||||
assert.equal(v[0].label, 'default');
|
||||
assert.equal(v[0].header, '| A | B | C |');
|
||||
assert.equal(v[0].file, 'foo.md');
|
||||
});
|
||||
|
||||
test('whitespace-around-pipes differences are normalized (not false drift)', () => {
|
||||
const schemas = { Foo: [{ label: 'default', columns: ['A', 'B'] }] };
|
||||
const readFile = (rel) => (rel === 'foo.md' ? '|A|B|\n|-|-|\n' : null);
|
||||
assert.deepEqual(findTableSchemaDrift(schemas, readFile, { Foo: 'foo.md' }), []);
|
||||
});
|
||||
|
||||
test('a schema id with no registered source file is flagged', () => {
|
||||
const schemas = { Untracked: [{ label: 'default', columns: ['X'] }] };
|
||||
const v = findTableSchemaDrift(schemas, () => null, {});
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].schemaId, 'Untracked');
|
||||
assert.match(v[0].reason, /no canonical source file registered/);
|
||||
});
|
||||
|
||||
test('a missing/unreadable source file flags every variant of that schema', () => {
|
||||
const schemas = {
|
||||
Foo: [
|
||||
{ label: 'a', columns: ['X'] },
|
||||
{ label: 'b', columns: ['Y'] },
|
||||
],
|
||||
};
|
||||
const v = findTableSchemaDrift(schemas, () => null, { Foo: 'missing.md' });
|
||||
assert.equal(v.length, 2);
|
||||
assert.ok(v.every((d) => d.reason === 'source file not found or unreadable'));
|
||||
});
|
||||
|
||||
test('buildHeader renders the exact `| col | col |` shape', () => {
|
||||
assert.equal(buildHeader({ columns: ['Phase', 'Status'] }), '| Phase | Status |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2143 table-schema drift lint: the live repo is clean', () => {
|
||||
test('scanRepo finds zero drift against the current TABLE_SCHEMAS registry', () => {
|
||||
const violations = scanRepo(ROOT);
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
'TABLE_SCHEMAS variant(s) drifted from their canonical template/workflow:\n'
|
||||
+ violations.map((d) => ` ${d.schemaId}.${d.label} (${d.file}): ${d.reason}`).join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2143 Phase 4: zero orphaned pending-marker literals in src/', () => {
|
||||
function walkCts(dir, acc) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkCts(full, acc);
|
||||
else if (entry.isFile() && entry.name.endsWith('.cts')) acc.push(full);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
test('no "pending #1372" or "pending #2143" literal marker remains in src/', () => {
|
||||
const srcDir = path.join(ROOT, 'src');
|
||||
const offenders = [];
|
||||
for (const file of walkCts(srcDir, [])) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
const rel = path.relative(ROOT, file);
|
||||
text.split(/\r?\n/).forEach((line, idx) => {
|
||||
if (line.includes('pending #1372') || line.includes('pending #2143')) {
|
||||
offenders.push(`${rel}:${idx + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
`orphaned "pending #1372"/"pending #2143" marker(s) found (ADR-2143 §7 requires zero):\n ${offenders.join('\n ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -216,13 +216,16 @@ describe('gsd-windsurf-pre-command.js (pre_run_command guard)', () => {
|
||||
assert.equal(result.status, 0, `expected exit 0, got ${result.status} (stderr: ${result.stderr})`);
|
||||
});
|
||||
|
||||
test('G10: ReDoS-guard — a 200000+-char rm -rf-shaped payload completes in well under 1s via the length cap', () => {
|
||||
test('G10: ReDoS-guard — a 200000+-char rm -rf-shaped payload is handled without catastrophic backtracking (length cap)', () => {
|
||||
const payload = `rm -${'r'.repeat(200000)}!`;
|
||||
const start = Date.now();
|
||||
// The ReDoS guard is `runHook`'s spawnSync `timeout: 10000`: catastrophic
|
||||
// backtracking on this payload would run for minutes, so the hook is
|
||||
// SIGKILL'd and returns a non-zero status — the exit-0 assertion below IS
|
||||
// the ReDoS check. (The prior `elapsedMs < 1000` wall-clock assertion was
|
||||
// redundant with that and flaked on a loaded bench at ~1.1s — CLAUDE.md:
|
||||
// do not assert on wall-clock time.)
|
||||
const result = runHook(PRE_COMMAND_SCRIPT, { tool_info: { command_line: payload } });
|
||||
const elapsedMs = Date.now() - start;
|
||||
assert.equal(result.status, 0, `expected exit 0 (length-capped allow), got ${result.status} (stderr: ${result.stderr})`);
|
||||
assert.ok(elapsedMs < 1000, `expected < 1000ms, took ${elapsedMs}ms`);
|
||||
assert.equal(result.status, 0, `expected exit 0 (length-capped allow, no ReDoS timeout), got ${result.status} (stderr: ${result.stderr})`);
|
||||
});
|
||||
|
||||
test('G7: malformed JSON on stdin -> fail-open exit 0', () => {
|
||||
|
||||
Reference in New Issue
Block a user