* fix(#2365): stop the api-coverage detector false-positiving non-API phases detectApiIntegration fired on any integration verb co-occurring anywhere on a line with any API noun, treated / as a word boundary (so a first-party Next.js src/app/api/... route path matched the noun "api"), and read any capitalized word before API/SDK/REST/GraphQL as a service name behind a fixed stopword denylist (so threat-model prose like "Resolver-only API" fired). Because the verify:pre seal gate is BLOCKING, a phase touching no external API could not reach UAT without fabricating a coverage matrix. The compound rule now requires the verb and noun to share one clause (sentence punctuation and table-cell walls end a clause) within a bounded word gap. Non-prose spans are excluded before matching: fenced code (already), inline code spans (new stripInlineCode in the markdown-sectionizer seam), and path-shaped tokens. The <Service> API surface rule requires proper-noun position — a clause-initial capitalized word is ordinary English and needs dependency evidence (URL / package reference) on the same line — and rejects compound modifiers ("Resolver-only", lowercase after the hyphen). A phase that integrates no external API now has a first-class, reasoned way to say so: a COVERAGE.md containing "No external API integration: <reason>" satisfies the gate (declaration + rows is contradictory and blocks). The true-positive path is pinned by regression tests: every default-vocabulary positive still fires, including the widest word-gap pairing and the surface-rule-only shape. Fixes #2365 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(#2365): tighten api-coverage detector per Codex review (round 2) Applies the Codex review findings on the initial #2365 fix: - S-1: a COVERAGE.md "no external API integration" declaration is the human override for a fallible detector, so it must PASS even when detection still fires — but the contradiction is now SURFACED in the gate output (overridden signal count + terms) instead of passing silently. - S-2: verb/noun pairing is now a term-group nearest-pair merge walk over precomputed word ordinals (computeWordStarts / minWordGap), not a match×match cross product — a hostile line repeating one pair thousands of times stays linear instead of going quadratic. - FN-4: package-shaped inline-code spans (`stripe-sdk`, `@stripe/stripe-js`) are kept as noun/dependency evidence rather than being fully masked, so a genuine dependency reference inside code ticks still corroborates. - C-1: the <Service> API surface rule now scans every candidate in every clause; a rejected first candidate no longer shadows a later genuine service. - Cross-clause binding: a verb may bind a noun in the immediately following clause only when its own clause names a service object, within a tight gap — admits "Integrate Stripe, exposing its endpoints …" without re-admitting the unrelated-clauses false-positive class. - Internal-descriptor negative evidence ("internal Payments API", "the internal endpoint") never pairs; URL/scheme matching generalized beyond http(s). All 5 acceptance criteria still hold: the three reported false positives are clean and "integrate the Stripe API" still fires. Built .cjs committed alongside the .cts. tsc + eslint (incl. no-adhoc-markdown-parsing) + lint:regression-names clean; affected suites 256/256 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): retune api-coverage detector fail-closed per Codex review (round 3) Codex's second-round review found the round-2 tightening had over-corrected into FAIL-OPEN false negatives — realistic external-API prose that the BLOCKING seal gate silently let through (the catastrophic class, since a missed API surface is worse than a dismissable false positive). Retuned the detector to be explicitly fail-closed: lean toward detecting, and let the one-line COVERAGE.md "no external API integration" declaration dismiss the residual false positives. Fail-open false negatives fixed (all now detect): - F1 clause-initial `<Service> API` with a plain follower ("Stripe API for payment processing") — dropped the follower-allowlist / corroboration gate on clause-initial surfaces; a service that is not a stopword, descriptor, or compound modifier is a real name from any clause position. - F2 scheme-less external host ("api.stripe.com/v1") — a dotted host with an alphabetic final label now contributes its API nouns; a first-party route path (no dotted host) still does not. - F3 vendor's first-party SDK ("Integrate Shopify's first-party SDK") — the compound path no longer filters nouns on "internal"/"first-party" (Codex: the qualifier can describe the vendor's own API, not the consuming project's). - F4 long single integration clause — removed the word-gap cap entirely: it could not separate a 21-word genuine clause from an 18-word internal one, so the clause boundary is now the whole relationship test. - F5 lowercase cross-clause service — cross-clause binding no longer requires a capitalized "service object". New false positives fixed (all now clean): - F6 a URL token that swallowed a trailing clause comma, merging two clauses — trailing clause punctuation is kept literal so the split survives. - F7 a capitalized internal component authorizing cross-clause binding — the new gate requires a dependent elaboration, not a new coordinate clause opened by a conjunction ("…, then document…"). - F8 a protocol name read as a service ("REST API", "GraphQL API") — protocol and locality descriptors are rejected in the `<Service>` position. - Finding 9: the inline-code-span scanner was O(n^2) on pathological backtick runs; rewritten to linear via a per-length run cursor (2 MB: 4.15 s -> ~6 ms), semantics preserved (148 sectionizer tests unchanged). Net simplification: the fail-closed model removed the round-2 minWordGap / groupByTerm / follower / corroboration machinery (350 insertions vs 445 deletions across the touched files). Under fail-closed, three round-2 negative tests now correctly detect (integration verb + "internal"-qualified noun, and the distant-same-clause case); none were trek-e acceptance FPs. Verified: 1491/1491 unit tests pass; tsc + eslint (incl. no-adhoc-markdown- parsing) + lint:regression-names clean; all 8 review findings reproduced as regression tests, both directions. Built .cjs committed alongside the .cts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): resolve round-3 Codex review findings (fail-closed, round 4) Codex's round-3 adversarial review found the fail-closed retune had introduced new holes in both directions. Resolved: Fail-open false negatives (now detect): - External host addressing a PATH ("graph.microsoft.com/v1.0/me") is itself an integration surface and contributes an endpoint noun even when the host names no vocabulary word. A bare domain link with no path ("https://example.com") stays a non-signal, so "Integrate … from example.com, document …" is still clean. - Locality qualification ("internal", "private") no longer leaks across a sentence or clause boundary: only plain spaces may separate the descriptor from the service, so "The cache is private. Stripe API …" now detects. - Cross-clause binding: the fragile head-word cap (which could not tell a genuine "Connect … to Stripe payments, exposing its endpoints" from an unrelated "Integrate … from URL, document …" — both 4 words after the verb) is replaced by a participial-continuation rule: a verb binds a noun in the next clause only when that clause begins with an "-ing" elaboration. This fixes the 4-word-head false negative AND the false positive below at once. False positives (now clean): - Cross-clause no longer binds a finite continuation regardless of separator: "Wire the settings form. Document endpoint props." / "…; document …" / "…, document …" are separate actions, not elaborations. Perf (quadratic → linear): - The trailing-punctuation peel is a backward char scan instead of an unanchored `[…]+$` regex (16k chars: 156 ms → ~1 ms). - SERVICE_SURFACE_API_RE bounds the service-name length {1,40} so a hostile "A-A-…-x" run cannot drive O(n^2) backtracking (16k: 385 ms → ~3 ms). Consumer fail-open (blocking gate): - readPhaseScope now distinguishes "no plans" from a plan that EXISTS but is unreadable. On a read error the gate BLOCKS ("could not read the phase scope …") instead of silently certifying no-integration from partial scope — an unreadable plan could be the one describing the integration. Documented fail-closed tradeoffs, now pinned with tests so they are not "fixed" back into a fail-open: a clause-initial capitalized common word before "API" ("Payment API", "Search API") reads as a service name; a long clause pairs a verb with a distant noun; and a CommonMark inline code span that wraps a newline is matched within-line only. Codex judged these acceptable because the COVERAGE.md declaration is a cheap override. One documented limitation remains out of scope: "Integrate Stripe, and authenticate requests with its API" (a coordinate finite clause whose noun refers back by pronoun) needs coreference resolution, beyond a lexical detector. Verified: 379/379 affected + command-router tests pass (+14 new regression tests covering every round-3 finding, both directions); tsc + eslint (no-adhoc-markdown-parsing) + lint:regression-names clean. Built .cjs committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): simplify to robust core — remove whack-a-mole heuristics (round 5) Round-4 review confirmed the detector's two most complex features generate findings in both directions no matter how they are tuned, because they need a vendor dictionary + coreference the issue rules out in principle. Per the operator's "ship the robust core" decision, both are removed and their gaps are documented rather than chased further: - Cross-clause binding DELETED (allowsCrossClause / participle rule). It caused a fail-open on finite continuations ("Integrate Stripe; use its OAuth endpoints" — missed) and a false positive on "-ing"-SPELLED nouns ("…, billing endpoint terminology…" — wrongly fired). Detection is now same-clause only. - URL-path-as-evidence REVERTED. Treating every path-bearing URL as an endpoint fired on ordinary asset/link URLs ("…/theme.css", "…?next=/x", a docs/repo link) and recreated routine UI-phase false positives. An external URL is evidence only when it NAMES an API vocabulary word ("api.stripe.com/v1"). Two fail-open cases are now DOCUMENTED limitations, pinned by tests so a future maintainer does not re-add the heuristics that caused the false positives above: a service named only in a clause separate from its API noun, and a bare external host that names no vocabulary word. Both are cheaply covered by the COVERAGE.md declaration and rare in real phase prose ("integrate the X API"). Also fixed from the round-4 review: - Qualification now survives markdown emphasis ("The **internal** Payments API" stays clean) while still not crossing a sentence/clause boundary. - readPhaseScope fail-closes on a REAL read failure (EACCES/EIO) enumerating the phase directory or reading the roadmap fallback — not only per-plan-file failures; a missing directory/section remains a legitimate no-op. The declaration-override path surfaces scope_read_error so an incomplete-scope override stays visible. - SERVICE_SURFACE_API_RE length-bound comment no longer overclaims. Net: the detector is same-clause verb+noun + `<Service> API` surface, with path/code/inline masking and a fail-closed posture. All five acceptance criteria hold. 1573/1573 unit tests pass; tsc + eslint (no-adhoc-markdown-parsing) + lint:regression-names clean. Built .cjs committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#2365): close roadmap-fallback fail-open + stale JSDoc (round-5 review) The round-5 sanity review confirmed the detector simplification is sound (all acceptance positives fire, all required negatives clean) and flagged one real blocker plus a nit: - Blocker: readPhaseScope's roadmap fallback could still silently pass an UNREADABLE roadmap. getRoadmapPhaseWithFallback gated on fs.existsSync(), which returns false on EACCES/EIO too — so an unreadable ROADMAP.md read as "absent", no exception reached isRealReadFailure, and the blocking gate certified empty scope. Fixed at the source: read the roadmap directly and honor the function's OWN documented contract — null only on ENOENT (genuinely absent), otherwise throw. Both existing callers already wrap it in try/catch expecting that throw, and readPhaseScope now fail-closes (blocks) via its roadmap catch. Verified by a new e2e test (unreadable roadmap fallback → block). - Nit: the detectApiIntegration JSDoc still described the removed cross-clause participial binding and "every external hostname counts" — corrected to the actual same-clause-only behavior and the names-a-vocab-word URL rule. Verified: full unit suite green; tsc + eslint + lint:regression-names clean. Built .cjs committed (roadmap.cjs is gitignored/rebuilt, per repo convention). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#2365): backfill changeset PR number (#2397) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#2365): sync generated capability-registry + recapture install goldens CI surfaced two generated-artifact staleness issues (all failing test shards + lint-tests traced to these, not to a logic defect): - gsd-core/bin/lib/capability-registry.cjs was stale: the initial fix edited the ai-integration `api-coverage-plan-pre.md` fragment (added the "No external API integration" declaration section) but did not regenerate the registry, which embeds an inline copy of that fragment. Regenerated via `gen-capability-registry.cjs --write` — the diff is exactly the fragment text sync. Fixes `lint:generated-sync` and the "committed registry is in sync" + "registry integration" tests. - The 18 golden-install-parity fixtures were stale by exactly one hash line each — `gsd-core/references/api-coverage.md`, which this PR edits and which is a hashed installed artifact. Recaptured with `UPDATE_GOLDEN=1`; the diff is that single hash per runtime and nothing else. Fixes the `golden parity — *` tests. No source or behavior change — generated artifacts only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#2365): flip representative-corpus manifest to assert the fixed behavior The #2371 representative corpus (merged into next after this branch was cut) is a known-bug tripwire: it asserts each fixture's currentBuggyOutput so the test fails loudly the moment #2365 is fixed, at which point — per its own contract in representative-corpus.test.cjs — the fixer removes currentBuggyOutput so the assertion checks expectedDetected instead. This is that moment. Removed currentBuggyOutput from the three detector fixtures (nextjs-route-path, unrelated-verb-noun, threat-model-prose); the corpus now asserts detected:false, which the fail-closed same-clause detector satisfies. Notes updated to describe the fix rather than the bug. The #2366 matrix corpus is left untouched — that tripwire belongs to its own PR (#2374). Corpus test: 7/7 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#2365): skip chmod-000 fail-closed e2e tests on Windows The three fail-closed gate tests induce an unreadable plan / directory / roadmap with chmod 000, but Windows does not enforce POSIX mode bits — readFileSync still succeeds, so the gate never reaches the read-error path and the assertion fails on the windows-latest CI leg. The fail-closed LOGIC is platform- independent (readError → block) and is fully exercised on the macOS/Linux legs; only the method of inducing EACCES is POSIX-specific. Guard the three tests to skip on win32 as well as root, mirroring golden-install-parity's win32 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#2365): address trek-e review — glossary, clock-seam, IO injection, bounds Review response to PR #2397 (trek-e, CHANGES_REQUESTED). Fix logic unchanged; this closes the test/process-hygiene findings. Major: - CONTEXT.md "Markdown Sectionizer" glossary now lists the two exports this fix relies on, `stripInlineCode` and `scanInlineCodeSpans` (glossary is a PR gate). - Replaced the banned wall-clock assertion in the "hostile repeated-term line" test (Clock Seams rule — no elapsed-time asserts) with a deterministic signal-count assertion, which also directly verifies the term-dedup that keeps pairing linear (one signal for a 10k-pair line, not thousands). - Rewrote the three fail-closed read-failure tests: instead of chmod 0o000 (a no-op under root / on Windows, the pattern the repo's IO-failure convention avoids) they now exercise the newly-exported `readPhaseScope` in-process and inject the failure by monkeypatching fs.readFileSync/readdirSync to throw, restoring in finally. Deterministic and platform-independent (no skip needed), and they add the ENOENT-is-absence case that the chmod tests couldn't express. Minor: - Added limit / limit+1 boundary tests for SERVICE_SURFACE_API_RE's {1,40} service-name bound, QUALIFIER_LOOKBACK's 24-char window, and REASON_MAX_LEN (200) on the declaration reason. - Added a fast-check property that fuzzes the tokenizer / clause splitter / masking (scanLineTokens, splitClauses, collectTermMatches) with adversarial tokens (slashes, backticks, URLs, clause punctuation) and asserts the detector is total (never throws), shape-stable, holds detected <=> signals, and is deterministic. readPhaseScope is exported for the in-process tests. Verified: 125 detector + 19 gate tests pass; tsc + eslint + generated-sync (glossary/registry) + lint-regression-test-names + lint-test-file-count clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1846 lines
74 KiB
JavaScript
1846 lines
74 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Behavioral tests for markdown-sectionizer.cjs
|
|
*
|
|
* Module: gsd-core/bin/lib/markdown-sectionizer.cjs
|
|
* Exports: stripFencedCode, stripInlineCode, 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,
|
|
stripInlineCode,
|
|
scanInlineCodeSpans,
|
|
} = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
|
|
// ─── stripInlineCode (#2365) ──────────────────────────────────────────────────
|
|
|
|
describe('stripInlineCode', () => {
|
|
test('removes a single-backtick inline span, delimiters included', () => {
|
|
assert.strictEqual(stripInlineCode('Verify the `api` helper'), 'Verify the helper');
|
|
});
|
|
|
|
test('removes a double-backtick span (CommonMark same-length closer)', () => {
|
|
assert.strictEqual(stripInlineCode('see ``a `nested` tick`` here'), 'see here');
|
|
});
|
|
|
|
test('an unmatched backtick run is left as literal text', () => {
|
|
assert.strictEqual(stripInlineCode('a ` stray backtick'), 'a ` stray backtick');
|
|
});
|
|
|
|
test('a longer closing run does not close a shorter opener (CommonMark §6.1)', () => {
|
|
// `x`` — no run of exactly 1 backtick follows, so nothing is stripped.
|
|
assert.strictEqual(stripInlineCode('a `x`` b'), 'a `x`` b');
|
|
});
|
|
|
|
test('multiple spans on one line are all removed', () => {
|
|
assert.strictEqual(stripInlineCode('`a` and `b` remain not'), ' and remain not');
|
|
});
|
|
|
|
test('spans do not cross line boundaries', () => {
|
|
assert.strictEqual(stripInlineCode('open `here\nstill` open'), 'open `here\nstill` open');
|
|
});
|
|
|
|
test('non-string / empty input degrades to empty string without throwing', () => {
|
|
assert.strictEqual(stripInlineCode(undefined), '');
|
|
assert.strictEqual(stripInlineCode(null), '');
|
|
assert.strictEqual(stripInlineCode(''), '');
|
|
});
|
|
|
|
test('text without backticks is returned unchanged', () => {
|
|
const s = 'plain prose, no code spans at all';
|
|
assert.strictEqual(stripInlineCode(s), s);
|
|
});
|
|
});
|
|
|
|
describe('scanInlineCodeSpans', () => {
|
|
test('returns spans covering the delimiters with inner content', () => {
|
|
const spans = scanInlineCodeSpans('use `api` and ``x``');
|
|
assert.deepStrictEqual(spans, [
|
|
{ start: 4, end: 9, content: 'api' },
|
|
{ start: 14, end: 19, content: 'x' },
|
|
]);
|
|
});
|
|
|
|
test('offsets are into the full multi-line string; spans never cross lines', () => {
|
|
const spans = scanInlineCodeSpans('a `x`\nno `span\nhere');
|
|
assert.deepStrictEqual(spans, [{ start: 2, end: 5, content: 'x' }]);
|
|
});
|
|
|
|
test('non-string / empty input yields no spans', () => {
|
|
assert.deepStrictEqual(scanInlineCodeSpans(undefined), []);
|
|
assert.deepStrictEqual(scanInlineCodeSpans(''), []);
|
|
});
|
|
});
|
|
|
|
// ─── 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.
|