Files
msd-core/tests/adr-parser.unit.test.cjs
Tom Boucher df04aae5e4 enhancement(#537): migrate all hand-written bin/lib/*.cjs to TypeScript source of truth (ADR-457) (#602)
* enhancement(#537): migrate code-review-flags to TS source of truth

Collapse the hand-written get-shit-done/bin/lib/code-review-flags.cjs to a
TypeScript source of truth (src/code-review-flags.cts), compiled by tsc to a
gitignored .cjs build artifact at the same path, per ADR-457 (build-at-publish).
Second module after the semver-compare pilot (#541).

Behaviour is preserved byte-for-behaviour (characterization test added in
tests/code-review-flags.test.cjs locks the parser quirks). Adds compile-time
type checking: CodeReviewFlags interface + CodeReviewWorkflow literal union.
The require() path is unchanged, so code-review.md and the bug-3727 test keep
working. The emitted .cjs is gitignored and eslint-ignored, mirroring the pilot.

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

* enhancement(#537): migrate 9 leaf bin/lib modules to TS source of truth

ADR-457 build-at-publish, batch 1 (pure leaf modules, 0 sibling-deps):
001-legacy-orphan-files, context-utilization, redaction, artifacts,
command-arg-projection, clock, ui-safety-gate, review-reviewer-selection,
clusters. Each moves to src/*.cts (strict TS, typed), compiled by tsc to a
gitignored .cjs at the same require() path; behaviour preserved byte-for-
behaviour. Adds src/node-globals.d.ts (minimal ambient shim; "types":[]).

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

* chore(#537): add @types/node, drop hand-rolled node-globals shim

ADR-457 migration infra: replace the temporary src/node-globals.d.ts ambient
shim with @types/node@22 + "types":["node"] in tsconfig.build.json. Unblocks
migrating the ~49 remaining bin/lib modules that use node:fs/path/os/
child_process. Build + full suite (3030 pass) + lint all green; no .cts type
changes were needed (real Node types matched the shim).

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

* enhancement(#537): migrate 9 more bin/lib modules to TS (batch 2)

ADR-457 build-at-publish. Clean leaves: installer-migration-report,
prompt-budget. Type-error-prone leaves (were tsconfig.lint-excluded; now
strict-typed and removed from that exclude list): secrets, phase-lifecycle,
workstream-name-policy, decisions, validate, schema-detect. Plus
runtime-name-policy. Strict type fixes narrow unknown->concrete domain types
(no any/ts-ignore); behaviour preserved. Full suite green, lint 0 errors.

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

* enhancement(#537): migrate runtime-slash to TS (cross-import proof)

ADR-457. First cross-module TS->TS import: src/runtime-slash.cts imports
./runtime-name-policy.cjs and tsc resolves the sibling .cts types under strict
(no declaration files; NodeNext .cjs->.cts mapping), emitting a correct
require("./runtime-name-policy.cjs"). Confirms the recipe for coupled modules,
which must be migrated in dependency order (leaves-up). Suite green, lint clean.

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

* enhancement(#537): migrate 10 more bin/lib modules to TS (batch 3)

ADR-457 build-at-publish, Wave-1 leaves: event, workstream-inventory-builder,
plan-scan, fallow-runner, project-root, installer-migration-authoring,
update-context, 000-first-time-baseline, runtime-homes, model-catalog. Strict
typing fixed real issues (narrowing unknown, qualified fs/path calls, removed
unnecessary casts); plan-scan/project-root/workstream-inventory-builder dropped
from tsconfig.lint exclude. Behaviour preserved; suite green, lint 0 errors.

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

* enhancement(#537): migrate 5 large Wave-1 leaves to TS (batch 4)

ADR-457 build-at-publish: configuration, state-document, shell-command-
projection (42 dependents), security, command-aliases. shell-command-
projection keeps a namespace child_process import for mock-intercept
testability. loadConfig/migrateOnDisk emit synchronously (every caller uses
them sync; the one awaited migrateOnDisk caller tolerates a non-Promise) —
full suite (3030 pass) confirms behaviour preserved. configuration/
state-document/command-aliases dropped from tsconfig.lint exclude. Also fixes
the malformed batch-3 changeset frontmatter (type/pr) that failed lint:docs.

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

* enhancement(#537): migrate 6 Wave-2 modules to TS (batch 5)

ADR-457 build-at-publish: config-schema, model-profiles,
002-codex-legacy-hooks-json, logger, active-workstream-store, adr-parser.
First batch importing already-migrated siblings (configuration, model-catalog,
shell-command-projection, redaction, security) via ./sibling.cjs specifiers.
Strict type narrowing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors.

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

* enhancement(#537): migrate 5 large Wave-2 modules to TS (batch 6)

ADR-457 build-at-publish: graphify, install-profiles, intel,
installer-migrations, worktree-safety. installer-migrations preserves its
dynamic require() loader for numbered migration modules (scoped lint
suppressions). Strict typing (typeof guards over String(unknown)); behaviour
preserved; suite 3030 pass, lint 0 errors. Wave 2 complete.

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

* enhancement(#537): migrate Wave-3 modules to TS (batch 7)

ADR-457 build-at-publish: planning-workspace, runtime-artifact-layout,
command-routing-hub, drift. Uses `import x = require()` for export= siblings;
drift's lazy require of runtime-slash hoisted to a top-level import (verified
non-circular). Behaviour preserved; suite 3030 pass, lint 0 errors.

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

* enhancement(#537): migrate small Wave-4 modules to TS (batch 8)

ADR-457 build-at-publish: cjs-command-router-adapter, phase-command-router,
surface, roadmap-upgrade. Typed the hub router handler results as the HubResult
discriminated union; surface drops 4 genuinely-unused imports. Behaviour
preserved; suite 3030 pass, lint 0 errors.

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

* enhancement(#537): migrate core hub (2.5k LOC, 68 dependents) to TS (batch 9)

ADR-457 build-at-publish: get-shit-done/bin/lib/core.cjs -> src/core.cts,
preserving all 63 exports via export=. All sibling deps already migrated
(shell-command-projection, model-profiles, model-catalog, worktree-safety,
planning-workspace, project-root, configuration, config-schema). Strict types,
no any/ts-ignore; config-schema lazy require hoisted (non-circular). Behaviour
preserved (independently verified: core's shard 3030 pass / 0 fail).

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

* test(#537): make ESLint-coverage + test-sprawl checks migration-aware

#551 test hardcoded 12 now-migrated modules as "hand-written, must be linted";
that invariant is obsoleted by the ADR-457 migration. Rewrite it to a
filesystem-driven invariant that holds at every stage: a bin/lib/*.cjs must be
eslint-ignored IFF it has a src/*.cts source (tsc-generated), else linted
(covers package-identity, which has no TS source). Also eslint-ignore
config-types.cjs (has a src counterpart) and drop the redundant
tests/clock.test.cjs (clock already covered by clock-seam + bug-474 tests),
which tripped the lint-test-file-count ratchet.

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

* enhancement(#537): migrate 9 Wave-5 router/inventory modules to TS (batch 10)

ADR-457 build-at-publish: phases/verify/init/agent/task/validate/roadmap/state
command routers + workstream-inventory. Router handler results typed against
core's exported shapes; behaviour preserved (caught+fixed a --verify boolean
flag regression mid-migration). Full suite green across all shards (only the 4
local gpg-env changeset-notes failures remain; CI passes them).

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

* enhancement(#537): migrate 7 Wave-5 modules to TS (batch 11)

ADR-457 build-at-publish: gap-checker, docs, check-command-router, frontmatter,
learnings, gsd2-import, profile-pipeline. Behaviour preserved; full suite green
across all shards (only the 4 local gpg-env failures remain). Also broadens
atomic-write-coverage.test.cjs to accept the tsc-compiled namespace-import form
while still asserting platformWriteSync is called (safety guard intact).

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

* enhancement(#537): migrate config + profile-output to TS (batch 12)

ADR-457 build-at-publish: config (729 LOC), profile-output (1142 LOC). All
exports preserved; cmdMigrateConfig de-asynced (migrateOnDisk is sync, awaited
caller tolerates it). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures). Wave 5 complete.

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

* enhancement(#537): migrate 5 Wave-6 modules to TS (batch 13)

ADR-457 build-at-publish: template, uat, workstream, roadmap, audit. Behaviour
preserved (dead toPosixPath import dropped from audit; inline requires hoisted).
Suite green across all shards (only the 4 local gpg-env failures).

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

* enhancement(#537): migrate commands + state hubs to TS (batch 14)

ADR-457 build-at-publish: commands (1305 LOC), state (2074 LOC, 17 dependents).
All exports preserved; inner requires kept non-hoisted where load-order matters
(install.js, per-call security); acquireStateLock cast inlined to preserve the
err.code source token a structural test inspects. Behaviour preserved; suite
green across all shards (only the 4 local gpg-env failures). Wave 6 complete.

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

* enhancement(#537): migrate milestone to TS (batch 15a, hand-authored)

ADR-457 build-at-publish: milestone -> src/milestone.cts. Authored directly
(subagent capacity was unavailable). Also relaxes core.output()'s 3rd param to
optional, matching its real always-optional call contract (unblocks remaining
2-arg output callers). Behaviour preserved; suite green across all shards
(only the 4 local gpg-env failures).

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

* enhancement(#537): migrate phase, verify, init to TS (batch 15, final modules)

ADR-457 build-at-publish, Wave 7 (the last hubs): phase (1608 LOC), verify
(1615), init (2113). Adds src/package-identity.d.cts so verify can import the
permanently value-baked package-identity.cjs under strict TS.

Fixes two regressions the migration introduced in verify: restore
cmdValidateHealth's `return result` (callers/tests read result.warnings — it is
NOT side-effect-only), and make the bug-3384 source-pattern test tolerant of the
tsc-compiled bracket-notation form of the git_list_failed->W020 branch (behaviour
intact). Full suite green across all shards (only the 4 local gpg-env failures);
lint 0 errors. All 86 migratable bin/lib modules are now TypeScript sources.

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

* chore(#537): finalize ADR-457 migration — retire tsconfig.lint.json

All hand-written bin/lib/*.cjs are now src/*.cts sources, so the checkJs
stopgap tsconfig.lint.json (unused; not wired into eslint, scripts, or CI) is
deleted per ADR-457's final step. Also gitignore the tsc-generated
config-types.cjs (was still committed) for consistency with every other
emitted artifact. package-identity.cjs stays value-baked (declared via
src/package-identity.d.cts). Suite green; #551 ESLint-coverage test green.

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

* fix(#537): add prepare script so unpacked/git installs build bin/lib artifacts

ADR-457 build-at-publish: bin/lib/*.cjs are now gitignored, built by tsc. The
prepack/prepublishOnly hooks cover `npm pack`/publish, but `npm install -g
<dir>` and git installs run the `prepare` lifecycle — which was missing — so the
unpacked install shipped without the compiled .cjs and failed at startup with
"Cannot find module './lib/core.cjs'" (caught by the smoke-unpacked CI job).
Add `prepare` mirroring prepublishOnly (build:lib + build:hooks). prepare does
NOT run for registry consumers (they get the pre-built tarball), only for
source/local/pack installs.

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

* fix(#537): make CI build/lockfile checks work with gitignored bin/lib artifacts

ADR-457 build-at-publish exposed two CI assumptions that bin/lib/*.cjs are
always present on disk:
- check:env's lockfile-sync ran `npm ci --dry-run`, which now triggers the
  `prepare` build (tsc) — but it runs before deps are installed, so tsc is
  absent and it misreported the lockfile as out of sync. Add --ignore-scripts
  (a lockfile check must not build).
- the lint-tests job installs with --ignore-scripts (no prepare build), but
  lint:skill-deps require()s the built install-profiles.cjs. Add an explicit
  `npm run build:lib` step after install.

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

* fix(#537): narrow prepare to build:lib only (unbreak packed-smoke pack step)

prepare running build:hooks emitted "✓ Copying ..." stdout during `npm pack`,
which the install-smoke "Pack root tarball" step captures into $GITHUB_OUTPUT —
breaking it with "Invalid format". build:lib (tsc) is silent on success and is
all the unpacked/source install needs (the smoke-unpacked assertions exercise
gsd-tools, i.e. bin/lib, and tolerate hook setup with `|| true`). Matches
prepack. build:hooks still runs on prepublishOnly for real publishes.

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

* fix(#537): wire Stryker mutation gate to build-at-publish layout

The gate scored 0.00 because it mutated changed bin/lib/*.cjs that (a) were
generated artifacts and (b) included modules with no coverage in the command's
test set. Rework: mutation.yml now derives changed COVERED modules from
src/*.cts and maps them to their built bin/lib/*.cjs; Stryker mutates those
built artifacts with a no-rebuild command (mutating src/*.cts + per-mutant tsc
was ~3x over the 30-min CI budget).

NOTE: with the gate now correctly measuring the covered modules, their actual
mutation score is 42.94% (< break 50) — a pre-existing test-coverage gap
(adr-parser/prompt-budget/etc.), not introduced by this behaviour-preserving
migration. Reaching 50 needs more tests, a threshold/scope change, or a waiver —
a maintainer decision.

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

* test(#537): raise mutation coverage of covered modules above the 50 gate

Adds focused example-based unit tests that kill surviving mutants in the two
lowest-scoring covered modules:
- tests/prompt-budget.unit.test.cjs (112 tests): 17.9% -> 97.9%
- tests/adr-parser.unit.test.cjs (205 tests): 44.7% -> 89.4%
Both wired into stryker.config.mjs's command. Fresh full run over the 6 covered
modules now scores 82.25% (>= break 50); every covered module is >= 68%.

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

* enhancement(#537,#609): parallelize mutation gate via dynamic per-module matrix

The serial Stryker run timed out at 30 min once the migration's added tests
made every mutant re-run ~300 tests. Replace it with a dynamic matrix so the
gate completes well under budget — folded into this PR (was tracked as #609)
because it's a prerequisite for this PR's mutation gate to pass.

- scripts/mutation-matrix.cjs: single source of truth (covered-module -> test
  files) computing changed covered modules from git diff -> {has_work, matrix}.
- mutation.yml: detect -> dynamic `matrix: fromJSON(...)` mutate job (one
  parallel shard per changed module, scoped via MUTATION_TEST_CMD to only that
  module's tests, 15-min/shard) -> summary job that KEEPS the legacy check name
  "Stryker mutation score (changed files only)" so branch protection is
  unchanged. Per-shard jobs report as "Stryker (<module>)".
- stryker.config.mjs: commandRunner.command reads MUTATION_TEST_CMD (falls back
  to the full command locally).

Closes #609.

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

* test(#537,#609): give each mutation shard ≥50% on its own tests; drop blacksmith note

Per-module sharding revealed that active-workstream-store (46.5%) and
frontmatter (7.4%) only cleared 50% in the old serial run via timeout-noise from
the bloated 300-test command; on their own tests they were below the gate. Add
focused unit tests:
- tests/active-workstream-store.unit.test.cjs (115 tests): 46.5% -> 81.9%
- tests/frontmatter.unit.test.cjs (165 tests): 7.4% -> 63.4%
Both wired into scripts/mutation-matrix.cjs (per-module test map) and
stryker.config.mjs DEFAULT_TEST_CMD. All 6 covered modules now clear break:50
with only their own tests (config-schema/context-utilization/prompt-budget/
adr-parser already did). Also removes the leftover blacksmith TODO comment —
GitHub-hosted runners only; speed comes from parallel per-module shards.

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

* test(#537,#609): strengthen prompt-budget tests to clear the gate on its own tests

prompt-budget scored 39.58% when mutation-tested with ONLY its own tests (the
way the per-module CI shard runs it) — an earlier ~98% reading was inflated by
accidentally running the full multi-module command. Add 96 targeted tests to
tests/prompt-budget.unit.test.cjs (exact note-template text, plan-truncation
arithmetic/percentages, drop-block strings, noteInjected/hardFailed booleans):
scoped score 39.58% -> 68.75% (>= break 50). All 6 covered modules now clear
the gate on their own tests.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:45:01 -04:00

1404 lines
62 KiB
JavaScript

'use strict';
/**
* Example-based unit tests for adr-parser.cjs
*
* Target: get-shit-done/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('../get-shit-done/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']);
});
});