* fix: make punctuated CANONICAL_HEADERS synonyms reachable (audit M7)
classifyHeader received an already-normalized header (via normalizeAdrHeader,
which collapses [\s:._-]+ to a space and strips [^\w\s]) but compared it against
the RAW synonym strings. So any synonym carrying a hyphen/apostrophe ('trade-offs',
'non-goals', 'anti-goals', 'follow-up', 'cross-cuts', 'post-grilling', "how we'll
know", "won't do/have") could never match — its ADR section silently went unmapped.
Nine synonyms across six buckets were dead; the repo had characterization tests
pinning that quirk ('unreachable synonym').
Root-cause fix (per ADR-1372's 'compound, don't accrete' guidance): normalize BOTH
sides via a module-load-precomputed index, instead of pre-baking 9 normalized
literals into the data table. Closes the abstraction asymmetry once for all current
and future synonyms; the table stays human-readable. Also de-dupes 'trade-offs' from
considered_options so it no longer shadows risks once both normalize to 'trade offs'.
Insertion order preserved → first-match-wins + exact-then-prefix precedence byte-
identical for already-normalized synonyms; only the 9 dead synonyms gain matching.
Updates the 7 characterization tests to the corrected behavior and adds a
reachability+no-collision invariant test guarding the whole class against regression.
Note: the audit's M7 premise (trade-offs *misclassified into considered_options*)
was factually wrong — it was unmapped, and tested as such. adr-parser is CLI-only
(ADR-1372 T2), so the behavior change cannot reach in-process gates.
Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
* chore(changeset): Fixed fragment for #1536 (adr-parser punctuated synonyms)
Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
---------
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1582 lines
73 KiB
JavaScript
1582 lines
73 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" maps to consequences_negative (M7: both sides normalized, synonym now reachable)', () => {
|
|
const out = parseAdrMarkdown('## Trade-offs\n- Increased latency.');
|
|
assert.deepEqual(out.consequences_negative, ['Increased latency.']);
|
|
assert.ok(!out.unmapped_headers.includes('Trade-offs'));
|
|
});
|
|
|
|
test('"Drawbacks" maps to consequences_negative', () => {
|
|
const out = parseAdrMarkdown('## Drawbacks\n- Higher cost.');
|
|
assert.deepEqual(out.consequences_negative, ['Higher cost.']);
|
|
});
|
|
|
|
test('"Cost" maps to consequences_negative', () => {
|
|
const out = parseAdrMarkdown('## Cost\n- Time investment.');
|
|
assert.deepEqual(out.consequences_negative, ['Time investment.']);
|
|
});
|
|
|
|
test('"Tensions" maps to consequences_negative', () => {
|
|
const out = parseAdrMarkdown('## Tensions\n- Team tension.');
|
|
assert.deepEqual(out.consequences_negative, ['Team tension.']);
|
|
});
|
|
|
|
test('"Liabilities" maps to consequences_negative', () => {
|
|
const out = parseAdrMarkdown('## Liabilities\n- Vendor lock-in.');
|
|
assert.deepEqual(out.consequences_negative, ['Vendor lock-in.']);
|
|
});
|
|
|
|
test('"Negative Consequences" maps to consequences_negative', () => {
|
|
const out = parseAdrMarkdown('## Negative Consequences\n- Debt.');
|
|
assert.deepEqual(out.consequences_negative, ['Debt.']);
|
|
});
|
|
|
|
test('"Side Effects" maps to consequences_negative', () => {
|
|
const out = parseAdrMarkdown('## Side Effects\n- Performance hit.');
|
|
assert.deepEqual(out.consequences_negative, ['Performance hit.']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — success_criteria section → consequences_positive
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: success_criteria section', () => {
|
|
test('"Success Criteria" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Success Criteria\n- 99.9% uptime.');
|
|
assert.deepEqual(out.consequences_positive, ['99.9% uptime.']);
|
|
assert.deepEqual(out.consequences_negative, []);
|
|
});
|
|
|
|
test('"Acceptance Criteria" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Acceptance Criteria\n- Tests pass.');
|
|
assert.deepEqual(out.consequences_positive, ['Tests pass.']);
|
|
});
|
|
|
|
test('"Validation" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Validation\n- Manual testing done.');
|
|
assert.deepEqual(out.consequences_positive, ['Manual testing done.']);
|
|
});
|
|
|
|
test('"Metrics" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Metrics\n- Latency < 100ms.');
|
|
assert.deepEqual(out.consequences_positive, ['Latency < 100ms.']);
|
|
});
|
|
|
|
test('"KPIs" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## KPIs\n- Revenue up 10%.');
|
|
assert.deepEqual(out.consequences_positive, ['Revenue up 10%.']);
|
|
});
|
|
|
|
test('"Verification" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Verification\n- CI green.');
|
|
assert.deepEqual(out.consequences_positive, ['CI green.']);
|
|
});
|
|
|
|
test('"Test Strategy" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Test Strategy\n- Unit + integration.');
|
|
assert.deepEqual(out.consequences_positive, ['Unit + integration.']);
|
|
});
|
|
|
|
test('"Definition of Done" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Definition of Done\n- Merged and deployed.');
|
|
assert.deepEqual(out.consequences_positive, ['Merged and deployed.']);
|
|
});
|
|
|
|
test('"Exit Criteria" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Exit Criteria\n- No open P0 bugs.');
|
|
assert.deepEqual(out.consequences_positive, ['No open P0 bugs.']);
|
|
});
|
|
|
|
test('"Positive Consequences" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Positive Consequences\n- Better DX.');
|
|
assert.deepEqual(out.consequences_positive, ['Better DX.']);
|
|
});
|
|
|
|
test('"How We\'ll Know" maps to consequences_positive (M7: synonym normalized on both sides, now reachable)', () => {
|
|
// The apostrophe in "we'll" is stripped by normalizeAdrHeader on BOTH the header and the
|
|
// synonym, so both yield "how well know" and now match (success_criteria → consequences_positive).
|
|
const out = parseAdrMarkdown("## How We'll Know\n- Sales increase.");
|
|
assert.deepEqual(out.consequences_positive, ['Sales increase.']);
|
|
assert.ok(!out.unmapped_headers.includes("How We'll Know"));
|
|
});
|
|
|
|
test('"Compliance" maps to consequences_positive', () => {
|
|
const out = parseAdrMarkdown('## Compliance\n- SOC2 passed.');
|
|
assert.deepEqual(out.consequences_positive, ['SOC2 passed.']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseConsequences — hint-based triage (via Consequences heading)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseConsequences (via Consequences section)', () => {
|
|
function makeConsequencesMd(entries) {
|
|
return `# ADR\n\n## Consequences\n${entries.map((e) => `- ${e}`).join('\n')}\n`;
|
|
}
|
|
|
|
test('entry containing "negative" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['negative: cost increases']));
|
|
assert.deepEqual(out.consequences_negative, ['negative: cost increases']);
|
|
assert.deepEqual(out.consequences_positive, []);
|
|
});
|
|
|
|
test('entry containing "drawback" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['One drawback: overhead']));
|
|
assert.deepEqual(out.consequences_negative, ['One drawback: overhead']);
|
|
});
|
|
|
|
test('entry containing "risk" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['risk of data loss']));
|
|
assert.deepEqual(out.consequences_negative, ['risk of data loss']);
|
|
});
|
|
|
|
test('entry containing "cost" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['high cost to maintain']));
|
|
assert.deepEqual(out.consequences_negative, ['high cost to maintain']);
|
|
});
|
|
|
|
test('entry containing "liability" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['legal liability risk']));
|
|
assert.deepEqual(out.consequences_negative, ['legal liability risk']);
|
|
});
|
|
|
|
test('entry containing "trade-off" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['this trade-off is worth it']));
|
|
assert.deepEqual(out.consequences_negative, ['this trade-off is worth it']);
|
|
});
|
|
|
|
test('entry containing "tension" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['team tension exists']));
|
|
assert.deepEqual(out.consequences_negative, ['team tension exists']);
|
|
});
|
|
|
|
test('entry containing "side effect" → consequences_negative', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['side effect: memory growth']));
|
|
assert.deepEqual(out.consequences_negative, ['side effect: memory growth']);
|
|
});
|
|
|
|
test('entry containing "positive" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['positive: faster deploys']));
|
|
assert.deepEqual(out.consequences_positive, ['positive: faster deploys']);
|
|
assert.deepEqual(out.consequences_negative, []);
|
|
});
|
|
|
|
test('entry containing "success" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['success rate improves']));
|
|
assert.deepEqual(out.consequences_positive, ['success rate improves']);
|
|
});
|
|
|
|
test('entry containing "metric" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['metric: latency < 100ms']));
|
|
assert.deepEqual(out.consequences_positive, ['metric: latency < 100ms']);
|
|
});
|
|
|
|
test('entry containing "kpi" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['kpi tracked monthly']));
|
|
assert.deepEqual(out.consequences_positive, ['kpi tracked monthly']);
|
|
});
|
|
|
|
test('entry containing "verification" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['verification: run test suite']));
|
|
assert.deepEqual(out.consequences_positive, ['verification: run test suite']);
|
|
});
|
|
|
|
test('entry containing "acceptance" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['acceptance tests pass']));
|
|
assert.deepEqual(out.consequences_positive, ['acceptance tests pass']);
|
|
});
|
|
|
|
test('entry containing "benefit" → consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['benefit: faster CI']));
|
|
assert.deepEqual(out.consequences_positive, ['benefit: faster CI']);
|
|
});
|
|
|
|
test('entry with no hint → fallback to consequences_positive', () => {
|
|
const out = parseAdrMarkdown(makeConsequencesMd(['General observation.']));
|
|
assert.deepEqual(out.consequences_positive, ['General observation.']);
|
|
assert.deepEqual(out.consequences_negative, []);
|
|
});
|
|
|
|
test('multiple entries each triaged independently', () => {
|
|
const entries = [
|
|
'negative: first bad thing',
|
|
'positive: first good thing',
|
|
'no hint here',
|
|
'drawback: another bad thing',
|
|
'benefit: another good thing',
|
|
];
|
|
const out = parseAdrMarkdown(makeConsequencesMd(entries));
|
|
assert.deepEqual(out.consequences_negative, [
|
|
'negative: first bad thing',
|
|
'drawback: another bad thing',
|
|
]);
|
|
assert.deepEqual(out.consequences_positive, [
|
|
'positive: first good thing',
|
|
'no hint here',
|
|
'benefit: another good thing',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — plan_sequence section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: plan_sequence section', () => {
|
|
test('"Implementation Plan" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Implementation Plan\n- Step 1.');
|
|
assert.deepEqual(out.plan_sequence, ['Step 1.']);
|
|
});
|
|
|
|
test('"Implementation Notes" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Implementation Notes\n- Note 1.');
|
|
assert.deepEqual(out.plan_sequence, ['Note 1.']);
|
|
});
|
|
|
|
test('"Steps" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Steps\n- Do A.\n- Do B.');
|
|
assert.deepEqual(out.plan_sequence, ['Do A.', 'Do B.']);
|
|
});
|
|
|
|
test('"Tasks" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Tasks\n- Task 1.');
|
|
assert.deepEqual(out.plan_sequence, ['Task 1.']);
|
|
});
|
|
|
|
test('"Roadmap" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Roadmap\n- Q1: alpha.');
|
|
assert.deepEqual(out.plan_sequence, ['Q1: alpha.']);
|
|
});
|
|
|
|
test('"Sequence" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Sequence\n- Phase 1.');
|
|
assert.deepEqual(out.plan_sequence, ['Phase 1.']);
|
|
});
|
|
|
|
test('"Migration Plan" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Migration Plan\n- Migrate DB first.');
|
|
assert.deepEqual(out.plan_sequence, ['Migrate DB first.']);
|
|
});
|
|
|
|
test('"Plan" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Plan\n- Create ticket.');
|
|
assert.deepEqual(out.plan_sequence, ['Create ticket.']);
|
|
});
|
|
|
|
test('"Action Items" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Action Items\n- Fix bug.');
|
|
assert.deepEqual(out.plan_sequence, ['Fix bug.']);
|
|
});
|
|
|
|
test('"Work Breakdown" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Work Breakdown\n- Backend sprint.');
|
|
assert.deepEqual(out.plan_sequence, ['Backend sprint.']);
|
|
});
|
|
|
|
test('"Phases" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Phases\n- Phase A.');
|
|
assert.deepEqual(out.plan_sequence, ['Phase A.']);
|
|
});
|
|
|
|
test('"Milestones" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Milestones\n- v1.0 release.');
|
|
assert.deepEqual(out.plan_sequence, ['v1.0 release.']);
|
|
});
|
|
|
|
test('"Stages" maps to plan_sequence', () => {
|
|
const out = parseAdrMarkdown('## Stages\n- Stage 1: prototype.');
|
|
assert.deepEqual(out.plan_sequence, ['Stage 1: prototype.']);
|
|
});
|
|
|
|
test('plan_sequence is empty when no section', () => {
|
|
const out = parseAdrMarkdown('# ADR\n');
|
|
assert.deepEqual(out.plan_sequence, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — key_files section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: key_files section', () => {
|
|
test('"Affected Files" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Affected Files\n- src/index.ts');
|
|
assert.deepEqual(out.key_files, ['src/index.ts']);
|
|
});
|
|
|
|
test('"Files Touched" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Files Touched\n- lib/core.js');
|
|
assert.deepEqual(out.key_files, ['lib/core.js']);
|
|
});
|
|
|
|
test('"Surface Area" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Surface Area\n- api/routes.ts');
|
|
assert.deepEqual(out.key_files, ['api/routes.ts']);
|
|
});
|
|
|
|
test('"Modules Affected" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Modules Affected\n- auth module');
|
|
assert.deepEqual(out.key_files, ['auth module']);
|
|
});
|
|
|
|
test('"Code Locations" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Code Locations\n- src/parser.ts');
|
|
assert.deepEqual(out.key_files, ['src/parser.ts']);
|
|
});
|
|
|
|
test('"File Changes" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## File Changes\n- config.json');
|
|
assert.deepEqual(out.key_files, ['config.json']);
|
|
});
|
|
|
|
test('"Diff Summary" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Diff Summary\n- +50 -10 lines');
|
|
assert.deepEqual(out.key_files, ['+50 -10 lines']);
|
|
});
|
|
|
|
test('"Touched Code" maps to key_files', () => {
|
|
const out = parseAdrMarkdown('## Touched Code\n- helpers.ts');
|
|
assert.deepEqual(out.key_files, ['helpers.ts']);
|
|
});
|
|
|
|
test('key_files is empty when no section', () => {
|
|
const out = parseAdrMarkdown('# ADR\n');
|
|
assert.deepEqual(out.key_files, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — out_of_scope section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: out_of_scope section', () => {
|
|
test('"Non-goals" maps to out_of_scope (M7: both sides normalized to "non goals", now reachable)', () => {
|
|
const out = parseAdrMarkdown('## Non-goals\n- Not this.');
|
|
assert.deepEqual(out.out_of_scope, ['Not this.']);
|
|
assert.ok(!out.unmapped_headers.includes('Non-goals'));
|
|
});
|
|
|
|
test('"Excluded" maps to out_of_scope', () => {
|
|
const out = parseAdrMarkdown('## Excluded\n- Feature X.');
|
|
assert.deepEqual(out.out_of_scope, ['Feature X.']);
|
|
});
|
|
|
|
test('"Not in this ADR" maps to out_of_scope', () => {
|
|
const out = parseAdrMarkdown('## Not in this ADR\n- Remote ingest.');
|
|
assert.deepEqual(out.out_of_scope, ['Remote ingest.']);
|
|
});
|
|
|
|
test('"Out of Bounds" maps to out_of_scope', () => {
|
|
const out = parseAdrMarkdown('## Out of Bounds\n- Infrastructure.');
|
|
assert.deepEqual(out.out_of_scope, ['Infrastructure.']);
|
|
});
|
|
|
|
test('"Beyond Scope" maps to out_of_scope', () => {
|
|
const out = parseAdrMarkdown('## Beyond Scope\n- Billing system.');
|
|
assert.deepEqual(out.out_of_scope, ['Billing system.']);
|
|
});
|
|
|
|
test('"Anti-goals" maps to out_of_scope (M7: both sides normalized to "anti goals", now reachable)', () => {
|
|
const out = parseAdrMarkdown('## Anti-goals\n- Gold plating.');
|
|
assert.deepEqual(out.out_of_scope, ['Gold plating.']);
|
|
assert.ok(!out.unmapped_headers.includes('Anti-goals'));
|
|
});
|
|
|
|
test('out_of_scope is empty when no section', () => {
|
|
const out = parseAdrMarkdown('# ADR\n');
|
|
assert.deepEqual(out.out_of_scope, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — deferred section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: deferred section', () => {
|
|
test('"Deferred" maps to deferred', () => {
|
|
const out = parseAdrMarkdown('## Deferred\n- Caching layer.');
|
|
assert.deepEqual(out.deferred, ['Caching layer.']);
|
|
});
|
|
|
|
test('"Future" maps to deferred', () => {
|
|
const out = parseAdrMarkdown('## Future\n- API v2.');
|
|
assert.deepEqual(out.deferred, ['API v2.']);
|
|
});
|
|
|
|
test('"Later" maps to deferred', () => {
|
|
const out = parseAdrMarkdown('## Later\n- Optimize later.');
|
|
assert.deepEqual(out.deferred, ['Optimize later.']);
|
|
});
|
|
|
|
test('"Follow-up" maps to deferred (M7: both sides normalized to "follow up", now reachable)', () => {
|
|
const out = parseAdrMarkdown('## Follow-up\n- Monitor metrics.');
|
|
assert.deepEqual(out.deferred, ['Monitor metrics.']);
|
|
assert.ok(!out.unmapped_headers.includes('Follow-up'));
|
|
});
|
|
|
|
test('"Next Steps" maps to deferred', () => {
|
|
const out = parseAdrMarkdown('## Next Steps\n- Schedule review.');
|
|
assert.deepEqual(out.deferred, ['Schedule review.']);
|
|
});
|
|
|
|
test('deferred is empty when no section', () => {
|
|
const out = parseAdrMarkdown('# ADR\n');
|
|
assert.deepEqual(out.deferred, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — dependencies section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: dependencies section', () => {
|
|
test('"Depends On" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Depends On\n- ADR-0001');
|
|
assert.deepEqual(out.dependencies, ['ADR-0001']);
|
|
});
|
|
|
|
test('"Prerequisites" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Prerequisites\n- Node.js 18');
|
|
assert.deepEqual(out.dependencies, ['Node.js 18']);
|
|
});
|
|
|
|
test('"Sequencing" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Sequencing\n- Must follow ADR-003.');
|
|
assert.deepEqual(out.dependencies, ['Must follow ADR-003.']);
|
|
});
|
|
|
|
test('"Order" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Order\n- ADR-002 first.');
|
|
assert.deepEqual(out.dependencies, ['ADR-002 first.']);
|
|
});
|
|
|
|
test('"Blocked By" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Blocked By\n- Team capacity.');
|
|
assert.deepEqual(out.dependencies, ['Team capacity.']);
|
|
});
|
|
|
|
test('"Cross-cuts" maps to dependencies (M7: both sides normalized to "cross cuts", now reachable)', () => {
|
|
const out = parseAdrMarkdown('## Cross-cuts\n- Security layer.');
|
|
assert.deepEqual(out.dependencies, ['Security layer.']);
|
|
assert.ok(!out.unmapped_headers.includes('Cross-cuts'));
|
|
});
|
|
|
|
test('"Related ADRs" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Related ADRs\n- ADR-0003');
|
|
assert.deepEqual(out.dependencies, ['ADR-0003']);
|
|
});
|
|
|
|
test('"Links" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Links\n- https://example.com');
|
|
assert.deepEqual(out.dependencies, ['https://example.com']);
|
|
});
|
|
|
|
test('"References" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## References\n- RFC 9110');
|
|
assert.deepEqual(out.dependencies, ['RFC 9110']);
|
|
});
|
|
|
|
test('"See Also" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## See Also\n- ADR-0005');
|
|
assert.deepEqual(out.dependencies, ['ADR-0005']);
|
|
});
|
|
|
|
test('"Upstream" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Upstream\n- Platform team.');
|
|
assert.deepEqual(out.dependencies, ['Platform team.']);
|
|
});
|
|
|
|
test('"Inbound" maps to dependencies', () => {
|
|
const out = parseAdrMarkdown('## Inbound\n- From ADR-0007.');
|
|
assert.deepEqual(out.dependencies, ['From ADR-0007.']);
|
|
});
|
|
|
|
test('dependencies is empty when no section', () => {
|
|
const out = parseAdrMarkdown('# ADR\n');
|
|
assert.deepEqual(out.dependencies, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — update section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: update section', () => {
|
|
test('"Revision" maps to updates', () => {
|
|
const out = parseAdrMarkdown('## Revision\n- Changed the approach.');
|
|
assert.equal(out.updates.length, 1);
|
|
assert.equal(out.updates[0].heading, 'Revision');
|
|
assert.deepEqual(out.updates[0].entries, ['Changed the approach.']);
|
|
});
|
|
|
|
test('"Amendment" maps to updates', () => {
|
|
const out = parseAdrMarkdown('## Amendment\n- Added exception.');
|
|
assert.equal(out.updates.length, 1);
|
|
assert.equal(out.updates[0].heading, 'Amendment');
|
|
});
|
|
|
|
test('"Locked Design" maps to updates', () => {
|
|
const out = parseAdrMarkdown('## Locked Design\n- Locked.', { sourcePath: '' });
|
|
assert.equal(out.updates.length, 1);
|
|
});
|
|
|
|
test('"Final Decision" maps to updates', () => {
|
|
const out = parseAdrMarkdown('## Final Decision\n- Ship v2.');
|
|
assert.equal(out.updates.length, 1);
|
|
assert.deepEqual(out.updates[0].entries, ['Ship v2.']);
|
|
});
|
|
|
|
test('"Post-grilling" maps to updates (M7: both sides normalized to "post grilling", now reachable)', () => {
|
|
const out = parseAdrMarkdown('## Post-grilling\n- Revised after review.');
|
|
assert.equal(out.updates.length, 1);
|
|
assert.deepEqual(out.updates[0].entries, ['Revised after review.']);
|
|
assert.ok(!out.unmapped_headers.includes('Post-grilling'));
|
|
});
|
|
|
|
test('"Addendum" maps to updates', () => {
|
|
const out = parseAdrMarkdown('## Addendum\n- Minor addition.');
|
|
assert.equal(out.updates.length, 1);
|
|
assert.deepEqual(out.updates[0].entries, ['Minor addition.']);
|
|
});
|
|
|
|
test('update section captures heading verbatim', () => {
|
|
const out = parseAdrMarkdown('## Update — locked design\n- Changed on 2024-01-01.');
|
|
assert.equal(out.updates[0].heading, 'Update — locked design');
|
|
});
|
|
|
|
test('multiple update sections produce multiple entries', () => {
|
|
const md = [
|
|
'## Update',
|
|
'- First update.',
|
|
'',
|
|
'## Revision',
|
|
'- Second update.',
|
|
].join('\n');
|
|
const out = parseAdrMarkdown(md);
|
|
assert.equal(out.updates.length, 2);
|
|
assert.equal(out.updates[0].heading, 'Update');
|
|
assert.equal(out.updates[1].heading, 'Revision');
|
|
});
|
|
|
|
test('updates is empty when no update section', () => {
|
|
const out = parseAdrMarkdown('# ADR\n\n## Decision\n- Do it.');
|
|
assert.deepEqual(out.updates, []);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// parseAdrMarkdown — consequences section
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: consequences canonical section', () => {
|
|
test('"Implications" maps to consequences (parsed via parseConsequences)', () => {
|
|
const out = parseAdrMarkdown('## Implications\n- negative: some drawback\n- positive: some benefit');
|
|
assert.deepEqual(out.consequences_negative, ['negative: some drawback']);
|
|
assert.deepEqual(out.consequences_positive, ['positive: some benefit']);
|
|
});
|
|
|
|
test('"Impact" maps to consequences', () => {
|
|
const out = parseAdrMarkdown('## Impact\n- risk of regression\n- benefit: faster');
|
|
assert.deepEqual(out.consequences_negative, ['risk of regression']);
|
|
assert.deepEqual(out.consequences_positive, ['benefit: faster']);
|
|
});
|
|
|
|
test('"What This Means" maps to consequences', () => {
|
|
const out = parseAdrMarkdown('## What This Means\n- General finding.');
|
|
assert.deepEqual(out.consequences_positive, ['General finding.']);
|
|
});
|
|
|
|
test('"Result" maps to consequences', () => {
|
|
const out = parseAdrMarkdown('## Result\n- drawback: extra cost');
|
|
assert.deepEqual(out.consequences_negative, ['drawback: extra cost']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// classifyHeader — cross-bucket synonym collision (audit M7)
|
|
// 'trade-offs' must resolve to risks (consequences_negative), not considered_options.
|
|
// CANONICAL_HEADERS once listed 'trade-offs' under BOTH buckets; classifyHeader is
|
|
// first-match-wins over Object.entries and considered_options is declared first, so
|
|
// '## Trade-offs' always misclassified as options and the risks entry was dead code.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: punctuated synonyms are reachable (M7)', () => {
|
|
// Root cause: classifyHeader receives a normalized header but historically compared it
|
|
// against RAW synonyms; normalizeAdrHeader collapses [\s:._-]+ → space and strips [^\w\s],
|
|
// so any synonym with a hyphen/apostrophe was dead and its section went unmapped. The fix
|
|
// normalizes both sides, making the whole class reachable while the table stays readable.
|
|
test('"## Trade-offs" lands in consequences_negative (risks), not options_considered', () => {
|
|
const out = parseAdrMarkdown('## Trade-offs\n- adds a per-acquire syscall\n- larger lock body');
|
|
assert.deepEqual(out.consequences_negative, ['adds a per-acquire syscall', 'larger lock body']);
|
|
assert.deepEqual(out.options_considered, []);
|
|
});
|
|
|
|
test('all formerly-dead punctuated headers now classify to their bucket', () => {
|
|
assert.deepEqual(parseAdrMarkdown('## Non-Goals\n- x').out_of_scope, ['x']);
|
|
assert.deepEqual(parseAdrMarkdown('## Anti-Goals\n- x').out_of_scope, ['x']);
|
|
assert.deepEqual(parseAdrMarkdown("## Won't Do\n- x").out_of_scope, ['x']);
|
|
assert.deepEqual(parseAdrMarkdown('## Follow-up\n- x').deferred, ['x']);
|
|
assert.deepEqual(parseAdrMarkdown('## Cross-cuts\n- x').dependencies, ['x']);
|
|
assert.deepEqual(parseAdrMarkdown("## How We'll Know\n- x").consequences_positive, ['x']);
|
|
assert.equal(parseAdrMarkdown('## Post-grilling\n- 2026-01-01: note').updates[0].heading, 'Post-grilling');
|
|
});
|
|
|
|
test("'trade-offs' lives only in risks (de-duped from considered_options to avoid a cross-bucket collision)", () => {
|
|
assert.ok(!CANONICAL_HEADERS.considered_options.includes('trade-offs'));
|
|
assert.ok(CANONICAL_HEADERS.risks.includes('trade-offs'));
|
|
});
|
|
|
|
// Reachability invariant — guards the whole class against regression: every synonym in
|
|
// CANONICAL_HEADERS must classify (a header written as that synonym is never unmapped),
|
|
// and no two synonyms may normalize into different buckets (cross-bucket collision).
|
|
test('invariant: every CANONICAL_HEADERS synonym is reachable and collision-free', () => {
|
|
const byNormalized = new Map();
|
|
for (const [bucket, synonyms] of Object.entries(CANONICAL_HEADERS)) {
|
|
for (const syn of synonyms) {
|
|
const out = parseAdrMarkdown(`## ${syn}\n- z`);
|
|
assert.ok(!out.unmapped_headers.includes(syn), `synonym "${syn}" (bucket ${bucket}) is unreachable`);
|
|
const n = syn.toLowerCase().replace(/[\s:._-]+/g, ' ').replace(/[^\w\s]/g, '').trim();
|
|
if (byNormalized.has(n)) {
|
|
assert.equal(byNormalized.get(n), bucket, `normalized synonym "${n}" collides across buckets (${byNormalized.get(n)} vs ${bucket})`);
|
|
} else {
|
|
byNormalized.set(n, bucket);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// classifyHeader — prefix-match branch
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('classifyHeader prefix-match (via parseAdrMarkdown)', () => {
|
|
test('heading that starts with a synonym prefix is classified', () => {
|
|
// "status " prefix match: "status as of 2024" → starts with "status "
|
|
const out = parseAdrMarkdown('## Status as of 2024\naccepted\n');
|
|
assert.equal(out.status, 'accepted');
|
|
});
|
|
|
|
test('heading that starts with "context " prefix is classified as goal', () => {
|
|
const out = parseAdrMarkdown('## Context and Problem Statement\nSome context.');
|
|
assert.equal(out.context.trim(), 'Some context.');
|
|
});
|
|
|
|
test('heading that starts with "decision " prefix is classified as decisions', () => {
|
|
const out = parseAdrMarkdown('## Decision Outcome\n- Use option A.');
|
|
assert.deepEqual(out.decisions, ['Use option A.']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// shouldRejectAdrStatus — normalisation path (uppercase/mixed)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('shouldRejectAdrStatus: normalisation', () => {
|
|
test('uppercase "SUPERSEDED" is rejected (normalised before Set check)', () => {
|
|
assert.equal(shouldRejectAdrStatus('SUPERSEDED'), true);
|
|
});
|
|
|
|
test('uppercase "REJECTED" is rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('REJECTED'), true);
|
|
});
|
|
|
|
test('uppercase "DEPRECATED" is rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('DEPRECATED'), true);
|
|
});
|
|
|
|
test('mixed-case "Superseded" is rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('Superseded'), true);
|
|
});
|
|
|
|
test('mixed-case "Rejected" is rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('Rejected'), true);
|
|
});
|
|
|
|
test('mixed-case "Deprecated" is rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('Deprecated'), true);
|
|
});
|
|
|
|
test('"accepted" is not rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('accepted'), false);
|
|
});
|
|
|
|
test('"proposed" is not rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('proposed'), false);
|
|
});
|
|
|
|
test('"active" is not rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus('active'), false);
|
|
});
|
|
|
|
test('empty string is not rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus(''), false);
|
|
});
|
|
|
|
test('non-string returns false (does not throw)', () => {
|
|
assert.equal(shouldRejectAdrStatus(null), false);
|
|
assert.equal(shouldRejectAdrStatus(undefined), false);
|
|
assert.equal(shouldRejectAdrStatus(42), false);
|
|
});
|
|
|
|
test('status with punctuation normalised: "superseded." is rejected', () => {
|
|
// normalizeAdrHeader strips . → "superseded" → rejected
|
|
assert.equal(shouldRejectAdrStatus('superseded.'), true);
|
|
});
|
|
|
|
test('status with extra spaces: " rejected " is rejected', () => {
|
|
assert.equal(shouldRejectAdrStatus(' rejected '), true);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// splitEntries behaviour (via parseAdrMarkdown)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('splitEntries (via parseAdrMarkdown decisions)', () => {
|
|
test('dash-prefixed entries stripped', () => {
|
|
const out = parseAdrMarkdown('## Decision\n- Entry one.\n- Entry two.');
|
|
assert.deepEqual(out.decisions, ['Entry one.', 'Entry two.']);
|
|
});
|
|
|
|
test('star-prefixed entries stripped', () => {
|
|
const out = parseAdrMarkdown('## Decision\n* Star entry.');
|
|
assert.deepEqual(out.decisions, ['Star entry.']);
|
|
});
|
|
|
|
test('plus-prefixed entries stripped', () => {
|
|
const out = parseAdrMarkdown('## Decision\n+ Plus entry.');
|
|
assert.deepEqual(out.decisions, ['Plus entry.']);
|
|
});
|
|
|
|
test('blank lines between entries filtered out', () => {
|
|
const out = parseAdrMarkdown('## Decision\n- First.\n\n- Second.');
|
|
assert.deepEqual(out.decisions, ['First.', 'Second.']);
|
|
});
|
|
|
|
test('plain text without bullet still included', () => {
|
|
const out = parseAdrMarkdown('## Decision\nPlain text entry.');
|
|
assert.deepEqual(out.decisions, ['Plain text entry.']);
|
|
});
|
|
|
|
test('lines with only whitespace filtered', () => {
|
|
const out = parseAdrMarkdown('## Decision\n \n- Real entry.\n ');
|
|
assert.deepEqual(out.decisions, ['Real entry.']);
|
|
});
|
|
|
|
// Regression guard for ADR-1372 T2: iterateBullets folded indented non-bullet
|
|
// lines into the preceding bullet — the flat splitEntries must keep them.
|
|
test('indented non-bullet line (4-space) kept verbatim as its own entry', () => {
|
|
const md = '## Decision\n- Bullet entry\n indented non-bullet line\n- Another bullet';
|
|
const out = parseAdrMarkdown(md);
|
|
assert.deepEqual(out.decisions, ['Bullet entry', 'indented non-bullet line', 'Another bullet']);
|
|
});
|
|
|
|
// Regression guard for ADR-1372 T2: iterateBullets stripped numbered markers
|
|
// ("1. Foo" → "Foo") — the flat splitEntries only strips [-*+], not numbers.
|
|
test('numbered list item kept verbatim (not stripped to bare text)', () => {
|
|
const md = '## Decision\n1. First\n2. Second';
|
|
const out = parseAdrMarkdown(md);
|
|
assert.deepEqual(out.decisions, ['1. First', '2. Second']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Full integration: all sections in one document
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('parseAdrMarkdown: full document integration', () => {
|
|
test('complete ADR with all section types parsed correctly', () => {
|
|
const md = [
|
|
'# ADR-0001: Switch to PostgreSQL',
|
|
'',
|
|
'## Status',
|
|
'Accepted',
|
|
'',
|
|
'## Context',
|
|
'The SQLite database cannot handle concurrent writes.',
|
|
'',
|
|
'## Decision',
|
|
'- Migrate to PostgreSQL.',
|
|
'- Use connection pooling.',
|
|
'',
|
|
'## Considered Options',
|
|
'- Stay with SQLite.',
|
|
'- Use CockroachDB.',
|
|
'',
|
|
'## Success Criteria',
|
|
'- Zero data loss.',
|
|
'- 99.9% uptime maintained.',
|
|
'',
|
|
'## Risks',
|
|
'- Migration downtime.',
|
|
'',
|
|
'## Implementation Plan',
|
|
'- Step 1: Set up Postgres.',
|
|
'- Step 2: Migrate data.',
|
|
'',
|
|
'## Affected Files',
|
|
'- src/db/client.ts',
|
|
'',
|
|
'## Out of Scope',
|
|
'- Redis integration.',
|
|
'',
|
|
'## Future Work',
|
|
'- Connection sharding.',
|
|
'',
|
|
'## Dependencies',
|
|
'- ADR-0000',
|
|
'',
|
|
'## Update',
|
|
'- Changed connection pool size to 20.',
|
|
'',
|
|
'## Consequences',
|
|
'- negative: higher operational cost.',
|
|
'- positive: improved throughput.',
|
|
].join('\n');
|
|
|
|
const out = parseAdrMarkdown(md, { sourcePath: 'docs/adr/0001.md', format: 'custom' });
|
|
|
|
assert.equal(out.title, 'ADR-0001: Switch to PostgreSQL');
|
|
assert.equal(out.status, 'accepted');
|
|
assert.equal(out.source_path, 'docs/adr/0001.md');
|
|
assert.equal(out.format, 'custom');
|
|
assert.equal(out.context.trim(), 'The SQLite database cannot handle concurrent writes.');
|
|
assert.deepEqual(out.decisions, ['Migrate to PostgreSQL.', 'Use connection pooling.']);
|
|
assert.deepEqual(out.options_considered, ['Stay with SQLite.', 'Use CockroachDB.']);
|
|
assert.deepEqual(out.consequences_positive, ['Zero data loss.', '99.9% uptime maintained.', 'positive: improved throughput.']);
|
|
assert.deepEqual(out.consequences_negative, ['Migration downtime.', 'negative: higher operational cost.']);
|
|
assert.deepEqual(out.plan_sequence, ['Step 1: Set up Postgres.', 'Step 2: Migrate data.']);
|
|
assert.deepEqual(out.key_files, ['src/db/client.ts']);
|
|
assert.deepEqual(out.out_of_scope, ['Redis integration.']);
|
|
assert.deepEqual(out.deferred, ['Connection sharding.']);
|
|
assert.deepEqual(out.dependencies, ['ADR-0000']);
|
|
assert.equal(out.updates.length, 1);
|
|
assert.equal(out.updates[0].heading, 'Update');
|
|
assert.deepEqual(out.updates[0].entries, ['Changed connection pool size to 20.']);
|
|
// The H1 heading "ADR-0001: Switch to PostgreSQL" is treated as a section heading;
|
|
// it normalizes to a non-canonical string → goes into unmapped_headers.
|
|
assert.deepEqual(out.unmapped_headers, ['ADR-0001: Switch to PostgreSQL']);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Targeted mutation-killing tests (T2 adapter seam)
|
|
// Each test is annotated with the mutant it kills.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
describe('targeted: pushUnique intra-values deduplication', () => {
|
|
// Kills: `seen.add(value)` removal mutant — without it, values-internal dups pass through.
|
|
test('duplicate entries within the same section body are deduplicated', () => {
|
|
const md = '## Decision\n- Same entry.\n- Same entry.\n- Different entry.';
|
|
const out = parseAdrMarkdown(md);
|
|
assert.deepEqual(out.decisions, ['Same entry.', 'Different entry.']);
|
|
});
|
|
});
|
|
|
|
describe('targeted: parseSections body-split round-trip', () => {
|
|
// Kills: body split/join mutations — each body line must be its own array element.
|
|
// The adapter does sec.body.split('\n'); parseAdrMarkdown does section.body.join('\n').
|
|
// A mutant replacing '\n' with ' ' in either call would break this.
|
|
test('multi-line goal body has each line preserved with internal newlines in prose', () => {
|
|
const md = '## Context\nLine one.\nLine two.\nLine three.';
|
|
const out = parseAdrMarkdown(md);
|
|
// prose = section.body.join('\n').trim() — must include all three lines separated by \n
|
|
assert.ok(out.context.includes('Line one.'), `context missing line one: ${out.context}`);
|
|
assert.ok(out.context.includes('Line two.'), `context missing line two: ${out.context}`);
|
|
assert.ok(out.context.includes('Line three.'), `context missing line three: ${out.context}`);
|
|
assert.ok(out.context.includes('\n'), 'context must retain internal newlines');
|
|
});
|
|
|
|
test('multi-line decision body produces one entry per non-blank line', () => {
|
|
// entries = splitEntries(section.body.join('\n')) — join must be '\n' not ' '
|
|
const md = '## Decision\n- Alpha.\n- Beta.\n- Gamma.';
|
|
const out = parseAdrMarkdown(md);
|
|
assert.deepEqual(out.decisions, ['Alpha.', 'Beta.', 'Gamma.']);
|
|
});
|
|
});
|
|
|
|
describe('targeted: parseStatusFromSections uses first entry only', () => {
|
|
// Kills: mutants that remove [0] indexing or change `splitEntries(...)[0]` to return all.
|
|
test('only the first non-blank line of the status body determines status', () => {
|
|
// Second line "rejected" must NOT influence the result.
|
|
const md = '# ADR\n\n## Status\naccepted\nrejected\n';
|
|
const out = parseAdrMarkdown(md);
|
|
assert.equal(out.status, 'accepted');
|
|
});
|
|
});
|
|
|
|
describe('targeted: classifyHeader exact-match vs prefix-match boundary', () => {
|
|
// Kills: mutants that remove the trailing space from startsWith check, or remove
|
|
// the equality check.
|
|
|
|
// Case 1: exact match — heading IS the synonym (no trailing content)
|
|
test('heading exactly equal to synonym matches (equality branch)', () => {
|
|
const out = parseAdrMarkdown('## Status\naccepted\n');
|
|
assert.equal(out.status, 'accepted');
|
|
});
|
|
|
|
// Case 2: prefix match — heading starts with synonym + space + more text
|
|
test('heading starting with synonym + space matches (prefix branch)', () => {
|
|
// "status of the adr" → starts with "status " → classified as status
|
|
const out = parseAdrMarkdown('## Status of the ADR\naccepted\n');
|
|
assert.equal(out.status, 'accepted');
|
|
});
|
|
|
|
// Case 3: heading IS synonym but no trailing space should NOT match via startsWith
|
|
// (it matches via equality instead) — this verifies the equality check fires
|
|
test('heading that exactly equals a synonym is classified without trailing space', () => {
|
|
// "context" equals the synonym exactly — must be classified as goal
|
|
const out = parseAdrMarkdown('## Context\nExact match context.');
|
|
assert.equal(out.context.trim(), 'Exact match context.');
|
|
});
|
|
|
|
// Case 4: heading with wrong suffix (synonym+letter, no space) must NOT match prefix
|
|
test('heading that is synonym + letter (no space) does NOT match prefix', () => {
|
|
// "statuses" → normalizes to "statuses", not "status " prefix — unclassified
|
|
const out = parseAdrMarkdown('## Statuses\naccepted\n');
|
|
assert.ok(out.unmapped_headers.includes('Statuses'));
|
|
// status should fall back to 'accepted' default (no status section found)
|
|
assert.equal(out.status, 'accepted');
|
|
});
|
|
});
|
|
|
|
describe('targeted: goal section prose vs entries distinction', () => {
|
|
// The goal/context case uses `prose` (joined + trimmed multi-line text), not `entries`
|
|
// (bullet-stripped list). Killing the `prose` variable or swapping it for `entries`
|
|
// would strip bullet markers from context text.
|
|
test('goal section body with bullet markers is preserved verbatim in context (prose, not entries)', () => {
|
|
// If parser used entries instead of prose, "- with a dash" would become "with a dash".
|
|
const md = '## Context\nThis is context.\n- with a dash item.\nMore prose.';
|
|
const out = parseAdrMarkdown(md);
|
|
assert.ok(out.context.includes('- with a dash item.'),
|
|
`context should preserve bullet markers in prose: ${out.context}`);
|
|
});
|
|
});
|
|
|
|
describe('targeted: normalizeAdrHeader non-word char removal', () => {
|
|
// Kills: regex mutation in the [^\w\s] replacement — e.g. inverting the class
|
|
// or changing the replacement target.
|
|
test('parentheses in heading are stripped by non-word removal', () => {
|
|
// "Context (v2)" normalizes to "context v2" — still matches "context" via prefix "context "
|
|
const out = parseAdrMarkdown('## Context (v2)\nSome context here.');
|
|
assert.equal(out.context.trim(), 'Some context here.');
|
|
});
|
|
|
|
test('non-word chars adjacent to word chars are stripped without inserting a space', () => {
|
|
// "Context/Background" → [^\w\s] removes '/' → "contextbackground" (no space)
|
|
// So it does NOT classify as goal (exact "contextbackground" ≠ any synonym).
|
|
const out = parseAdrMarkdown('## Context/Background\nSlash context.');
|
|
// Does not classify as goal — goes to unmapped_headers
|
|
assert.ok(out.unmapped_headers.includes('Context/Background'));
|
|
assert.equal(out.context, '');
|
|
});
|
|
});
|