Files
msd-core/tests/adr-parser.unit.test.cjs
Tom Boucher 463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00

1404 lines
62 KiB
JavaScript

'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" heading normalized to "trade offs" does NOT match synonym "trade-offs" (unreachable synonym)', () => {
const out = parseAdrMarkdown('## Trade-offs\n- Increased latency.');
assert.deepEqual(out.consequences_negative, []);
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" normalized to "how well know" does NOT match synonym "how we\'ll know" (unreachable synonym)', () => {
// The apostrophe in "we'll" is stripped by normalizeAdrHeader, yielding "how well know".
// The synonym "how we'll know" is stored with apostrophe — can't match.
const out = parseAdrMarkdown("## How We'll Know\n- Sales increase.");
assert.deepEqual(out.consequences_positive, []);
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" heading normalized to "non goals" does NOT match synonym "non-goals" (unreachable synonym)', () => {
// "Non-goals" normalizes to "non goals"; CANONICAL_HEADERS stores "non-goals" (with hyphen).
// classifyHeader does exact equality — these can't match, so it goes to unmapped_headers.
const out = parseAdrMarkdown('## Non-goals\n- Not this.');
assert.deepEqual(out.out_of_scope, []);
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" heading normalized to "anti goals" does NOT match synonym "anti-goals" (unreachable synonym)', () => {
const out = parseAdrMarkdown('## Anti-goals\n- Gold plating.');
assert.deepEqual(out.out_of_scope, []);
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" heading normalized to "follow up" does NOT match synonym "follow-up" (unreachable synonym)', () => {
// Synonym "follow-up" has a hyphen which normalizeAdrHeader converts to a space.
// Since classifyHeader does exact string comparison with raw synonyms, this can't match.
const out = parseAdrMarkdown('## Follow-up\n- Monitor metrics.');
assert.deepEqual(out.deferred, []);
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" heading normalized to "cross cuts" does NOT match synonym "cross-cuts" (unreachable synonym)', () => {
const out = parseAdrMarkdown('## Cross-cuts\n- Security layer.');
assert.deepEqual(out.dependencies, []);
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" heading normalized to "post grilling" does NOT match synonym "post-grilling" (unreachable synonym)', () => {
const out = parseAdrMarkdown('## Post-grilling\n- Revised after review.');
assert.equal(out.updates.length, 0);
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 — 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.']);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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']);
});
});