Files
msd-core/tests/adr-parser.unit.test.cjs
Tom Boucher 107eb8c1d9 feat(#3753): run docs guards on the PR that changes the docs they read (#3787)
A PR whose diff is entirely under docs/ runs zero tests, so a guard whose INPUT
is shipped prose cannot protect the PR lane of the diffs it exists to check. Its
only firing opportunity is after merge, on the shared branch -- which is how next
went red on dacae9273 while the PR that caused it (#3746) was green on every
check.

The docs-lint job in .github/workflows/docs-required.yml -- an ALREADY-REQUIRED
context -- now selects and runs the docs guards that read the specific docs files
the PR changed.

  scripts/docs-guard-registry.cjs    test file -> the docs paths it reads (63)
  scripts/select-docs-guards.cjs     pure (changedPaths, registry) -> test files
  scripts/lint-docs-guard-registration.cjs   drift guard, wired into lint:ci

scripts/ci-test-scope.cjs is NOT touched -- `git diff origin/next --` on it is
empty -- so #764's saving stands and its 21 pinning tests are untouched.

Selection: exact path; trailing-slash directory prefix (boundary-checked --
docs/adrenaline.md does NOT match docs/adr/, which a naive startsWith gets
wrong); and '*' for the 6 entries that walk docs/ generally or read a computed
path. Unknown maps to '*' -- guessing narrow is how a guard silently stops
running. Measured: a typo fix selects 6 of 63; docs/AGENTS.md selects 12;
docs/COMMANDS.md selects 18.

Four things this got wrong first, each found by an independent reviewer or by
probe, and each having been asserted safe in a comment:

1. The registry started as a RULE in ci-test-scope.cjs's RULES, on the theory
   that classify()'s !codeChanged normalization made it inert. True for
   docs-ONLY diffs; false for MIXED docs+code diffs, where codeChanged is true
   and the normalization never runs:

     node scripts/ci-test-scope.cjs --files "docs/a.md src/semver.cts"
       with the RULE:  25 targeted_tests
       origin/next:     3 targeted_tests

   Category error: RULES is the scoped lane's input; a docs-guard registry is a
   lane manifest for a consumer that never calls classify(). Extracted; pinned
   by value.

2. The second attempt was a dedicated workflow with paths: [docs/**]. Such a
   workflow never reports on a non-docs PR, so it can never be a required
   context without hanging every non-docs PR -- and a non-required check does not
   block a merge, so the guard would have been advisory and #3753 unfixed.
   docs-required.yml already has no paths: filter, already supplies the required
   docs-lint context, already computes docs_changed, and already ran one docs
   guard gated on it. Generalizing that step needs no ruleset edit at all.

3. The registry and the drift lint were built from ONE path-segment heuristic, so
   both were blind identically -- and blind at the guard that motivated the issue.
   The reader-call regex required a character BEFORE its keyword, so a callee
   named exactly read( / load( / parse( / doc( / file( / content( could never
   match; and only an INLINE path.join(ROOT,'docs','X.md') argument was caught,
   missing the two-step-via-variable form -- the MAJORITY spelling -- plus
   template literals and concatenation. Detector 1 fired on 14 of ~450 files, so
   35 genuine guards sat unregistered while the lint reported 0 violations,
   including cursor-reviewer (reads docs/COMMANDS.md, asserts
   .includes('--cursor')) and inventory-headings-countfree. The "accepted blind
   spot" this shipped with was the common case, not a fringe.

4. With detection fixed the true population is 115 files: 63 genuine guards, 52
   incidental. Running all 63 in a REQUIRED check on a one-line typo fix is the
   cost #764 exists to avoid -- install.test.cjs is 7840 lines and reads exactly
   one docs file, docs/AGENTS.md, for its frontmatter. Dropping it reproduces the
   bug; running it for a typo elsewhere is waste. Hence the map.

Then a second review round found six more, all fixed here:

- fragment-single-edit-propagation.install.test.cjs was EXEMPTED as
  "overlay fixture only". False: it reads the real docs/registries/eos.json and
  asserts on a registry entry name, and reads the real ADR-0001 and asserts its
  H1. A docs-only PR touching either would have gone green and red next -- #3753
  shipping again, from inside the fix for it. Now registered against both paths,
  and all 52 remaining exemptions were re-audited one by one.
- The SUITES-collision guard compared RAW registry keys, but run-tests.cjs strips
  a leading `tests/` BEFORE its suite check. So it caught 'all' and missed
  'tests/all' -- the only spelling that can actually occur, since every key
  carries the prefix. One typo would have run all 824 test files inside the
  required job. Now normalized the same way run-tests.cjs normalizes.
- The lint failed OPEN on an unreadable tests dir or candidate file: 0 violations,
  ok:true. A guard that cannot read its input must never report success.
- The exemption ratchet gated identity only, so a baselined file that later
  STARTED asserting on shipped docs stayed exempt silently -- 52 permanently blind
  files. The baseline now fingerprints the docs paths each exempted file
  references and fails when that set changes, naming what changed.
- The exemption marker was still honored inside a multi-line template literal in
  the header window. The scanner now tracks template-literal and block-comment
  state.
- `git diff --name-only | grep '^docs/'` silently dropped C-quoted non-ASCII docs
  paths, making docs_changed=false a green zero-guard check. Both call sites now
  pass -c core.quotepath=false.
- The run step was gated on hashFiles(), which a force-committed
  .docs-guard-tests.txt would satisfy. The step now rm -f's both scratch files
  first and gates on an output it sets itself.

Three empty states, deliberately distinct, because conflating them rebuilds
#3753: an empty or malformed registry HARD-FAILS; docs changed with no guard
covering them logs and skips; no docs change is already gated. The middle state
must never be expressed as an empty --files-from, which prints `no tests in suite
"all"` and exits 0 -- a green check that guarded nothing. With the current
registry that state is unreachable, because the six '*' entries always match;
the branch is kept as defensive handling for a future registry and says so.

timeout-minutes: 15 bounds the required job against a hanging fork-supplied test;
it had none. npm ci was added because the job never installed dependencies -- the
previous single-file step got away without it, the registry does not.

docs/contributing/docs-guard-registration.md documents the rule, following its
sibling cross-platform-portability-rules.md, and CONTRIBUTING.md's CI Test
Quality Checks table links to it. It is also load-bearing: without a docs/ file
in the diff this PR would not have triggered its own lane, shipping an
unexercised change to a required check.

One unrelated fix, included because this PR surfaced it and CLAUDE.md forbids
deferring a defect found while working. On this branch's first CI run,
`full test (windows-latest, 24, shard 3/3)` was CANCELLED at exactly 30 minutes;
tests were still passing 0.8s before the cancel, so it is a wall-clock timeout,
not a hang, and a cancelled job reddens `Required tests`.

The cause is not this PR's test file, which costs ~60ms. Shard composition is
unstable: adding ONE file to the unit suite reshuffled 115 of 268 files between
shards, and shard 3 drew a heavier mix. Underneath that is a real pre-existing
defect. tests/ci-test-job-timeout-budget.test.cjs requires every lane's budget to
be >= 1.5x its MEASURED cost -- "a lane that got slower must be re-budgeted, not
excused" -- and its test-full entry recorded 19m from a windows-22 shard. That is
stale. Measured on `next` with none of this PR's changes present: 26m18s (run
32614439702, windows-latest/24 shard 3/3), 23m36s and 23m17s on shard 2/3. So the
lane costs ~26m and the 30-minute cap carried 1.14x headroom, not 1.5x. The gate
had been out of compliance with its own rule; this PR was merely the file
addition that reshuffled shard 3 past the cliff.

Fixed as that file prescribes: measuredMinutes 19 -> 27 with fresh evidence, and
test-full timeout-minutes 30 -> 45. The rule's minimum for 27m is 41; 45 is
deliberately above it because the reshuffle means per-shard worst case moves run
to run, and a budget pinned to the exact minimum would be re-breached by the next
test file anyone adds. Only that one job's timeout changed; test.yml's scope,
matrix and steps are untouched, so #764's saving is unaffected.

Raising that cap let the Windows shard finish (28m45s, inside 45) and uncovered
a real failure the 30-minute cancel had been masking:
`new quick-task branch branches off origin/main (#2916)` died with
`outcome=timed_out exitCode=null`, SIGTERM, at the 15000ms bound.

tests/quick-branching.test.cjs:149 `runStep` runs a `#!/usr/bin/env bash` script
executing MULTIPLE git commands, but was bound to GIT_TIMEOUT_MS (15000) -- the
norm for a SINGLE git plumbing call. tests/helpers/timeouts.cjs already documents
this exact failure and exists to fix it: HOOK_FANOUT_TIMEOUT_MS was created after
PR #3285 recorded "outcome=timed_out exitCode=null at exactly the 15000ms probe
bound while every other lane passed the same commit", and calls that "a bound
sized for the wrong class, not a slow machine". Our failure is that case
verbatim, so both sites move to the class norm rather than to a bigger number.

The same class also failed on `next` itself 21 hours earlier -- run 32608945654,
windows-latest/24 shard 1/3, `plan touching only src/ in a submodule project
keeps worktree isolation ENABLED` -- where tests/worktree-safety.test.cjs:5845
`runGate` fans out to `git config --file .gitmodules` under a hardcoded 30000.
Fixed too, since it is a defect in the tree regardless of which branch surfaced
it.

A survey of the whole tests/ tree found the same class-mismatch at further
bash fan-out sites bound under 60000ms, and the maintainer approved sweeping
them rather than leaving them latent to surface the same way one at a time. 16
fan-out sites across 16 files now use the class norm.

The sweep is class-correctness, not raising numbers until things pass. Sites
were moved ONLY where the bash body demonstrably spawns something (git, node,
npm, a CLI); self-contained shell snippets were left where they are, and are
listed as deliberately unchanged: pure if/printf bodies (copilot-install), pure
array/case builtins (code-review-pipeline-regression:638), a documented
pure-shell gsd_run stub (host-integration), single-process hook calls
(workflow-guard:222/271/302), and a deliberately tight 5000ms fast-check hook
(gsd-write-guard.property). Nothing was lowered. process-seam.test.cjs:513
(literal 300) is untouched on purpose -- it tests timeout BEHAVIOR, so raising
it would destroy what it asserts.

Shared file-level constants were the trap here, and were handled per file rather
than by redefinition: GIT_TIMEOUT_MS has ~15 users in git-base-branch and only 1
is a fan-out; WORKTREE_TIMEOUT_MS has 16 users in worktree.test.cjs and 3 are;
PROBE_TIMEOUT_MS has several in three more files. In each the CALL SITE was
changed and the constant left alone, so no single-plumbing-call site silently
inherited a 60s bound. The one exception is hooks-opt-in.test.cjs, where
HOOK_TIMEOUT_MS has exactly one consumer -- spawnHook, the fan-out itself -- so
redefining it is identical in effect and reads better.

Only two of these sites have actually been observed failing. The rest cite that
shared class and those two run ids rather than inventing evidence of their own.

Co-authored-by: sim <sim@local>
2026-08-23 21:21:21 -04:00

1583 lines
73 KiB
JavaScript

// docs-guard-exempt: docs/adr/NNNN.md below is a sourcePath metadata label passed to parseAdrMarkdown, never read from disk.
'use strict';
/**
* Example-based unit tests for adr-parser.cjs
*
* Target: gsd-core/bin/lib/adr-parser.cjs
* Purpose: kill surviving mutants by asserting EXACT values from every branch
*
* Gap coverage:
* - normalizeAdrHeader: each transformation step
* - classifyHeader (via parseAdrMarkdown): every CANONICAL_HEADERS key,
* prefix-match branch, unknown → unmapped_headers
* - parseSections: heading levels 1-6, CRLF, empty markdown, body-only,
* empty-heading guard, last section flushed
* - parseStatusFromSections: each keyword, empty body, custom passthrough
* - parseAdrMarkdown: title from H1, no-H1 title, format/sourcePath defaults,
* status fallback 'accepted', goal context-once guard, pushUnique dedup,
* all canonical section types
* - parseConsequences: every hint word, fallback positive
* - shouldRejectAdrStatus: uppercase/mixed-case normalisation path
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const {
normalizeAdrHeader,
parseAdrMarkdown,
shouldRejectAdrStatus,
CANONICAL_HEADERS,
} = require('../gsd-core/bin/lib/adr-parser.cjs');
// ─────────────────────────────────────────────────────────────────────────────
// normalizeAdrHeader — exact transformation chain
// ─────────────────────────────────────────────────────────────────────────────
describe('normalizeAdrHeader', () => {
test('returns empty string for non-string input (undefined)', () => {
assert.equal(normalizeAdrHeader(undefined), '');
});
test('returns empty string for null', () => {
assert.equal(normalizeAdrHeader(null), '');
});
test('returns empty string for number', () => {
assert.equal(normalizeAdrHeader(42), '');
});
test('returns empty string for object', () => {
assert.equal(normalizeAdrHeader({}), '');
});
test('trims leading/trailing whitespace', () => {
assert.equal(normalizeAdrHeader(' status '), 'status');
});
test('lowercases the string', () => {
assert.equal(normalizeAdrHeader('STATUS'), 'status');
assert.equal(normalizeAdrHeader('Context'), 'context');
});
test('collapses whitespace/colon/dot/underscore/hyphen runs to single space', () => {
assert.equal(normalizeAdrHeader('out_of_scope'), 'out of scope');
assert.equal(normalizeAdrHeader('plan-sequence'), 'plan sequence');
assert.equal(normalizeAdrHeader('key.files'), 'key files');
assert.equal(normalizeAdrHeader('status:'), 'status');
assert.equal(normalizeAdrHeader('multiple spaces'), 'multiple spaces');
});
test('removes non-word non-space characters', () => {
// An exclamation mark is not \w or \s so it is stripped
assert.equal(normalizeAdrHeader('status!'), 'status');
assert.equal(normalizeAdrHeader('decisions?'), 'decisions');
});
test('trims again after removal (leading/trailing spaces from stripped chars)', () => {
// If punctuation was adjacent to start/end, second trim fires
assert.equal(normalizeAdrHeader('!status'), 'status');
assert.equal(normalizeAdrHeader('status!'), 'status');
});
test('empty string returns empty string', () => {
assert.equal(normalizeAdrHeader(''), '');
});
test('whitespace-only returns empty string', () => {
assert.equal(normalizeAdrHeader(' '), '');
});
test('exact output for every CANONICAL_HEADERS key slug', () => {
// status group
assert.equal(normalizeAdrHeader('Status'), 'status');
assert.equal(normalizeAdrHeader('State'), 'state');
assert.equal(normalizeAdrHeader('Lifecycle'), 'lifecycle');
assert.equal(normalizeAdrHeader('Stage'), 'stage');
// goal group
assert.equal(normalizeAdrHeader('Context'), 'context');
assert.equal(normalizeAdrHeader('Background'), 'background');
assert.equal(normalizeAdrHeader('Problem Statement'), 'problem statement');
assert.equal(normalizeAdrHeader('Motivation'), 'motivation');
assert.equal(normalizeAdrHeader('Drivers'), 'drivers');
// decisions
assert.equal(normalizeAdrHeader('Decision'), 'decision');
assert.equal(normalizeAdrHeader('Resolution'), 'resolution');
assert.equal(normalizeAdrHeader('We Decided'), 'we decided');
// considered_options
assert.equal(normalizeAdrHeader('Considered Options'), 'considered options');
assert.equal(normalizeAdrHeader('Alternatives'), 'alternatives');
assert.equal(normalizeAdrHeader('Trade-offs'), 'trade offs');
// risks
assert.equal(normalizeAdrHeader('Risks'), 'risks');
assert.equal(normalizeAdrHeader('Drawbacks'), 'drawbacks');
assert.equal(normalizeAdrHeader('Side Effects'), 'side effects');
// success_criteria
assert.equal(normalizeAdrHeader('Success Criteria'), 'success criteria');
assert.equal(normalizeAdrHeader('Metrics'), 'metrics');
assert.equal(normalizeAdrHeader('KPIs'), 'kpis');
assert.equal(normalizeAdrHeader('Definition of Done'), 'definition of done');
// plan_sequence
assert.equal(normalizeAdrHeader('Implementation Plan'), 'implementation plan');
assert.equal(normalizeAdrHeader('Roadmap'), 'roadmap');
assert.equal(normalizeAdrHeader('Milestones'), 'milestones');
// key_files
assert.equal(normalizeAdrHeader('Affected Files'), 'affected files');
assert.equal(normalizeAdrHeader('Diff Summary'), 'diff summary');
// out_of_scope
assert.equal(normalizeAdrHeader('Out of Scope'), 'out of scope');
assert.equal(normalizeAdrHeader("Won't Do"), 'wont do');
// deferred
assert.equal(normalizeAdrHeader('Future Work'), 'future work');
assert.equal(normalizeAdrHeader('Follow-up'), 'follow up');
// dependencies
assert.equal(normalizeAdrHeader('Dependencies'), 'dependencies');
assert.equal(normalizeAdrHeader('Related ADRs'), 'related adrs');
// update
assert.equal(normalizeAdrHeader('Update'), 'update');
assert.equal(normalizeAdrHeader('Amendment'), 'amendment');
// consequences
assert.equal(normalizeAdrHeader('Consequences'), 'consequences');
assert.equal(normalizeAdrHeader('Implications'), 'implications');
assert.equal(normalizeAdrHeader('Impact'), 'impact');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// CANONICAL_HEADERS — structure exported correctly
// ─────────────────────────────────────────────────────────────────────────────
describe('CANONICAL_HEADERS export', () => {
test('exports CANONICAL_HEADERS as an object', () => {
assert.ok(typeof CANONICAL_HEADERS === 'object' && CANONICAL_HEADERS !== null);
});
test('contains all 13 canonical keys', () => {
const keys = Object.keys(CANONICAL_HEADERS);
for (const k of ['status', 'goal', 'decisions', 'considered_options', 'risks',
'success_criteria', 'plan_sequence', 'key_files', 'out_of_scope',
'deferred', 'dependencies', 'update', 'consequences']) {
assert.ok(keys.includes(k), `missing key: ${k}`);
}
});
test('each canonical key maps to a non-empty array of strings', () => {
for (const [key, synonyms] of Object.entries(CANONICAL_HEADERS)) {
assert.ok(Array.isArray(synonyms), `${key} synonyms must be array`);
assert.ok(synonyms.length > 0, `${key} synonyms must not be empty`);
for (const syn of synonyms) {
assert.equal(typeof syn, 'string', `${key} synonym must be string`);
}
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseSections behaviour (via parseAdrMarkdown on targeted inputs)
// ─────────────────────────────────────────────────────────────────────────────
describe('parseSections (via parseAdrMarkdown)', () => {
test('empty string produces no title, no decisions, empty output', () => {
const out = parseAdrMarkdown('');
assert.equal(out.title, '');
assert.deepEqual(out.decisions, []);
assert.equal(out.status, 'accepted'); // fallback
});
test('body-only markdown (no headings) has empty title', () => {
const out = parseAdrMarkdown('Just some text\nAnother line');
assert.equal(out.title, '');
assert.equal(out.status, 'accepted');
});
test('H1 heading extracts as title', () => {
const out = parseAdrMarkdown('# My ADR Title\n\n## Status\nAccepted\n');
assert.equal(out.title, 'My ADR Title');
});
test('H2 section heading is parsed (not title)', () => {
const out = parseAdrMarkdown('## Decision\n- Do the thing.');
assert.deepEqual(out.decisions, ['Do the thing.']);
assert.equal(out.title, ''); // H2 is not extracted as title
});
test('H3 section heading is parsed', () => {
const out = parseAdrMarkdown('# ADR\n\n### Decision\n- Sub-level choice.');
assert.deepEqual(out.decisions, ['Sub-level choice.']);
});
test('H4 section heading is parsed', () => {
const out = parseAdrMarkdown('#### Decision\n- Deep choice.');
assert.deepEqual(out.decisions, ['Deep choice.']);
});
test('H5 section heading is parsed', () => {
const out = parseAdrMarkdown('##### Decision\n- Very deep choice.');
assert.deepEqual(out.decisions, ['Very deep choice.']);
});
test('H6 section heading is parsed', () => {
const out = parseAdrMarkdown('###### Decision\n- Deepest choice.');
assert.deepEqual(out.decisions, ['Deepest choice.']);
});
test('CRLF line endings are handled', () => {
const out = parseAdrMarkdown('# ADR\r\n\r\n## Status\r\nAccepted\r\n\r\n## Decision\r\n- CRLF entry.');
assert.equal(out.title, 'ADR');
assert.equal(out.status, 'accepted');
assert.deepEqual(out.decisions, ['CRLF entry.']);
});
test('multiple sections with same canonical key merge (pushUnique)', () => {
const md = [
'# ADR',
'',
'## Decision',
'- First.',
'',
'## Resolution',
'- Second.',
].join('\n');
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['First.', 'Second.']);
});
test('duplicate entries in pushUnique are deduplicated', () => {
const md = [
'# ADR',
'',
'## Decision',
'- Same entry.',
'',
'## Resolution',
'- Same entry.',
].join('\n');
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['Same entry.']);
});
test('unknown/unmapped heading added to unmapped_headers', () => {
const md = [
'# ADR',
'',
'## Custom Weird Section',
'Content here.',
].join('\n');
const out = parseAdrMarkdown(md);
assert.ok(out.unmapped_headers.includes('Custom Weird Section'));
});
test('multiple unknown headings all in unmapped_headers', () => {
const md = [
'# ADR',
'',
'## Appendix A',
'Some data.',
'',
'## Appendix B',
'More data.',
].join('\n');
const out = parseAdrMarkdown(md);
assert.ok(out.unmapped_headers.includes('Appendix A'));
assert.ok(out.unmapped_headers.includes('Appendix B'));
// H1 "ADR" is also treated as a section heading and goes into unmapped_headers
assert.ok(out.unmapped_headers.includes('ADR'));
assert.equal(out.unmapped_headers.length, 3);
});
test('section with empty body produces empty entries', () => {
const md = '# ADR\n\n## Decision\n\n## Context\nSome context.';
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, []);
assert.ok(out.context.includes('Some context.'));
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — title extraction
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: title extraction', () => {
test('no H1 → empty title string', () => {
const out = parseAdrMarkdown('## Status\nAccepted\n');
assert.equal(out.title, '');
});
test('H1 with complex title preserved exactly', () => {
const out = parseAdrMarkdown('# ADR-0042: Use TypeScript for new modules\n');
assert.equal(out.title, 'ADR-0042: Use TypeScript for new modules');
});
test('H1 is found even when not on the first line', () => {
const md = 'Some preamble\n\n# Actual Title\n\n## Status\nAccepted\n';
const out = parseAdrMarkdown(md);
assert.equal(out.title, 'Actual Title');
});
test('only the first H1 is taken as title', () => {
const md = '# First Title\n# Second Title\n';
const out = parseAdrMarkdown(md);
assert.equal(out.title, 'First Title');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — status extraction (all keyword branches)
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: status extraction', () => {
function makeStatusMd(statusText) {
return `# ADR\n\n## Status\n${statusText}\n`;
}
test('status "accepted" (case-insensitive match)', () => {
assert.equal(parseAdrMarkdown(makeStatusMd('Accepted')).status, 'accepted');
assert.equal(parseAdrMarkdown(makeStatusMd('ACCEPTED')).status, 'accepted');
assert.equal(parseAdrMarkdown(makeStatusMd('accepted')).status, 'accepted');
});
test('status "proposed" (case-insensitive match)', () => {
assert.equal(parseAdrMarkdown(makeStatusMd('Proposed')).status, 'proposed');
assert.equal(parseAdrMarkdown(makeStatusMd('PROPOSED')).status, 'proposed');
});
test('status "superseded" (case-insensitive match)', () => {
assert.equal(parseAdrMarkdown(makeStatusMd('Superseded')).status, 'superseded');
assert.equal(parseAdrMarkdown(makeStatusMd('SUPERSEDED')).status, 'superseded');
});
test('status "rejected" (case-insensitive match)', () => {
assert.equal(parseAdrMarkdown(makeStatusMd('Rejected')).status, 'rejected');
});
test('status "deprecated" (case-insensitive match)', () => {
assert.equal(parseAdrMarkdown(makeStatusMd('Deprecated')).status, 'deprecated');
});
test('status with surrounding text containing "accepted" keyword', () => {
assert.equal(parseAdrMarkdown(makeStatusMd('Accepted by team on 2024-01')).status, 'accepted');
});
test('custom/unknown status is normalized and returned verbatim (normalized)', () => {
// normalizeAdrHeader applied: lowercased, spaces collapsed
assert.equal(parseAdrMarkdown(makeStatusMd('Active')).status, 'active');
assert.equal(parseAdrMarkdown(makeStatusMd('Draft')).status, 'draft');
assert.equal(parseAdrMarkdown(makeStatusMd('On Hold')).status, 'on hold');
});
test('status section with empty body → empty string → falls back to "accepted"', () => {
// empty norm → returns '' → parseAdrMarkdown uses || 'accepted'
const md = '# ADR\n\n## Status\n\n## Decision\n- Something.';
const out = parseAdrMarkdown(md);
assert.equal(out.status, 'accepted');
});
test('no status section → falls back to "accepted"', () => {
const out = parseAdrMarkdown('# ADR\n\n## Context\nSome context.');
assert.equal(out.status, 'accepted');
});
test('status synonym "State" maps to status section', () => {
const out = parseAdrMarkdown('# ADR\n\n## State\nproposed\n');
assert.equal(out.status, 'proposed');
});
test('status synonym "Lifecycle" maps to status section', () => {
const out = parseAdrMarkdown('# ADR\n\n## Lifecycle\naccepted\n');
assert.equal(out.status, 'accepted');
});
test('status synonym "Stage" maps to status section', () => {
const out = parseAdrMarkdown('# ADR\n\n## Stage\ndraft\n');
assert.equal(out.status, 'draft');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — sourcePath and format options
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: options', () => {
test('sourcePath defaults to empty string', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.equal(out.source_path, '');
});
test('sourcePath is preserved exactly', () => {
const out = parseAdrMarkdown('# ADR\n', { sourcePath: 'docs/adr/0099.md' });
assert.equal(out.source_path, 'docs/adr/0099.md');
});
test('format defaults to "auto"', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.equal(out.format, 'auto');
});
test('format option is preserved exactly', () => {
const out = parseAdrMarkdown('# ADR\n', { format: 'madr' });
assert.equal(out.format, 'madr');
});
test('empty options object uses defaults', () => {
const out = parseAdrMarkdown('# ADR\n', {});
assert.equal(out.source_path, '');
assert.equal(out.format, 'auto');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — goal/context section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: goal/context section', () => {
test('context set from "Context" heading', () => {
const out = parseAdrMarkdown('# ADR\n\n## Context\nWe need to fix the build.');
assert.equal(out.context.trim(), 'We need to fix the build.');
});
test('context set from "Background" heading', () => {
const out = parseAdrMarkdown('# ADR\n\n## Background\nThe system is slow.');
assert.equal(out.context.trim(), 'The system is slow.');
});
test('context set from "Problem Statement" heading', () => {
const out = parseAdrMarkdown('# ADR\n\n## Problem Statement\nThe cache is broken.');
assert.equal(out.context.trim(), 'The cache is broken.');
});
test('context only set from FIRST goal section (guard: !out.context)', () => {
const md = [
'# ADR',
'',
'## Context',
'First context.',
'',
'## Background',
'Second context.',
].join('\n');
const out = parseAdrMarkdown(md);
assert.equal(out.context.trim(), 'First context.');
});
test('context is empty string when no goal section', () => {
const out = parseAdrMarkdown('# ADR\n\n## Decision\n- Do it.');
assert.equal(out.context, '');
});
test('goal section with empty body does NOT set context', () => {
const md = '# ADR\n\n## Context\n\n## Decision\n- Do it.';
const out = parseAdrMarkdown(md);
assert.equal(out.context, '');
});
test('"Situation" heading maps to goal/context', () => {
const out = parseAdrMarkdown('# ADR\n\n## Situation\nSystem at capacity.');
assert.equal(out.context.trim(), 'System at capacity.');
});
test('"Forces" heading maps to goal/context', () => {
const out = parseAdrMarkdown('# ADR\n\n## Forces\nTime pressure.');
assert.equal(out.context.trim(), 'Time pressure.');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — decisions section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: decisions section', () => {
test('"Decision" maps to decisions', () => {
const out = parseAdrMarkdown('## Decision\n- Use TypeScript.');
assert.deepEqual(out.decisions, ['Use TypeScript.']);
});
test('"Decisions" maps to decisions', () => {
const out = parseAdrMarkdown('## Decisions\n- Use TypeScript.');
assert.deepEqual(out.decisions, ['Use TypeScript.']);
});
test('"Resolution" maps to decisions', () => {
const out = parseAdrMarkdown('## Resolution\n- Use TypeScript.');
assert.deepEqual(out.decisions, ['Use TypeScript.']);
});
test('"Conclusion" maps to decisions', () => {
const out = parseAdrMarkdown('## Conclusion\n- Refactor auth.');
assert.deepEqual(out.decisions, ['Refactor auth.']);
});
test('"Choice" maps to decisions', () => {
const out = parseAdrMarkdown('## Choice\n- GraphQL over REST.');
assert.deepEqual(out.decisions, ['GraphQL over REST.']);
});
test('"We Decided" maps to decisions', () => {
const out = parseAdrMarkdown('## We Decided\n- Adopt Rust.');
assert.deepEqual(out.decisions, ['Adopt Rust.']);
});
test('"Direction" maps to decisions', () => {
const out = parseAdrMarkdown('## Direction\n- Move to cloud.');
assert.deepEqual(out.decisions, ['Move to cloud.']);
});
test('"Approach" maps to decisions', () => {
const out = parseAdrMarkdown('## Approach\n- Use monorepo.');
assert.deepEqual(out.decisions, ['Use monorepo.']);
});
test('"Solution" maps to decisions', () => {
const out = parseAdrMarkdown('## Solution\n- Use Redis.');
assert.deepEqual(out.decisions, ['Use Redis.']);
});
test('"Outcome" maps to decisions', () => {
const out = parseAdrMarkdown('## Outcome\n- Deployed to prod.');
assert.deepEqual(out.decisions, ['Deployed to prod.']);
});
test('"Selected Option" maps to decisions', () => {
const out = parseAdrMarkdown('## Selected Option\n- Option A.');
assert.deepEqual(out.decisions, ['Option A.']);
});
test('"Recommendation" maps to decisions', () => {
const out = parseAdrMarkdown('## Recommendation\n- Do X.');
assert.deepEqual(out.decisions, ['Do X.']);
});
test('"Strategy" maps to decisions', () => {
const out = parseAdrMarkdown('## Strategy\n- Incremental rollout.');
assert.deepEqual(out.decisions, ['Incremental rollout.']);
});
test('"Decision Outcome" maps to decisions', () => {
const out = parseAdrMarkdown('## Decision Outcome\n- Ship it.');
assert.deepEqual(out.decisions, ['Ship it.']);
});
test('bullet items stripped of marker characters', () => {
const md = '## Decision\n- Dash item.\n* Star item.\n+ Plus item.';
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['Dash item.', 'Star item.', 'Plus item.']);
});
test('decisions is empty array when no decision section', () => {
const out = parseAdrMarkdown('# ADR\n\n## Context\nSome context.');
assert.deepEqual(out.decisions, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — considered_options section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: considered_options section', () => {
test('"Alternatives" maps to options_considered', () => {
const out = parseAdrMarkdown('## Alternatives\n- Option B.');
assert.deepEqual(out.options_considered, ['Option B.']);
});
test('"Options" maps to options_considered', () => {
const out = parseAdrMarkdown('## Options\n- Option C.');
assert.deepEqual(out.options_considered, ['Option C.']);
});
test('"Choices" maps to options_considered', () => {
const out = parseAdrMarkdown('## Choices\n- Option D.');
assert.deepEqual(out.options_considered, ['Option D.']);
});
test('"Candidates" maps to options_considered', () => {
const out = parseAdrMarkdown('## Candidates\n- Candidate X.');
assert.deepEqual(out.options_considered, ['Candidate X.']);
});
test('"Approaches Considered" maps to options_considered', () => {
const out = parseAdrMarkdown('## Approaches Considered\n- Approach A.');
assert.deepEqual(out.options_considered, ['Approach A.']);
});
test('"Variants" maps to options_considered', () => {
const out = parseAdrMarkdown('## Variants\n- Variant 1.');
assert.deepEqual(out.options_considered, ['Variant 1.']);
});
test('"Discussion" maps to options_considered', () => {
const out = parseAdrMarkdown('## Discussion\n- Discussed approach.');
assert.deepEqual(out.options_considered, ['Discussed approach.']);
});
test('"Pros and Cons of the Options" maps to options_considered', () => {
const out = parseAdrMarkdown('## Pros and Cons of the Options\n- Pro: fast.');
assert.deepEqual(out.options_considered, ['Pro: fast.']);
});
test('options_considered is empty when no section', () => {
const out = parseAdrMarkdown('# ADR\n\n## Decision\n- Do it.');
assert.deepEqual(out.options_considered, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — risks section → consequences_negative
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: risks section', () => {
test('"Risks" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Risks\n- Risk of outage.');
assert.deepEqual(out.consequences_negative, ['Risk of outage.']);
assert.deepEqual(out.consequences_positive, []);
});
test('"Trade-offs" maps to consequences_negative (M7: both sides normalized, synonym now reachable)', () => {
const out = parseAdrMarkdown('## Trade-offs\n- Increased latency.');
assert.deepEqual(out.consequences_negative, ['Increased latency.']);
assert.ok(!out.unmapped_headers.includes('Trade-offs'));
});
test('"Drawbacks" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Drawbacks\n- Higher cost.');
assert.deepEqual(out.consequences_negative, ['Higher cost.']);
});
test('"Cost" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Cost\n- Time investment.');
assert.deepEqual(out.consequences_negative, ['Time investment.']);
});
test('"Tensions" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Tensions\n- Team tension.');
assert.deepEqual(out.consequences_negative, ['Team tension.']);
});
test('"Liabilities" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Liabilities\n- Vendor lock-in.');
assert.deepEqual(out.consequences_negative, ['Vendor lock-in.']);
});
test('"Negative Consequences" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Negative Consequences\n- Debt.');
assert.deepEqual(out.consequences_negative, ['Debt.']);
});
test('"Side Effects" maps to consequences_negative', () => {
const out = parseAdrMarkdown('## Side Effects\n- Performance hit.');
assert.deepEqual(out.consequences_negative, ['Performance hit.']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — success_criteria section → consequences_positive
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: success_criteria section', () => {
test('"Success Criteria" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Success Criteria\n- 99.9% uptime.');
assert.deepEqual(out.consequences_positive, ['99.9% uptime.']);
assert.deepEqual(out.consequences_negative, []);
});
test('"Acceptance Criteria" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Acceptance Criteria\n- Tests pass.');
assert.deepEqual(out.consequences_positive, ['Tests pass.']);
});
test('"Validation" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Validation\n- Manual testing done.');
assert.deepEqual(out.consequences_positive, ['Manual testing done.']);
});
test('"Metrics" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Metrics\n- Latency < 100ms.');
assert.deepEqual(out.consequences_positive, ['Latency < 100ms.']);
});
test('"KPIs" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## KPIs\n- Revenue up 10%.');
assert.deepEqual(out.consequences_positive, ['Revenue up 10%.']);
});
test('"Verification" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Verification\n- CI green.');
assert.deepEqual(out.consequences_positive, ['CI green.']);
});
test('"Test Strategy" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Test Strategy\n- Unit + integration.');
assert.deepEqual(out.consequences_positive, ['Unit + integration.']);
});
test('"Definition of Done" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Definition of Done\n- Merged and deployed.');
assert.deepEqual(out.consequences_positive, ['Merged and deployed.']);
});
test('"Exit Criteria" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Exit Criteria\n- No open P0 bugs.');
assert.deepEqual(out.consequences_positive, ['No open P0 bugs.']);
});
test('"Positive Consequences" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Positive Consequences\n- Better DX.');
assert.deepEqual(out.consequences_positive, ['Better DX.']);
});
test('"How We\'ll Know" maps to consequences_positive (M7: synonym normalized on both sides, now reachable)', () => {
// The apostrophe in "we'll" is stripped by normalizeAdrHeader on BOTH the header and the
// synonym, so both yield "how well know" and now match (success_criteria → consequences_positive).
const out = parseAdrMarkdown("## How We'll Know\n- Sales increase.");
assert.deepEqual(out.consequences_positive, ['Sales increase.']);
assert.ok(!out.unmapped_headers.includes("How We'll Know"));
});
test('"Compliance" maps to consequences_positive', () => {
const out = parseAdrMarkdown('## Compliance\n- SOC2 passed.');
assert.deepEqual(out.consequences_positive, ['SOC2 passed.']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseConsequences — hint-based triage (via Consequences heading)
// ─────────────────────────────────────────────────────────────────────────────
describe('parseConsequences (via Consequences section)', () => {
function makeConsequencesMd(entries) {
return `# ADR\n\n## Consequences\n${entries.map((e) => `- ${e}`).join('\n')}\n`;
}
test('entry containing "negative" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['negative: cost increases']));
assert.deepEqual(out.consequences_negative, ['negative: cost increases']);
assert.deepEqual(out.consequences_positive, []);
});
test('entry containing "drawback" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['One drawback: overhead']));
assert.deepEqual(out.consequences_negative, ['One drawback: overhead']);
});
test('entry containing "risk" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['risk of data loss']));
assert.deepEqual(out.consequences_negative, ['risk of data loss']);
});
test('entry containing "cost" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['high cost to maintain']));
assert.deepEqual(out.consequences_negative, ['high cost to maintain']);
});
test('entry containing "liability" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['legal liability risk']));
assert.deepEqual(out.consequences_negative, ['legal liability risk']);
});
test('entry containing "trade-off" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['this trade-off is worth it']));
assert.deepEqual(out.consequences_negative, ['this trade-off is worth it']);
});
test('entry containing "tension" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['team tension exists']));
assert.deepEqual(out.consequences_negative, ['team tension exists']);
});
test('entry containing "side effect" → consequences_negative', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['side effect: memory growth']));
assert.deepEqual(out.consequences_negative, ['side effect: memory growth']);
});
test('entry containing "positive" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['positive: faster deploys']));
assert.deepEqual(out.consequences_positive, ['positive: faster deploys']);
assert.deepEqual(out.consequences_negative, []);
});
test('entry containing "success" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['success rate improves']));
assert.deepEqual(out.consequences_positive, ['success rate improves']);
});
test('entry containing "metric" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['metric: latency < 100ms']));
assert.deepEqual(out.consequences_positive, ['metric: latency < 100ms']);
});
test('entry containing "kpi" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['kpi tracked monthly']));
assert.deepEqual(out.consequences_positive, ['kpi tracked monthly']);
});
test('entry containing "verification" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['verification: run test suite']));
assert.deepEqual(out.consequences_positive, ['verification: run test suite']);
});
test('entry containing "acceptance" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['acceptance tests pass']));
assert.deepEqual(out.consequences_positive, ['acceptance tests pass']);
});
test('entry containing "benefit" → consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['benefit: faster CI']));
assert.deepEqual(out.consequences_positive, ['benefit: faster CI']);
});
test('entry with no hint → fallback to consequences_positive', () => {
const out = parseAdrMarkdown(makeConsequencesMd(['General observation.']));
assert.deepEqual(out.consequences_positive, ['General observation.']);
assert.deepEqual(out.consequences_negative, []);
});
test('multiple entries each triaged independently', () => {
const entries = [
'negative: first bad thing',
'positive: first good thing',
'no hint here',
'drawback: another bad thing',
'benefit: another good thing',
];
const out = parseAdrMarkdown(makeConsequencesMd(entries));
assert.deepEqual(out.consequences_negative, [
'negative: first bad thing',
'drawback: another bad thing',
]);
assert.deepEqual(out.consequences_positive, [
'positive: first good thing',
'no hint here',
'benefit: another good thing',
]);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — plan_sequence section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: plan_sequence section', () => {
test('"Implementation Plan" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Implementation Plan\n- Step 1.');
assert.deepEqual(out.plan_sequence, ['Step 1.']);
});
test('"Implementation Notes" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Implementation Notes\n- Note 1.');
assert.deepEqual(out.plan_sequence, ['Note 1.']);
});
test('"Steps" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Steps\n- Do A.\n- Do B.');
assert.deepEqual(out.plan_sequence, ['Do A.', 'Do B.']);
});
test('"Tasks" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Tasks\n- Task 1.');
assert.deepEqual(out.plan_sequence, ['Task 1.']);
});
test('"Roadmap" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Roadmap\n- Q1: alpha.');
assert.deepEqual(out.plan_sequence, ['Q1: alpha.']);
});
test('"Sequence" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Sequence\n- Phase 1.');
assert.deepEqual(out.plan_sequence, ['Phase 1.']);
});
test('"Migration Plan" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Migration Plan\n- Migrate DB first.');
assert.deepEqual(out.plan_sequence, ['Migrate DB first.']);
});
test('"Plan" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Plan\n- Create ticket.');
assert.deepEqual(out.plan_sequence, ['Create ticket.']);
});
test('"Action Items" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Action Items\n- Fix bug.');
assert.deepEqual(out.plan_sequence, ['Fix bug.']);
});
test('"Work Breakdown" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Work Breakdown\n- Backend sprint.');
assert.deepEqual(out.plan_sequence, ['Backend sprint.']);
});
test('"Phases" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Phases\n- Phase A.');
assert.deepEqual(out.plan_sequence, ['Phase A.']);
});
test('"Milestones" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Milestones\n- v1.0 release.');
assert.deepEqual(out.plan_sequence, ['v1.0 release.']);
});
test('"Stages" maps to plan_sequence', () => {
const out = parseAdrMarkdown('## Stages\n- Stage 1: prototype.');
assert.deepEqual(out.plan_sequence, ['Stage 1: prototype.']);
});
test('plan_sequence is empty when no section', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.deepEqual(out.plan_sequence, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — key_files section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: key_files section', () => {
test('"Affected Files" maps to key_files', () => {
const out = parseAdrMarkdown('## Affected Files\n- src/index.ts');
assert.deepEqual(out.key_files, ['src/index.ts']);
});
test('"Files Touched" maps to key_files', () => {
const out = parseAdrMarkdown('## Files Touched\n- lib/core.js');
assert.deepEqual(out.key_files, ['lib/core.js']);
});
test('"Surface Area" maps to key_files', () => {
const out = parseAdrMarkdown('## Surface Area\n- api/routes.ts');
assert.deepEqual(out.key_files, ['api/routes.ts']);
});
test('"Modules Affected" maps to key_files', () => {
const out = parseAdrMarkdown('## Modules Affected\n- auth module');
assert.deepEqual(out.key_files, ['auth module']);
});
test('"Code Locations" maps to key_files', () => {
const out = parseAdrMarkdown('## Code Locations\n- src/parser.ts');
assert.deepEqual(out.key_files, ['src/parser.ts']);
});
test('"File Changes" maps to key_files', () => {
const out = parseAdrMarkdown('## File Changes\n- config.json');
assert.deepEqual(out.key_files, ['config.json']);
});
test('"Diff Summary" maps to key_files', () => {
const out = parseAdrMarkdown('## Diff Summary\n- +50 -10 lines');
assert.deepEqual(out.key_files, ['+50 -10 lines']);
});
test('"Touched Code" maps to key_files', () => {
const out = parseAdrMarkdown('## Touched Code\n- helpers.ts');
assert.deepEqual(out.key_files, ['helpers.ts']);
});
test('key_files is empty when no section', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.deepEqual(out.key_files, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — out_of_scope section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: out_of_scope section', () => {
test('"Non-goals" maps to out_of_scope (M7: both sides normalized to "non goals", now reachable)', () => {
const out = parseAdrMarkdown('## Non-goals\n- Not this.');
assert.deepEqual(out.out_of_scope, ['Not this.']);
assert.ok(!out.unmapped_headers.includes('Non-goals'));
});
test('"Excluded" maps to out_of_scope', () => {
const out = parseAdrMarkdown('## Excluded\n- Feature X.');
assert.deepEqual(out.out_of_scope, ['Feature X.']);
});
test('"Not in this ADR" maps to out_of_scope', () => {
const out = parseAdrMarkdown('## Not in this ADR\n- Remote ingest.');
assert.deepEqual(out.out_of_scope, ['Remote ingest.']);
});
test('"Out of Bounds" maps to out_of_scope', () => {
const out = parseAdrMarkdown('## Out of Bounds\n- Infrastructure.');
assert.deepEqual(out.out_of_scope, ['Infrastructure.']);
});
test('"Beyond Scope" maps to out_of_scope', () => {
const out = parseAdrMarkdown('## Beyond Scope\n- Billing system.');
assert.deepEqual(out.out_of_scope, ['Billing system.']);
});
test('"Anti-goals" maps to out_of_scope (M7: both sides normalized to "anti goals", now reachable)', () => {
const out = parseAdrMarkdown('## Anti-goals\n- Gold plating.');
assert.deepEqual(out.out_of_scope, ['Gold plating.']);
assert.ok(!out.unmapped_headers.includes('Anti-goals'));
});
test('out_of_scope is empty when no section', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.deepEqual(out.out_of_scope, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — deferred section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: deferred section', () => {
test('"Deferred" maps to deferred', () => {
const out = parseAdrMarkdown('## Deferred\n- Caching layer.');
assert.deepEqual(out.deferred, ['Caching layer.']);
});
test('"Future" maps to deferred', () => {
const out = parseAdrMarkdown('## Future\n- API v2.');
assert.deepEqual(out.deferred, ['API v2.']);
});
test('"Later" maps to deferred', () => {
const out = parseAdrMarkdown('## Later\n- Optimize later.');
assert.deepEqual(out.deferred, ['Optimize later.']);
});
test('"Follow-up" maps to deferred (M7: both sides normalized to "follow up", now reachable)', () => {
const out = parseAdrMarkdown('## Follow-up\n- Monitor metrics.');
assert.deepEqual(out.deferred, ['Monitor metrics.']);
assert.ok(!out.unmapped_headers.includes('Follow-up'));
});
test('"Next Steps" maps to deferred', () => {
const out = parseAdrMarkdown('## Next Steps\n- Schedule review.');
assert.deepEqual(out.deferred, ['Schedule review.']);
});
test('deferred is empty when no section', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.deepEqual(out.deferred, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — dependencies section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: dependencies section', () => {
test('"Depends On" maps to dependencies', () => {
const out = parseAdrMarkdown('## Depends On\n- ADR-0001');
assert.deepEqual(out.dependencies, ['ADR-0001']);
});
test('"Prerequisites" maps to dependencies', () => {
const out = parseAdrMarkdown('## Prerequisites\n- Node.js 18');
assert.deepEqual(out.dependencies, ['Node.js 18']);
});
test('"Sequencing" maps to dependencies', () => {
const out = parseAdrMarkdown('## Sequencing\n- Must follow ADR-003.');
assert.deepEqual(out.dependencies, ['Must follow ADR-003.']);
});
test('"Order" maps to dependencies', () => {
const out = parseAdrMarkdown('## Order\n- ADR-002 first.');
assert.deepEqual(out.dependencies, ['ADR-002 first.']);
});
test('"Blocked By" maps to dependencies', () => {
const out = parseAdrMarkdown('## Blocked By\n- Team capacity.');
assert.deepEqual(out.dependencies, ['Team capacity.']);
});
test('"Cross-cuts" maps to dependencies (M7: both sides normalized to "cross cuts", now reachable)', () => {
const out = parseAdrMarkdown('## Cross-cuts\n- Security layer.');
assert.deepEqual(out.dependencies, ['Security layer.']);
assert.ok(!out.unmapped_headers.includes('Cross-cuts'));
});
test('"Related ADRs" maps to dependencies', () => {
const out = parseAdrMarkdown('## Related ADRs\n- ADR-0003');
assert.deepEqual(out.dependencies, ['ADR-0003']);
});
test('"Links" maps to dependencies', () => {
const out = parseAdrMarkdown('## Links\n- https://example.com');
assert.deepEqual(out.dependencies, ['https://example.com']);
});
test('"References" maps to dependencies', () => {
const out = parseAdrMarkdown('## References\n- RFC 9110');
assert.deepEqual(out.dependencies, ['RFC 9110']);
});
test('"See Also" maps to dependencies', () => {
const out = parseAdrMarkdown('## See Also\n- ADR-0005');
assert.deepEqual(out.dependencies, ['ADR-0005']);
});
test('"Upstream" maps to dependencies', () => {
const out = parseAdrMarkdown('## Upstream\n- Platform team.');
assert.deepEqual(out.dependencies, ['Platform team.']);
});
test('"Inbound" maps to dependencies', () => {
const out = parseAdrMarkdown('## Inbound\n- From ADR-0007.');
assert.deepEqual(out.dependencies, ['From ADR-0007.']);
});
test('dependencies is empty when no section', () => {
const out = parseAdrMarkdown('# ADR\n');
assert.deepEqual(out.dependencies, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — update section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: update section', () => {
test('"Revision" maps to updates', () => {
const out = parseAdrMarkdown('## Revision\n- Changed the approach.');
assert.equal(out.updates.length, 1);
assert.equal(out.updates[0].heading, 'Revision');
assert.deepEqual(out.updates[0].entries, ['Changed the approach.']);
});
test('"Amendment" maps to updates', () => {
const out = parseAdrMarkdown('## Amendment\n- Added exception.');
assert.equal(out.updates.length, 1);
assert.equal(out.updates[0].heading, 'Amendment');
});
test('"Locked Design" maps to updates', () => {
const out = parseAdrMarkdown('## Locked Design\n- Locked.', { sourcePath: '' });
assert.equal(out.updates.length, 1);
});
test('"Final Decision" maps to updates', () => {
const out = parseAdrMarkdown('## Final Decision\n- Ship v2.');
assert.equal(out.updates.length, 1);
assert.deepEqual(out.updates[0].entries, ['Ship v2.']);
});
test('"Post-grilling" maps to updates (M7: both sides normalized to "post grilling", now reachable)', () => {
const out = parseAdrMarkdown('## Post-grilling\n- Revised after review.');
assert.equal(out.updates.length, 1);
assert.deepEqual(out.updates[0].entries, ['Revised after review.']);
assert.ok(!out.unmapped_headers.includes('Post-grilling'));
});
test('"Addendum" maps to updates', () => {
const out = parseAdrMarkdown('## Addendum\n- Minor addition.');
assert.equal(out.updates.length, 1);
assert.deepEqual(out.updates[0].entries, ['Minor addition.']);
});
test('update section captures heading verbatim', () => {
const out = parseAdrMarkdown('## Update — locked design\n- Changed on 2024-01-01.');
assert.equal(out.updates[0].heading, 'Update — locked design');
});
test('multiple update sections produce multiple entries', () => {
const md = [
'## Update',
'- First update.',
'',
'## Revision',
'- Second update.',
].join('\n');
const out = parseAdrMarkdown(md);
assert.equal(out.updates.length, 2);
assert.equal(out.updates[0].heading, 'Update');
assert.equal(out.updates[1].heading, 'Revision');
});
test('updates is empty when no update section', () => {
const out = parseAdrMarkdown('# ADR\n\n## Decision\n- Do it.');
assert.deepEqual(out.updates, []);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseAdrMarkdown — consequences section
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: consequences canonical section', () => {
test('"Implications" maps to consequences (parsed via parseConsequences)', () => {
const out = parseAdrMarkdown('## Implications\n- negative: some drawback\n- positive: some benefit');
assert.deepEqual(out.consequences_negative, ['negative: some drawback']);
assert.deepEqual(out.consequences_positive, ['positive: some benefit']);
});
test('"Impact" maps to consequences', () => {
const out = parseAdrMarkdown('## Impact\n- risk of regression\n- benefit: faster');
assert.deepEqual(out.consequences_negative, ['risk of regression']);
assert.deepEqual(out.consequences_positive, ['benefit: faster']);
});
test('"What This Means" maps to consequences', () => {
const out = parseAdrMarkdown('## What This Means\n- General finding.');
assert.deepEqual(out.consequences_positive, ['General finding.']);
});
test('"Result" maps to consequences', () => {
const out = parseAdrMarkdown('## Result\n- drawback: extra cost');
assert.deepEqual(out.consequences_negative, ['drawback: extra cost']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// classifyHeader — cross-bucket synonym collision (audit M7)
// 'trade-offs' must resolve to risks (consequences_negative), not considered_options.
// CANONICAL_HEADERS once listed 'trade-offs' under BOTH buckets; classifyHeader is
// first-match-wins over Object.entries and considered_options is declared first, so
// '## Trade-offs' always misclassified as options and the risks entry was dead code.
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: punctuated synonyms are reachable (M7)', () => {
// Root cause: classifyHeader receives a normalized header but historically compared it
// against RAW synonyms; normalizeAdrHeader collapses [\s:._-]+ → space and strips [^\w\s],
// so any synonym with a hyphen/apostrophe was dead and its section went unmapped. The fix
// normalizes both sides, making the whole class reachable while the table stays readable.
test('"## Trade-offs" lands in consequences_negative (risks), not options_considered', () => {
const out = parseAdrMarkdown('## Trade-offs\n- adds a per-acquire syscall\n- larger lock body');
assert.deepEqual(out.consequences_negative, ['adds a per-acquire syscall', 'larger lock body']);
assert.deepEqual(out.options_considered, []);
});
test('all formerly-dead punctuated headers now classify to their bucket', () => {
assert.deepEqual(parseAdrMarkdown('## Non-Goals\n- x').out_of_scope, ['x']);
assert.deepEqual(parseAdrMarkdown('## Anti-Goals\n- x').out_of_scope, ['x']);
assert.deepEqual(parseAdrMarkdown("## Won't Do\n- x").out_of_scope, ['x']);
assert.deepEqual(parseAdrMarkdown('## Follow-up\n- x').deferred, ['x']);
assert.deepEqual(parseAdrMarkdown('## Cross-cuts\n- x').dependencies, ['x']);
assert.deepEqual(parseAdrMarkdown("## How We'll Know\n- x").consequences_positive, ['x']);
assert.equal(parseAdrMarkdown('## Post-grilling\n- 2026-01-01: note').updates[0].heading, 'Post-grilling');
});
test("'trade-offs' lives only in risks (de-duped from considered_options to avoid a cross-bucket collision)", () => {
assert.ok(!CANONICAL_HEADERS.considered_options.includes('trade-offs'));
assert.ok(CANONICAL_HEADERS.risks.includes('trade-offs'));
});
// Reachability invariant — guards the whole class against regression: every synonym in
// CANONICAL_HEADERS must classify (a header written as that synonym is never unmapped),
// and no two synonyms may normalize into different buckets (cross-bucket collision).
test('invariant: every CANONICAL_HEADERS synonym is reachable and collision-free', () => {
const byNormalized = new Map();
for (const [bucket, synonyms] of Object.entries(CANONICAL_HEADERS)) {
for (const syn of synonyms) {
const out = parseAdrMarkdown(`## ${syn}\n- z`);
assert.ok(!out.unmapped_headers.includes(syn), `synonym "${syn}" (bucket ${bucket}) is unreachable`);
const n = syn.toLowerCase().replace(/[\s:._-]+/g, ' ').replace(/[^\w\s]/g, '').trim();
if (byNormalized.has(n)) {
assert.equal(byNormalized.get(n), bucket, `normalized synonym "${n}" collides across buckets (${byNormalized.get(n)} vs ${bucket})`);
} else {
byNormalized.set(n, bucket);
}
}
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// classifyHeader — prefix-match branch
// ─────────────────────────────────────────────────────────────────────────────
describe('classifyHeader prefix-match (via parseAdrMarkdown)', () => {
test('heading that starts with a synonym prefix is classified', () => {
// "status " prefix match: "status as of 2024" → starts with "status "
const out = parseAdrMarkdown('## Status as of 2024\naccepted\n');
assert.equal(out.status, 'accepted');
});
test('heading that starts with "context " prefix is classified as goal', () => {
const out = parseAdrMarkdown('## Context and Problem Statement\nSome context.');
assert.equal(out.context.trim(), 'Some context.');
});
test('heading that starts with "decision " prefix is classified as decisions', () => {
const out = parseAdrMarkdown('## Decision Outcome\n- Use option A.');
assert.deepEqual(out.decisions, ['Use option A.']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// shouldRejectAdrStatus — normalisation path (uppercase/mixed)
// ─────────────────────────────────────────────────────────────────────────────
describe('shouldRejectAdrStatus: normalisation', () => {
test('uppercase "SUPERSEDED" is rejected (normalised before Set check)', () => {
assert.equal(shouldRejectAdrStatus('SUPERSEDED'), true);
});
test('uppercase "REJECTED" is rejected', () => {
assert.equal(shouldRejectAdrStatus('REJECTED'), true);
});
test('uppercase "DEPRECATED" is rejected', () => {
assert.equal(shouldRejectAdrStatus('DEPRECATED'), true);
});
test('mixed-case "Superseded" is rejected', () => {
assert.equal(shouldRejectAdrStatus('Superseded'), true);
});
test('mixed-case "Rejected" is rejected', () => {
assert.equal(shouldRejectAdrStatus('Rejected'), true);
});
test('mixed-case "Deprecated" is rejected', () => {
assert.equal(shouldRejectAdrStatus('Deprecated'), true);
});
test('"accepted" is not rejected', () => {
assert.equal(shouldRejectAdrStatus('accepted'), false);
});
test('"proposed" is not rejected', () => {
assert.equal(shouldRejectAdrStatus('proposed'), false);
});
test('"active" is not rejected', () => {
assert.equal(shouldRejectAdrStatus('active'), false);
});
test('empty string is not rejected', () => {
assert.equal(shouldRejectAdrStatus(''), false);
});
test('non-string returns false (does not throw)', () => {
assert.equal(shouldRejectAdrStatus(null), false);
assert.equal(shouldRejectAdrStatus(undefined), false);
assert.equal(shouldRejectAdrStatus(42), false);
});
test('status with punctuation normalised: "superseded." is rejected', () => {
// normalizeAdrHeader strips . → "superseded" → rejected
assert.equal(shouldRejectAdrStatus('superseded.'), true);
});
test('status with extra spaces: " rejected " is rejected', () => {
assert.equal(shouldRejectAdrStatus(' rejected '), true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// splitEntries behaviour (via parseAdrMarkdown)
// ─────────────────────────────────────────────────────────────────────────────
describe('splitEntries (via parseAdrMarkdown decisions)', () => {
test('dash-prefixed entries stripped', () => {
const out = parseAdrMarkdown('## Decision\n- Entry one.\n- Entry two.');
assert.deepEqual(out.decisions, ['Entry one.', 'Entry two.']);
});
test('star-prefixed entries stripped', () => {
const out = parseAdrMarkdown('## Decision\n* Star entry.');
assert.deepEqual(out.decisions, ['Star entry.']);
});
test('plus-prefixed entries stripped', () => {
const out = parseAdrMarkdown('## Decision\n+ Plus entry.');
assert.deepEqual(out.decisions, ['Plus entry.']);
});
test('blank lines between entries filtered out', () => {
const out = parseAdrMarkdown('## Decision\n- First.\n\n- Second.');
assert.deepEqual(out.decisions, ['First.', 'Second.']);
});
test('plain text without bullet still included', () => {
const out = parseAdrMarkdown('## Decision\nPlain text entry.');
assert.deepEqual(out.decisions, ['Plain text entry.']);
});
test('lines with only whitespace filtered', () => {
const out = parseAdrMarkdown('## Decision\n \n- Real entry.\n ');
assert.deepEqual(out.decisions, ['Real entry.']);
});
// Regression guard for ADR-1372 T2: iterateBullets folded indented non-bullet
// lines into the preceding bullet — the flat splitEntries must keep them.
test('indented non-bullet line (4-space) kept verbatim as its own entry', () => {
const md = '## Decision\n- Bullet entry\n indented non-bullet line\n- Another bullet';
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['Bullet entry', 'indented non-bullet line', 'Another bullet']);
});
// Regression guard for ADR-1372 T2: iterateBullets stripped numbered markers
// ("1. Foo" → "Foo") — the flat splitEntries only strips [-*+], not numbers.
test('numbered list item kept verbatim (not stripped to bare text)', () => {
const md = '## Decision\n1. First\n2. Second';
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['1. First', '2. Second']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Full integration: all sections in one document
// ─────────────────────────────────────────────────────────────────────────────
describe('parseAdrMarkdown: full document integration', () => {
test('complete ADR with all section types parsed correctly', () => {
const md = [
'# ADR-0001: Switch to PostgreSQL',
'',
'## Status',
'Accepted',
'',
'## Context',
'The SQLite database cannot handle concurrent writes.',
'',
'## Decision',
'- Migrate to PostgreSQL.',
'- Use connection pooling.',
'',
'## Considered Options',
'- Stay with SQLite.',
'- Use CockroachDB.',
'',
'## Success Criteria',
'- Zero data loss.',
'- 99.9% uptime maintained.',
'',
'## Risks',
'- Migration downtime.',
'',
'## Implementation Plan',
'- Step 1: Set up Postgres.',
'- Step 2: Migrate data.',
'',
'## Affected Files',
'- src/db/client.ts',
'',
'## Out of Scope',
'- Redis integration.',
'',
'## Future Work',
'- Connection sharding.',
'',
'## Dependencies',
'- ADR-0000',
'',
'## Update',
'- Changed connection pool size to 20.',
'',
'## Consequences',
'- negative: higher operational cost.',
'- positive: improved throughput.',
].join('\n');
const out = parseAdrMarkdown(md, { sourcePath: 'docs/adr/0001.md', format: 'custom' });
assert.equal(out.title, 'ADR-0001: Switch to PostgreSQL');
assert.equal(out.status, 'accepted');
assert.equal(out.source_path, 'docs/adr/0001.md');
assert.equal(out.format, 'custom');
assert.equal(out.context.trim(), 'The SQLite database cannot handle concurrent writes.');
assert.deepEqual(out.decisions, ['Migrate to PostgreSQL.', 'Use connection pooling.']);
assert.deepEqual(out.options_considered, ['Stay with SQLite.', 'Use CockroachDB.']);
assert.deepEqual(out.consequences_positive, ['Zero data loss.', '99.9% uptime maintained.', 'positive: improved throughput.']);
assert.deepEqual(out.consequences_negative, ['Migration downtime.', 'negative: higher operational cost.']);
assert.deepEqual(out.plan_sequence, ['Step 1: Set up Postgres.', 'Step 2: Migrate data.']);
assert.deepEqual(out.key_files, ['src/db/client.ts']);
assert.deepEqual(out.out_of_scope, ['Redis integration.']);
assert.deepEqual(out.deferred, ['Connection sharding.']);
assert.deepEqual(out.dependencies, ['ADR-0000']);
assert.equal(out.updates.length, 1);
assert.equal(out.updates[0].heading, 'Update');
assert.deepEqual(out.updates[0].entries, ['Changed connection pool size to 20.']);
// The H1 heading "ADR-0001: Switch to PostgreSQL" is treated as a section heading;
// it normalizes to a non-canonical string → goes into unmapped_headers.
assert.deepEqual(out.unmapped_headers, ['ADR-0001: Switch to PostgreSQL']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Targeted mutation-killing tests (T2 adapter seam)
// Each test is annotated with the mutant it kills.
// ─────────────────────────────────────────────────────────────────────────────
describe('targeted: pushUnique intra-values deduplication', () => {
// Kills: `seen.add(value)` removal mutant — without it, values-internal dups pass through.
test('duplicate entries within the same section body are deduplicated', () => {
const md = '## Decision\n- Same entry.\n- Same entry.\n- Different entry.';
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['Same entry.', 'Different entry.']);
});
});
describe('targeted: parseSections body-split round-trip', () => {
// Kills: body split/join mutations — each body line must be its own array element.
// The adapter does sec.body.split('\n'); parseAdrMarkdown does section.body.join('\n').
// A mutant replacing '\n' with ' ' in either call would break this.
test('multi-line goal body has each line preserved with internal newlines in prose', () => {
const md = '## Context\nLine one.\nLine two.\nLine three.';
const out = parseAdrMarkdown(md);
// prose = section.body.join('\n').trim() — must include all three lines separated by \n
assert.ok(out.context.includes('Line one.'), `context missing line one: ${out.context}`);
assert.ok(out.context.includes('Line two.'), `context missing line two: ${out.context}`);
assert.ok(out.context.includes('Line three.'), `context missing line three: ${out.context}`);
assert.ok(out.context.includes('\n'), 'context must retain internal newlines');
});
test('multi-line decision body produces one entry per non-blank line', () => {
// entries = splitEntries(section.body.join('\n')) — join must be '\n' not ' '
const md = '## Decision\n- Alpha.\n- Beta.\n- Gamma.';
const out = parseAdrMarkdown(md);
assert.deepEqual(out.decisions, ['Alpha.', 'Beta.', 'Gamma.']);
});
});
describe('targeted: parseStatusFromSections uses first entry only', () => {
// Kills: mutants that remove [0] indexing or change `splitEntries(...)[0]` to return all.
test('only the first non-blank line of the status body determines status', () => {
// Second line "rejected" must NOT influence the result.
const md = '# ADR\n\n## Status\naccepted\nrejected\n';
const out = parseAdrMarkdown(md);
assert.equal(out.status, 'accepted');
});
});
describe('targeted: classifyHeader exact-match vs prefix-match boundary', () => {
// Kills: mutants that remove the trailing space from startsWith check, or remove
// the equality check.
// Case 1: exact match — heading IS the synonym (no trailing content)
test('heading exactly equal to synonym matches (equality branch)', () => {
const out = parseAdrMarkdown('## Status\naccepted\n');
assert.equal(out.status, 'accepted');
});
// Case 2: prefix match — heading starts with synonym + space + more text
test('heading starting with synonym + space matches (prefix branch)', () => {
// "status of the adr" → starts with "status " → classified as status
const out = parseAdrMarkdown('## Status of the ADR\naccepted\n');
assert.equal(out.status, 'accepted');
});
// Case 3: heading IS synonym but no trailing space should NOT match via startsWith
// (it matches via equality instead) — this verifies the equality check fires
test('heading that exactly equals a synonym is classified without trailing space', () => {
// "context" equals the synonym exactly — must be classified as goal
const out = parseAdrMarkdown('## Context\nExact match context.');
assert.equal(out.context.trim(), 'Exact match context.');
});
// Case 4: heading with wrong suffix (synonym+letter, no space) must NOT match prefix
test('heading that is synonym + letter (no space) does NOT match prefix', () => {
// "statuses" → normalizes to "statuses", not "status " prefix — unclassified
const out = parseAdrMarkdown('## Statuses\naccepted\n');
assert.ok(out.unmapped_headers.includes('Statuses'));
// status should fall back to 'accepted' default (no status section found)
assert.equal(out.status, 'accepted');
});
});
describe('targeted: goal section prose vs entries distinction', () => {
// The goal/context case uses `prose` (joined + trimmed multi-line text), not `entries`
// (bullet-stripped list). Killing the `prose` variable or swapping it for `entries`
// would strip bullet markers from context text.
test('goal section body with bullet markers is preserved verbatim in context (prose, not entries)', () => {
// If parser used entries instead of prose, "- with a dash" would become "with a dash".
const md = '## Context\nThis is context.\n- with a dash item.\nMore prose.';
const out = parseAdrMarkdown(md);
assert.ok(out.context.includes('- with a dash item.'),
`context should preserve bullet markers in prose: ${out.context}`);
});
});
describe('targeted: normalizeAdrHeader non-word char removal', () => {
// Kills: regex mutation in the [^\w\s] replacement — e.g. inverting the class
// or changing the replacement target.
test('parentheses in heading are stripped by non-word removal', () => {
// "Context (v2)" normalizes to "context v2" — still matches "context" via prefix "context "
const out = parseAdrMarkdown('## Context (v2)\nSome context here.');
assert.equal(out.context.trim(), 'Some context here.');
});
test('non-word chars adjacent to word chars are stripped without inserting a space', () => {
// "Context/Background" → [^\w\s] removes '/' → "contextbackground" (no space)
// So it does NOT classify as goal (exact "contextbackground" ≠ any synonym).
const out = parseAdrMarkdown('## Context/Background\nSlash context.');
// Does not classify as goal — goes to unmapped_headers
assert.ok(out.unmapped_headers.includes('Context/Background'));
assert.equal(out.context, '');
});
});