Files
msd-core/tests/markdown-sectionizer.test.cjs
Tom Boucher c1885df9e5 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>
2026-07-14 14:25:44 -04:00

1783 lines
72 KiB
JavaScript

'use strict';
/**
* Behavioral tests for markdown-sectionizer.cjs
*
* Module: gsd-core/bin/lib/markdown-sectionizer.cjs
* 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
* - Unicode headings
* - Heading INSIDE a fenced block (must be ignored)
* - Unterminated fence (unterminatedFence === true)
* - Nested heading levels with level-bounded stop
* - All three bullet markers (dash/checkbox/numbered) + indented continuation lines
* - Empty/whitespace/non-string input
*
* Includes fast-check property tests: stripFencedCode idempotence invariant,
* and withSection's ADR-2143 §4 bounded-mutation guarantee (an edit confined to
* one section cannot alter any other section, even with a greedy regex).
* The parity guard for the T0-era tracked duplication (stripFencedCode vs
* uat-predicate _stripFencedBlocks) was removed in T5: uat-predicate now imports
* the seam directly, so the guard would compare the seam to itself.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
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 ──────────────────────────────────────────────────────────
describe('stripFencedCode', () => {
test('returns empty text and no unterminatedFence on empty input', () => {
const r = stripFencedCode('');
assert.equal(r.text, '');
assert.equal(r.unterminatedFence, false);
});
test('non-string input returns empty result', () => {
// Safety: callers may pass non-strings; must not throw
for (const bad of [null, undefined, 42, [], {}]) {
const r = stripFencedCode(bad);
assert.equal(r.text, '');
assert.equal(r.unterminatedFence, false);
}
});
test('content with no fences is returned unchanged', () => {
const src = '## Heading\n\nSome text.\n\n- bullet';
const r = stripFencedCode(src);
assert.equal(r.text, src);
assert.equal(r.unterminatedFence, false);
});
test('removes a backtick fenced block (LF)', () => {
const src = [
'before',
'```js',
'const x = 1;',
'```',
'after',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'before\nafter');
assert.equal(r.unterminatedFence, false);
});
test('removes a tilde fenced block', () => {
const src = [
'before',
'~~~',
'some code',
'~~~',
'after',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'before\nafter');
assert.equal(r.unterminatedFence, false);
});
test('handles CRLF line endings correctly', () => {
const src = 'before\r\n```\r\ncode\r\n```\r\nafter';
const r = stripFencedCode(src);
assert.ok(r.text.includes('before'));
assert.ok(r.text.includes('after'));
assert.ok(!r.text.includes('code'), 'code inside fence should be stripped');
assert.equal(r.unterminatedFence, false);
});
test('unterminatedFence is true when fence is not closed', () => {
const src = 'before\n```\nsome code without closing fence';
const r = stripFencedCode(src);
assert.equal(r.unterminatedFence, true);
assert.ok(!r.text.includes('some code'), 'fence body should be stripped even if unterminated');
});
test('tilde inside backtick fence is treated as content, not a closer', () => {
const src = [
'```',
'~~~',
'still inside',
'```',
'outside',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'outside');
assert.equal(r.unterminatedFence, false);
});
test('backtick inside tilde fence is treated as content, not a closer', () => {
const src = [
'~~~',
'```',
'still inside',
'~~~',
'outside',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'outside');
assert.equal(r.unterminatedFence, false);
});
test('closing fence must be same-char and same-or-longer run', () => {
// A `` ``` `` opener cannot be closed by ```` ```` ``; a 4-backtick closer is valid.
const src = [
'text',
'```',
'body',
'`````', // longer run of same char — valid closer per CommonMark
'after',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'text\nafter');
assert.equal(r.unterminatedFence, false);
});
test('closing fence must have no trailing non-whitespace text', () => {
// ``` js (info string) is only valid on OPENING lines; a line like "``` extra"
// inside a fence is content, not a closer.
const src = [
'```',
'``` still inside (has trailing text)',
'```',
'after',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'after');
assert.equal(r.unterminatedFence, false);
});
test('multiple successive fenced blocks are all stripped', () => {
const src = [
'a',
'```',
'code1',
'```',
'b',
'```',
'code2',
'```',
'c',
].join('\n');
const r = stripFencedCode(src);
assert.equal(r.text, 'a\nb\nc');
assert.equal(r.unterminatedFence, false);
});
});
// ─── 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', () => {
test('returns empty array for empty/non-string input', () => {
assert.deepEqual(tokenizeHeadings(''), []);
assert.deepEqual(tokenizeHeadings(null), []);
assert.deepEqual(tokenizeHeadings(undefined), []);
});
test('extracts ATX headings in document order', () => {
const src = '# H1\n## H2\n### H3\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 3);
assert.equal(tokens[0].level, 1);
assert.equal(tokens[0].text, 'H1');
assert.equal(tokens[1].level, 2);
assert.equal(tokens[1].text, 'H2');
assert.equal(tokens[2].level, 3);
assert.equal(tokens[2].text, 'H3');
});
test('headings inside fenced blocks are ignored', () => {
const src = [
'# Real heading',
'```',
'## Fake heading inside fence',
'```',
'## Another real heading',
].join('\n');
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 2);
assert.equal(tokens[0].text, 'Real heading');
assert.equal(tokens[1].text, 'Another real heading');
});
test('supports Unicode heading text', () => {
const src = '## Résumé — Überblick\n### 日本語見出し\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 2);
assert.equal(tokens[0].text, 'Résumé — Überblick');
assert.equal(tokens[1].text, '日本語見出し');
});
test('records correct 1-based line number', () => {
const src = 'prose\n## Heading\nmore';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1);
assert.equal(tokens[0].line, 2);
});
test('records non-negative byte offset', () => {
const src = 'prose\n## Heading\n';
const tokens = tokenizeHeadings(src);
assert.ok(tokens[0].offset >= 0);
// The offset should point somewhere inside the heading line
assert.ok(tokens[0].offset < src.length);
});
test('handles CRLF headings', () => {
const src = '# H1\r\n## H2\r\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 2);
assert.equal(tokens[0].text, 'H1');
assert.equal(tokens[1].text, 'H2');
});
test('ignores setext-style headings (only ATX supported)', () => {
// Setext (underline) headings are NOT in scope for this seam
const src = 'Title\n=====\n\nSubtitle\n--------\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 0);
});
});
// ─── collectSections ─────────────────────────────────────────────────────────
describe('collectSections', () => {
test('returns empty array for empty/non-string input', () => {
assert.deepEqual(collectSections('', () => true), []);
assert.deepEqual(collectSections(null, () => true), []);
});
test('collects all headings when predicate is always-true', () => {
const src = '## A\nBody A\n## B\nBody B\n';
const sections = collectSections(src, () => true);
assert.equal(sections.length, 2);
assert.equal(sections[0].heading.text, 'A');
assert.ok(sections[0].body.includes('Body A'));
assert.equal(sections[1].heading.text, 'B');
assert.ok(sections[1].body.includes('Body B'));
});
test('collects only headings matching predicate; non-matching headings end section', () => {
// When the predicate matches Section A but not Section B, Section B acts as
// a body line inside Section A (it is not a stop boundary), so its *heading*
// text appears in the body. However Section B's *content* also appears.
// If we want to stop at any heading regardless of the predicate, callers
// should use levelBounded collectSection instead.
//
// To test filtering: use a predicate that matches both headings, then verify
// two sections are returned with the correct split.
const src = '## Section A\nContent A\n## Section B\nContent B\n';
const sections = collectSections(src, () => true);
assert.equal(sections.length, 2);
assert.equal(sections[0].heading.text, 'Section A');
assert.ok(sections[0].body.includes('Content A'));
assert.ok(!sections[0].body.includes('Content B'), 'Content B must not appear in Section A body');
assert.equal(sections[1].heading.text, 'Section B');
assert.ok(sections[1].body.includes('Content B'));
});
test('stopPredicate controls which headings open sections; non-matching headings appear as body text', () => {
// When predicate matches only Section A, Section B is not a stop boundary
// so it (and its content) is included in Section A's body.
const src = '## Section A\nContent A\n## Section B\nContent B\n';
const sections = collectSections(src, (h) => h.text.includes('A'));
assert.equal(sections.length, 1);
assert.equal(sections[0].heading.text, 'Section A');
assert.ok(sections[0].body.includes('Content A'));
// Section B heading line and Content B are inside Section A's body
assert.ok(sections[0].body.includes('Section B'));
assert.ok(sections[0].body.includes('Content B'));
});
test('last section body runs to EOF', () => {
const src = '## Only\nBody line 1\nBody line 2';
const sections = collectSections(src, () => true);
assert.equal(sections.length, 1);
assert.ok(sections[0].body.includes('Body line 1'));
assert.ok(sections[0].body.includes('Body line 2'));
});
test('adjacent headings produce empty bodies', () => {
const src = '## A\n## B\n## C\nContent C\n';
const sections = collectSections(src, () => true);
assert.equal(sections.length, 3);
assert.equal(sections[0].body.trim(), '');
assert.equal(sections[1].body.trim(), '');
assert.ok(sections[2].body.includes('Content C'));
});
});
// ─── collectSection ───────────────────────────────────────────────────────────
describe('collectSection', () => {
test('returns null when no heading matches', () => {
const src = '## Foo\ntext\n';
const result = collectSection(src, (h) => h.text === 'Bar');
assert.equal(result, null);
});
test('returns null for empty/non-string input', () => {
assert.equal(collectSection('', () => true), null);
assert.equal(collectSection(null, () => true), null);
});
test('collects section body up to next same-level heading (levelBounded default)', () => {
const src = [
'## Section A',
'Content A',
'## Section B',
'Content B',
].join('\n');
const result = collectSection(src, (h) => h.text === 'Section A');
assert.ok(result !== null);
assert.ok(result.body.includes('Content A'));
assert.ok(!result.body.includes('Content B'));
});
test('levelBounded: true — sub-headings are included in body, not stops', () => {
const src = [
'## Parent',
'Parent intro',
'### Child',
'Child body',
'## Sibling',
'Sibling body',
].join('\n');
const result = collectSection(src, (h) => h.text === 'Parent', { levelBounded: true });
assert.ok(result !== null);
assert.ok(result.body.includes('Parent intro'));
assert.ok(result.body.includes('Child'), 'child heading line should be in body');
assert.ok(result.body.includes('Child body'));
assert.ok(!result.body.includes('Sibling body'), 'sibling body should NOT be included');
});
test('levelBounded: false — stops at any following heading', () => {
const src = [
'## Parent',
'Parent intro',
'### Child',
'Child body',
'## Sibling',
].join('\n');
const result = collectSection(src, (h) => h.text === 'Parent', { levelBounded: false });
assert.ok(result !== null);
assert.ok(result.body.includes('Parent intro'));
assert.ok(!result.body.includes('Child body'), 'with levelBounded:false, child heading stops section');
});
test('stripFences: true — strips fenced blocks from body', () => {
const src = [
'## Section',
'```',
'code here',
'```',
'prose here',
].join('\n');
const result = collectSection(src, (h) => h.text === 'Section', { stripFences: true });
assert.ok(result !== null);
assert.ok(!result.body.includes('code here'), 'fenced code should be stripped');
assert.ok(result.body.includes('prose here'));
});
test('heading token in result matches the matched heading', () => {
const src = '## My Section\nContent\n';
const result = collectSection(src, (h) => h.text === 'My Section');
assert.ok(result !== null);
assert.equal(result.heading.text, 'My Section');
assert.equal(result.heading.level, 2);
});
test('nested heading level: H3 section stops at next H3 or higher', () => {
const src = [
'### Alpha',
'Alpha body',
'#### Sub-Alpha',
'Sub-Alpha body',
'### Beta',
'Beta body',
].join('\n');
const result = collectSection(src, (h) => h.text === 'Alpha', { levelBounded: true });
assert.ok(result !== null);
assert.ok(result.body.includes('Alpha body'));
assert.ok(result.body.includes('Sub-Alpha'));
assert.ok(!result.body.includes('Beta body'));
});
test('section at EOF has body to end of string', () => {
const src = '## Only\nLast line';
const result = collectSection(src, (h) => h.text === 'Only');
assert.ok(result !== null);
assert.ok(result.body.includes('Last line'));
});
});
// ─── iterateBullets ───────────────────────────────────────────────────────────
describe('iterateBullets', () => {
test('returns empty array for empty/non-string input', () => {
assert.deepEqual(iterateBullets(''), []);
assert.deepEqual(iterateBullets(null), []);
assert.deepEqual(iterateBullets(undefined), []);
assert.deepEqual(iterateBullets(' '), []);
});
test('parses dash bullets', () => {
const src = '- First\n- Second\n';
const items = iterateBullets(src);
assert.equal(items.length, 2);
assert.equal(items[0].marker, 'dash');
assert.equal(items[0].text, 'First');
assert.equal(items[0].checked, null);
assert.equal(items[1].text, 'Second');
});
test('parses asterisk and plus bullets as dash marker', () => {
const src = '* Asterisk\n+ Plus\n';
const items = iterateBullets(src);
assert.equal(items.length, 2);
assert.equal(items[0].marker, 'dash');
assert.equal(items[0].text, 'Asterisk');
assert.equal(items[1].marker, 'dash');
assert.equal(items[1].text, 'Plus');
});
test('parses unchecked checkbox bullets', () => {
const src = '- [ ] Todo item\n';
const items = iterateBullets(src);
assert.equal(items.length, 1);
assert.equal(items[0].marker, 'checkbox-unchecked');
assert.equal(items[0].checked, false);
assert.equal(items[0].text, 'Todo item');
});
test('parses checked checkbox bullets (lowercase x)', () => {
const src = '- [x] Done item\n';
const items = iterateBullets(src);
assert.equal(items.length, 1);
assert.equal(items[0].marker, 'checkbox-checked');
assert.equal(items[0].checked, true);
assert.equal(items[0].text, 'Done item');
});
test('parses checked checkbox bullets (uppercase X)', () => {
const src = '- [X] Done uppercase\n';
const items = iterateBullets(src);
assert.equal(items.length, 1);
assert.equal(items[0].marker, 'checkbox-checked');
assert.equal(items[0].checked, true);
});
test('parses numbered bullets', () => {
const src = '1. First\n2. Second\n42. Forty-two\n';
const items = iterateBullets(src);
assert.equal(items.length, 3);
assert.equal(items[0].marker, 'numbered');
assert.equal(items[0].text, 'First');
assert.equal(items[0].checked, null);
assert.equal(items[2].text, 'Forty-two');
});
test('accumulates indented continuation lines into bullet text', () => {
const src = [
'- Main bullet',
' continuation line',
' another continuation',
'- Next bullet',
].join('\n');
const items = iterateBullets(src);
assert.equal(items.length, 2);
assert.ok(items[0].text.includes('Main bullet'));
assert.ok(items[0].text.includes('continuation line'));
assert.ok(items[0].text.includes('another continuation'));
assert.equal(items[1].text, 'Next bullet');
});
test('blank line terminates current bullet', () => {
const src = '- First\n\n- Second\n';
const items = iterateBullets(src);
assert.equal(items.length, 2);
assert.equal(items[0].text, 'First');
assert.equal(items[1].text, 'Second');
});
test('mixed marker types in sequence', () => {
const src = [
'1. Numbered',
'- [x] Checked',
'- [ ] Unchecked',
'- Plain dash',
].join('\n');
const items = iterateBullets(src);
assert.equal(items.length, 4);
assert.equal(items[0].marker, 'numbered');
assert.equal(items[1].marker, 'checkbox-checked');
assert.equal(items[2].marker, 'checkbox-unchecked');
assert.equal(items[3].marker, 'dash');
});
test('CRLF input is handled correctly', () => {
const src = '- First\r\n- Second\r\n';
const items = iterateBullets(src);
assert.equal(items.length, 2);
assert.equal(items[0].text, 'First');
assert.equal(items[1].text, 'Second');
});
test('indent field captures leading whitespace of bullet opener', () => {
const src = ' - Indented bullet\n';
const items = iterateBullets(src);
assert.equal(items.length, 1);
assert.equal(items[0].indent, ' ');
});
test('non-bullet lines before any bullet are ignored', () => {
const src = 'Some prose\n\n- Bullet\n';
const items = iterateBullets(src);
assert.equal(items.length, 1);
assert.equal(items[0].text, 'Bullet');
});
});
// ─── 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', () => {
test('collectSection ignores headings inside fenced blocks', () => {
const src = [
'## Real',
'Real body',
'```',
'## Fake inside fence',
'```',
'More real body',
].join('\n');
const result = collectSection(src, (h) => h.text === 'Real');
assert.ok(result !== null, 'should find the real heading');
assert.ok(result.body.includes('More real body'), 'real body after fence should be included');
// The section should not have ended at the fake heading
});
test('tokenizeHeadings ignores headings in CRLF fenced blocks', () => {
const src = '# Outer\r\n```\r\n# Inner\r\n```\r\n## After\r\n';
const tokens = tokenizeHeadings(src);
const texts = tokens.map((t) => t.text);
assert.ok(texts.includes('Outer'));
assert.ok(texts.includes('After'));
assert.ok(!texts.includes('Inner'), 'heading inside fence should be invisible');
});
});
// ─── Property test: stripFencedCode idempotence ───────────────────────────────
describe('stripFencedCode: property-based tests', () => {
test('property: never throws on any string input', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string({ maxLength: 500 }),
fc.string({ unit: 'binary', maxLength: 200 }),
fc.string({ unit: 'grapheme-composite', maxLength: 200 }),
fc.constant(''),
fc.constant('```\ncode\n```\n'),
fc.constant('~~~\nunterminated'),
),
(input) => {
assert.doesNotThrow(
() => stripFencedCode(input),
`stripFencedCode threw on: ${JSON.stringify(input.slice(0, 80))}`,
);
},
),
);
});
test('property: always returns { text: string, unterminatedFence: boolean }', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 500 }),
(input) => {
const result = stripFencedCode(input);
assert.ok(typeof result === 'object' && result !== null, 'result must be object');
assert.ok(typeof result.text === 'string', 'text must be string');
assert.ok(typeof result.unterminatedFence === 'boolean', 'unterminatedFence must be boolean');
},
),
);
});
test('property: idempotence — stripping twice gives the same text as stripping once', () => {
// A well-formed (terminated) fence: strip once gives fence-free text with no
// remaining fences. Stripping again gives the same text.
// For unterminated fences the text after first strip has no fence content but
// the result is still idempotent — stripping a fence-free string is a no-op.
fc.assert(
fc.property(
fc.string({ maxLength: 500 }),
(input) => {
const once = stripFencedCode(input);
const twice = stripFencedCode(once.text);
assert.equal(
twice.text,
once.text,
`Idempotence violated: input=${JSON.stringify(input.slice(0, 60))}`,
);
// After the first strip the text has no fences (or only unterminated remnants),
// so the second pass must not report unterminated unless the first pass already did.
// (The second pass cannot have MORE unterminatedFence; it can have less.)
assert.ok(
!twice.unterminatedFence || once.unterminatedFence,
'Second pass may not introduce a new unterminatedFence not present in first pass',
);
},
),
);
});
test('property: output text is always a substring or equal-length string of input', () => {
// Stripping removes content, so output length <= input length
fc.assert(
fc.property(
fc.string({ maxLength: 500 }),
(input) => {
const result = stripFencedCode(input);
assert.ok(
result.text.length <= input.length,
`Output (${result.text.length}) must not be longer than input (${input.length})`,
);
},
),
);
});
});
// ─── extractTaggedBlocks ──────────────────────────────────────────────────────
describe('extractTaggedBlocks', () => {
test('returns empty array for empty/non-string content', () => {
assert.deepEqual(extractTaggedBlocks('', 'decisions'), []);
assert.deepEqual(extractTaggedBlocks(null, 'decisions'), []);
assert.deepEqual(extractTaggedBlocks(undefined, 'decisions'), []);
});
test('returns empty array when tag is empty/non-string', () => {
assert.deepEqual(extractTaggedBlocks('<decisions>body</decisions>', ''), []);
assert.deepEqual(extractTaggedBlocks('<decisions>body</decisions>', null), []);
});
test('returns empty array when tag is not present', () => {
const content = 'Some prose without any matching block.\n## Heading\n- bullet';
assert.deepEqual(extractTaggedBlocks(content, 'decisions'), []);
});
test('extracts inner text of a single block', () => {
const content = 'before\n<decisions>\nD-01: Foo\n</decisions>\nafter';
const result = extractTaggedBlocks(content, 'decisions');
assert.equal(result.length, 1);
assert.ok(result[0].includes('D-01: Foo'), 'inner text should be returned');
});
test('extracts multiple blocks in document order', () => {
const content = [
'<decisions>',
'D-01: First',
'</decisions>',
'some text',
'<decisions>',
'D-02: Second',
'</decisions>',
].join('\n');
const result = extractTaggedBlocks(content, 'decisions');
assert.equal(result.length, 2);
assert.ok(result[0].includes('D-01: First'));
assert.ok(result[1].includes('D-02: Second'));
});
test('preserves document order of multiple blocks', () => {
const content = '<tag>alpha</tag> middle <tag>beta</tag> end <tag>gamma</tag>';
const result = extractTaggedBlocks(content, 'tag');
assert.deepEqual(result, ['alpha', 'beta', 'gamma']);
});
test('handles CRLF content inside a block', () => {
const content = '<decisions>\r\nD-01: CRLF test\r\n</decisions>';
const result = extractTaggedBlocks(content, 'decisions');
assert.equal(result.length, 1);
assert.ok(result[0].includes('D-01: CRLF test'));
});
test('tag name that needs regex-escaping: dot in tag name is matched literally', () => {
// A tag name with a dot (e.g. 'my.tag') must be matched literally, not as
// a regex wildcard. So '<my.tag>' should match only the exact literal tag.
const content = '<my.tag>inner</my.tag>';
const result = extractTaggedBlocks(content, 'my.tag');
assert.equal(result.length, 1);
assert.equal(result[0], 'inner');
// Crucially, 'myXtag' (dot as wildcard) should NOT match the literal block
const result2 = extractTaggedBlocks('<myXtag>other</myXtag>', 'my.tag');
assert.equal(result2.length, 0, 'dot in tagName must be treated as literal, not wildcard');
});
test('tag name with + character is escaped and matched literally', () => {
const content = '<my+tag>inner</my+tag>';
const result = extractTaggedBlocks(content, 'my+tag');
assert.equal(result.length, 1);
assert.equal(result[0], 'inner');
});
test('content with tag text appearing outside any block is not extracted', () => {
// The tag appears as inline text, not as an XML block
const _content = 'This is about <decisions> but no closing tag in same element sense\n\nNot a block.';
// Actually we need to use content that has the opening tag on the same line as text
// but no matching close tag — result should be empty or the inner text is everything after.
// Since the regex is non-greedy, an unclosed tag won't match.
const content2 = 'Text with <decisions> but tag is unclosed.';
const result = extractTaggedBlocks(content2, 'decisions');
assert.equal(result.length, 0, 'unclosed tag should not produce a match');
});
});
// ─── replaceSection ───────────────────────────────────────────────────────────
describe('replaceSection', () => {
test('replaces section body and preserves heading and surrounding sections', () => {
const content = '## Intro\nIntro body.\n## Name\nOld name body.\n## Footer\nFooter body.\n';
const section = collectSection(content, (h) => h.text === 'Name');
assert.ok(section !== null, 'section must be found');
const newContent = replaceSection(content, section, 'New name body.\n');
assert.ok(newContent.includes('## Intro'), 'Intro heading preserved');
assert.ok(newContent.includes('Intro body.'), 'Intro body preserved');
assert.ok(newContent.includes('## Name'), 'Name heading preserved');
assert.ok(newContent.includes('New name body.'), 'new body present');
assert.ok(!newContent.includes('Old name body.'), 'old body removed');
assert.ok(newContent.includes('## Footer'), 'Footer heading preserved');
assert.ok(newContent.includes('Footer body.'), 'Footer body preserved');
});
test('replaces section body in a multi-section document', () => {
const content = [
'## Alpha',
'Alpha content.',
'## Beta',
'Beta old content.',
'## Gamma',
'Gamma content.',
].join('\n') + '\n';
const section = collectSection(content, (h) => h.text === 'Beta');
assert.ok(section !== null);
const updated = replaceSection(content, section, 'Beta new content.\n');
assert.ok(updated.includes('Alpha content.'), 'Alpha preserved');
assert.ok(updated.includes('Beta new content.'), 'Beta updated');
assert.ok(!updated.includes('Beta old content.'), 'Beta old removed');
assert.ok(updated.includes('Gamma content.'), 'Gamma preserved');
});
test('round-trip: collectSection → replaceSection with section.body → content unchanged', () => {
// INVARIANT: content.slice(bodyStart, bodyEnd) === body
// so replaceSection(content, section, section.body) must equal content exactly.
const content = '## Section A\nLine one.\nLine two.\n## Section B\nB body.\n';
const section = collectSection(content, (h) => h.text === 'Section A');
assert.ok(section !== null);
// Verify the slice invariant directly
assert.equal(
content.slice(section.bodyStart, section.bodyEnd),
section.body,
'content.slice(bodyStart, bodyEnd) must equal section.body (invariant)',
);
// True round-trip: supply section.body (not a re-sliced value)
const roundTripped = replaceSection(content, section, section.body);
assert.equal(roundTripped, content, 'round-trip must produce identical content');
});
test('CRLF content is handled without corruption', () => {
const content = '## Title\r\nOld body.\r\n## Next\r\nNext body.\r\n';
const section = collectSection(content, (h) => h.text === 'Title');
assert.ok(section !== null);
const updated = replaceSection(content, section, 'New body.\r\n');
assert.ok(updated.includes('## Title\r\n'), 'heading with CRLF preserved');
assert.ok(updated.includes('New body.'), 'new body present');
assert.ok(!updated.includes('Old body.'), 'old body removed');
assert.ok(updated.includes('## Next\r\n'), 'next section heading preserved');
assert.ok(updated.includes('Next body.'), 'next section body preserved');
});
test('non-string arguments return content unchanged', () => {
const content = '## Sec\nbody\n';
const section = collectSection(content, (h) => h.text === 'Sec');
assert.ok(section !== null);
assert.equal(replaceSection(null, section, 'x'), null);
assert.equal(replaceSection(content, section, null), content);
});
});
// ─── FIX 1: Section offset invariant tests ────────────────────────────────────
describe('Section offset invariant: content.slice(bodyStart, bodyEnd) === body', () => {
test('invariant holds for a mid-document section (LF, trailing newline)', () => {
const content = '## A\nBody A\n## B\nBody B\n';
const s = collectSection(content, (h) => h.text === 'A');
assert.ok(s !== null);
assert.equal(
content.slice(s.bodyStart, s.bodyEnd),
s.body,
'invariant: content.slice(bodyStart, bodyEnd) === body',
);
assert.equal(
replaceSection(content, s, s.body),
content,
'true round-trip with section.body must be identity',
);
});
test('invariant holds at EOF with no trailing newline', () => {
const content = '## Only\nLast line';
const s = collectSection(content, (h) => h.text === 'Only');
assert.ok(s !== null);
assert.equal(content.slice(s.bodyStart, s.bodyEnd), s.body, 'EOF no-trailing-newline invariant');
assert.equal(replaceSection(content, s, s.body), content, 'round-trip EOF no-trailing-newline');
});
test('invariant holds with CRLF line endings', () => {
const content = '## Title\r\nBody line.\r\n## Next\r\nNext body.\r\n';
const s = collectSection(content, (h) => h.text === 'Title');
assert.ok(s !== null);
assert.equal(content.slice(s.bodyStart, s.bodyEnd), s.body, 'CRLF invariant');
assert.equal(replaceSection(content, s, s.body), content, 'CRLF round-trip');
});
test('invariant holds for an empty body (adjacent headings)', () => {
const content = '## A\n## B\nB body\n';
const s = collectSection(content, (h) => h.text === 'A');
assert.ok(s !== null);
assert.equal(s.body, '', 'empty body expected');
assert.equal(content.slice(s.bodyStart, s.bodyEnd), s.body, 'empty body invariant');
assert.equal(replaceSection(content, s, s.body), content, 'empty body round-trip');
});
test('collectSections: invariant holds for every returned section', () => {
const content = '## Alpha\nAlpha body.\n## Beta\nBeta body.\n## Gamma\nGamma body\n';
const sections = collectSections(content, () => true);
assert.equal(sections.length, 3);
for (const s of sections) {
assert.equal(
content.slice(s.bodyStart, s.bodyEnd),
s.body,
`collectSections invariant for section "${s.heading.text}"`,
);
assert.equal(
replaceSection(content, s, s.body),
content,
`collectSections round-trip for section "${s.heading.text}"`,
);
}
});
});
// ─── FIX 2: tokenizeHeadings CommonMark indented and empty headings ──────────
describe('tokenizeHeadings: CommonMark ≤3-space indent and empty headings', () => {
test('1-space indent is a valid heading', () => {
const src = ' # One space heading\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1);
assert.equal(tokens[0].level, 1);
assert.equal(tokens[0].text, 'One space heading');
});
test('2-space indent is a valid heading', () => {
const src = ' ## Two space heading\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1);
assert.equal(tokens[0].level, 2);
assert.equal(tokens[0].text, 'Two space heading');
});
test('3-space indent is a valid heading', () => {
const src = ' ### Three space heading\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1);
assert.equal(tokens[0].level, 3);
assert.equal(tokens[0].text, 'Three space heading');
});
test('4-space indent is NOT a heading (indented code block per CommonMark)', () => {
const src = ' ## Four space — not a heading\n## Real heading\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1, 'only the non-indented heading should be found');
assert.equal(tokens[0].text, 'Real heading');
});
test('## with no following text is an empty heading (text === "")', () => {
const src = '##\n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1);
assert.equal(tokens[0].level, 2);
assert.equal(tokens[0].text, '');
});
test('## (only whitespace after hashes) is an empty heading (text === "")', () => {
const src = '## \n';
const tokens = tokenizeHeadings(src);
assert.equal(tokens.length, 1);
assert.equal(tokens[0].level, 2);
assert.equal(tokens[0].text, '');
});
});
// ─── FIX 3: collectSection stopAtLevel option ─────────────────────────────────
describe('collectSection: stopAtLevel option', () => {
test('stopAtLevel:3 stops a ##-opened section at the following ###', () => {
const src = [
'## Parent',
'Parent body',
'### Child',
'Child body',
'## Sibling',
'Sibling body',
].join('\n');
const s = collectSection(src, (h) => h.text === 'Parent', { stopAtLevel: 3 });
assert.ok(s !== null);
assert.ok(s.body.includes('Parent body'), 'parent body included');
assert.ok(!s.body.includes('Child body'), 'section should stop at ### with stopAtLevel:3');
assert.ok(!s.body.includes('Sibling body'), 'sibling body not included');
});
test('default levelBounded:true does NOT stop a ##-opened section at ###', () => {
const src = [
'## Parent',
'Parent body',
'### Child',
'Child body',
'## Sibling',
'Sibling body',
].join('\n');
const s = collectSection(src, (h) => h.text === 'Parent', { levelBounded: true });
assert.ok(s !== null);
assert.ok(s.body.includes('Child body'), 'child body is inside the ## section with levelBounded');
assert.ok(!s.body.includes('Sibling body'), 'sibling body not included');
});
test('stopAtLevel:2 stops at the next ## (same as levelBounded default for ## opener)', () => {
const src = '## A\nA body\n## B\nB body\n';
const s = collectSection(src, (h) => h.text === 'A', { stopAtLevel: 2 });
assert.ok(s !== null);
assert.ok(s.body.includes('A body'));
assert.ok(!s.body.includes('B body'));
});
test('stopAtLevel round-trip invariant holds', () => {
const src = '## Parent\nParent body\n### Child\nChild body\n## Sibling\nSibling body\n';
const s = collectSection(src, (h) => h.text === 'Parent', { stopAtLevel: 3 });
assert.ok(s !== null);
assert.equal(src.slice(s.bodyStart, s.bodyEnd), s.body, 'offset invariant with stopAtLevel');
assert.equal(replaceSection(src, s, s.body), src, 'round-trip with stopAtLevel');
});
});
// ─── FIX 4: backtick fence — info string with backtick is not a fence opener ─
describe('stripFencedCode and tokenizeHeadings: backtick info string with backtick', () => {
test('stripFencedCode: backtick in info string does not open a backtick fence', () => {
// The line "``` ` info" has a backtick in the info string → NOT a fence opener.
const src = '``` ` not-a-fence\n## Heading\n';
const r = stripFencedCode(src);
// Both lines should be kept (no fence was opened)
assert.ok(r.text.includes('## Heading'), 'heading line must be kept since no fence opened');
assert.ok(r.text.includes('``` ` not-a-fence'), 'the non-fence line must be kept');
assert.equal(r.unterminatedFence, false, 'no fence was opened, so unterminated must be false');
});
test('tokenizeHeadings: heading after a backtick-in-info line is still tokenized', () => {
// ``` ` info-with-backtick is NOT a fence opener, so ## Heading below it is visible.
const src = '``` ` not-a-fence\n## Heading\nprose\n```\n';
const tokens = tokenizeHeadings(src);
assert.ok(tokens.some((t) => t.text === 'Heading'), '## Heading must be tokenized when "opener" has backtick in info');
});
test('tilde fence info string WITH backtick IS still a valid fence opener (tildes unaffected)', () => {
// Only backtick fences have the "no backtick in info" restriction.
const src = '~~~ ` this-is-fine\n## Inside tilde fence\n~~~\n## Outside\n';
const tokens = tokenizeHeadings(src);
// ## Inside tilde fence should be ignored (inside a real fence)
assert.ok(!tokens.some((t) => t.text === 'Inside tilde fence'), 'tilde fence with backtick in info is still a valid fence');
assert.ok(tokens.some((t) => t.text === 'Outside'), 'heading after tilde fence close is tokenized');
});
});
// ─── FIX 6: extractTaggedBlocks — nested tag behavior ─────────────────────────
describe('extractTaggedBlocks: nested same-name tag behavior (#2128 stop-at-next-open)', () => {
test('nested <x><x>inner</x></x> extracts the well-formed inner block', () => {
// #2128: the ReDoS-safe body scan terminates at the NEXT opening <x>, so the
// unterminated outer <x> is skipped and the inner block is extracted.
const content = '<x><x>inner</x></x>';
const result = extractTaggedBlocks(content, 'x');
assert.equal(result.length, 1, 'exactly one result from nested input');
assert.equal(result[0], 'inner', 'the well-formed inner block is extracted; the unterminated outer is skipped');
});
test('back-to-back blocks (not nested) are both extracted', () => {
const content = '<x>first</x><x>second</x>';
const result = extractTaggedBlocks(content, 'x');
assert.equal(result.length, 2);
assert.equal(result[0], 'first');
assert.equal(result[1], 'second');
});
test('#2128: a document full of unclosed <x> openings stays linear and yields no match', () => {
const content = '<x>a\n'.repeat(50) + 'no closing tag';
assert.deepEqual(extractTaggedBlocks(content, 'x'), [], 'no </x> anywhere -> no blocks');
});
test('#557 / #2128: attr-intolerant by default preserves <details open>; opt-in matches <task type=…>', () => {
// stripTaggedBlocks(details) must PRESERVE <details open> (the active-milestone
// marker) and strip only bare <details>; extractTaggedBlocks(task, true) must
// match attributed tasks, and must NOT when allowAttributes is left false.
assert.equal(
stripTaggedBlocks('X<details>shipped</details>Y<details open>active</details>Z', 'details'),
'XY<details open>active</details>Z',
'#557: <details open> preserved; bare <details> stripped',
);
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task', true), ['body'], 'attributed task matched with allowAttributes=true');
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task'), [], 'attributed task NOT matched with allowAttributes=false');
});
});
// ─── withSection ───────────────────────────────────────────────────────────────
describe('withSection', () => {
test('edits only the targeted section, leaving surrounding sections untouched', () => {
const content = '## Intro\nIntro body.\n## Name\nOld name body.\n## Footer\nFooter body.\n';
const result = withSection(content, 'Name', (body) => body.replace('Old name body.', 'New name body.'));
assert.ok(result.includes('## Intro\nIntro body.'), 'Intro section preserved verbatim');
assert.ok(result.includes('## Name\nNew name body.'), 'Name section updated');
assert.ok(!result.includes('Old name body.'), 'old body removed');
assert.ok(result.includes('## Footer\nFooter body.'), 'Footer section preserved verbatim');
});
test('a section not present in content leaves content unchanged (bounded no-op)', () => {
const content = '## A\nBody A\n## B\nBody B\n';
const result = withSection(content, 'Nonexistent', (body) => body + ' MUTATED');
assert.equal(result, content, 'no matching heading -> content returned unchanged');
});
test('predicate form: target may be a HeadingToken predicate function', () => {
const content = '## Alpha\nAlpha body\n## Beta\nBeta body\n';
const result = withSection(content, (h) => h.level === 2 && h.text === 'Beta', (body) => body.toUpperCase());
assert.ok(result.includes('## Beta\nBETA BODY'), 'predicate-matched section edited');
assert.ok(result.includes('## Alpha\nAlpha body'), 'non-matched section untouched');
});
test('edit returning the identical body is a no-op (no splice performed)', () => {
const content = '## A\nBody A\n## B\nBody B\n';
const result = withSection(content, 'A', (body) => body);
assert.equal(result, content, 'identical body -> no-op');
});
test('edit returning a non-string is a no-op (defensive)', () => {
const content = '## A\nBody A\n## B\nBody B\n';
// Deliberately malformed edit callback (returns a number, not a string).
const result = withSection(content, 'A', () => 42);
assert.equal(result, content, 'non-string return -> no-op');
});
test('non-string content is returned unchanged', () => {
assert.equal(withSection(null, 'A', (b) => b + 'x'), null);
assert.equal(withSection(undefined, 'A', (b) => b + 'x'), undefined);
});
test('ADR-2143 §4: a greedy regex inside the edit callback cannot cross a section boundary', () => {
// Build a 3-section document; run a maximally-greedy regex (`[\s\S]*`) inside
// the edit callback for section 2. The callback only ever sees section 2's
// body, so sections 1 and 3 must remain byte-identical.
const content = [
'## Section One',
'alpha content line 1',
'alpha content line 2',
'## Section Two',
'beta content to be replaced',
'## Section Three',
'gamma content line 1',
'gamma content line 2',
].join('\n') + '\n';
const before = collectSection(content, (h) => h.text === 'Section One');
const afterSectionThree = collectSection(content, (h) => h.text === 'Section Three');
assert.ok(before !== null && afterSectionThree !== null);
const result = withSection(content, 'Section Two', (body) => body.replace(/[\s\S]*/, 'REPLACED ENTIRELY'));
const resultOne = collectSection(result, (h) => h.text === 'Section One');
const resultThree = collectSection(result, (h) => h.text === 'Section Three');
assert.equal(resultOne.body, before.body, 'Section One byte-identical after greedy edit on Section Two');
assert.equal(resultThree.body, afterSectionThree.body, 'Section Three byte-identical after greedy edit on Section Two');
assert.ok(result.includes('## Section Two\nREPLACED ENTIRELY'), 'Section Two was replaced as intended');
});
});
describe('withSection: property-based tests', () => {
// Anchored phase-heading predicate mirroring roadmap-parser.cjs's
// withPhaseSection fix: the phase token must sit at the START of the
// heading text, so a section whose TITLE merely mentions another phase
// number is never matched by that other phase's query.
const anchoredPhasePredicate = (k) => {
const re = new RegExp(`^Phase\\s+${k}(?=[\\s:(]|$)`, 'i');
return (h) => re.test(h.text);
};
test('property (ADR-2143 §4): editing phase k never alters any sibling section j≠k', () => {
// Model a ROADMAP-like document with N `## Phase k` sections (k=1..N), each
// with a distinct, generated body. Pick a random k, run withSection to append
// ' EDITED' to that section's body, and assert every OTHER section (j≠k) is
// byte-identical in the output — the bounded-mutation guarantee this seam
// exists to provide (structurally retires the #2130/#2067/#2080 boundary-
// crossing class).
//
// Also exercises Blocker 1 (title-collision hijack): each section's heading
// TITLE may be decorated with a reference to a DIFFERENT phase number
// (e.g. `Phase 3: legacy Phase 1 notes`), and the predicate above (anchored
// to the start of the heading text) must still resolve to the section whose
// OWN number matches, never a differently-numbered section whose title
// happens to mention the queried number.
//
// Heading level is kept UNIFORM (`##`) across sections deliberately: mixing
// random heading levels would make "which section is k" ambiguous for this
// property (a shallower section can syntactically nest a deeper one) — see
// the separate explicit mixed-level test below instead.
fc.assert(
fc.property(
fc.integer({ min: 2, max: 6 }).chain((n) =>
fc.record({
n: fc.constant(n),
bodies: fc.array(
fc.string({ minLength: 1, maxLength: 40 }).filter((s) => !/[\r\n]/.test(s) && s.trim().length > 0),
{ minLength: n, maxLength: n },
),
targetIdx: fc.integer({ min: 0, max: n - 1 }),
// For each section, optionally reference a DIFFERENT phase number in
// its own heading title (title-collision decoy). `undefined`/self
// means "no decoy for this section".
titleDecoys: fc.array(
fc.option(fc.integer({ min: 1, max: 6 }), { nil: undefined }),
{ minLength: n, maxLength: n },
),
}),
),
({ n, bodies, targetIdx, titleDecoys }) => {
const lines = [];
for (let k = 1; k <= n; k++) {
const decoy = titleDecoys[k - 1];
const heading = decoy !== undefined && decoy !== k
? `Phase ${k}: legacy Phase ${decoy} notes`
: `Phase ${k}`;
lines.push(`## ${heading}`);
lines.push(`body-${k}: ${bodies[k - 1]}`);
}
const doc = lines.join('\n') + '\n';
const targetPhase = targetIdx + 1;
// Snapshot every section's body BEFORE the edit.
const before = [];
for (let k = 1; k <= n; k++) {
const s = collectSection(doc, anchoredPhasePredicate(k));
assert.ok(s !== null, `Phase ${k} section must be found before edit`);
before.push(s.body);
}
const result = withSection(
doc,
anchoredPhasePredicate(targetPhase),
(body) => body + ' EDITED',
);
for (let k = 1; k <= n; k++) {
const s = collectSection(result, anchoredPhasePredicate(k));
assert.ok(s !== null, `Phase ${k} section must still be found after edit`);
if (k === targetPhase) {
assert.equal(s.body, before[k - 1] + ' EDITED', `Phase ${targetPhase} (the target) must be edited`);
} else {
assert.equal(s.body, before[k - 1], `Phase ${k} (j≠k) must be byte-identical after editing Phase ${targetPhase}`);
}
}
},
),
);
});
test('mixed heading levels + title collision: withSection({levelBounded:false}) does not fold a deeper heading into the target section, and the anchored predicate is not hijacked by a title mentioning another phase', () => {
// Phase 3 (appearing FIRST) has a title that mentions "Phase 1" — under the
// OLD unanchored regex this would be matched first (document order) by a
// query for phase 1 (Blocker 1). Phase 1 is followed by a DEEPER heading
// (`#### Phase 2`, level 4 vs Phase 1's level 3) — under the default
// `levelBounded: true` this would nest Phase 2 inside Phase 1's section
// and let an edit on Phase 1 reach into it (Blocker 2).
const content = [
'### Phase 3: Migrate off Phase 1 pipeline',
'gamma body line',
'### Phase 1: Foundation',
'alpha body line',
'#### Phase 2: API (deeper level, nested syntactically under Phase 1)',
'**Plans:** 1 plans',
].join('\n') + '\n';
const before2 = collectSection(content, anchoredPhasePredicate(2));
assert.ok(before2 !== null, 'Phase 2 section must be found before edit');
const result = withSection(
content,
anchoredPhasePredicate(1),
(body) => body + ' EDITED',
{ levelBounded: false },
);
assert.ok(
result.includes('### Phase 3: Migrate off Phase 1 pipeline\ngamma body line'),
'Phase 3 (title mentions "Phase 1") is byte-identical — not hijacked by the anchored Phase-1 query',
);
assert.ok(!result.includes('gamma body line EDITED'), 'the edit did not land in Phase 3');
const after2 = collectSection(result, anchoredPhasePredicate(2));
assert.equal(
after2.body,
before2.body,
'Phase 2 (deeper level than Phase 1) stays byte-identical — levelBounded:false stopped Phase 1 at the next heading of ANY level',
);
assert.ok(result.includes('alpha body line EDITED'), "Phase 1's own body was correctly edited");
});
});
// ─── 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.