* 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>
479 lines
13 KiB
TypeScript
479 lines
13 KiB
TypeScript
/**
|
|
* ADR Markdown parser — parses Architecture Decision Record documents into
|
|
* structured objects for downstream processing (adr command, gap checker, etc.).
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/adr-parser.cjs collapsed
|
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
* from the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { requireSafePath } from './security.cjs';
|
|
import { collectSections } from './markdown-sectionizer.cjs';
|
|
|
|
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']);
|
|
|
|
type CanonicalHeader =
|
|
| 'status'
|
|
| 'goal'
|
|
| 'decisions'
|
|
| 'considered_options'
|
|
| 'risks'
|
|
| 'success_criteria'
|
|
| 'plan_sequence'
|
|
| 'key_files'
|
|
| 'out_of_scope'
|
|
| 'deferred'
|
|
| 'dependencies'
|
|
| 'update'
|
|
| 'consequences';
|
|
|
|
const CANONICAL_HEADERS: Record<CanonicalHeader, string[]> = {
|
|
status: ['status', 'state', 'lifecycle', 'stage'],
|
|
goal: [
|
|
'context',
|
|
'background',
|
|
'problem statement',
|
|
'problem',
|
|
'situation',
|
|
'forces',
|
|
'motivation',
|
|
'issue',
|
|
'drivers',
|
|
'pain points',
|
|
'story',
|
|
'setting',
|
|
'premise',
|
|
'status quo',
|
|
'context and problem statement',
|
|
],
|
|
decisions: [
|
|
'decision',
|
|
'decisions',
|
|
'resolution',
|
|
'conclusion',
|
|
'choice',
|
|
'we decided',
|
|
'direction',
|
|
'approach',
|
|
'solution',
|
|
'outcome',
|
|
'selected option',
|
|
'recommendation',
|
|
'strategy',
|
|
'decision outcome',
|
|
],
|
|
considered_options: [
|
|
'considered options',
|
|
'alternatives',
|
|
'options',
|
|
'choices',
|
|
'candidates',
|
|
'approaches considered',
|
|
'variants',
|
|
'pros and cons of the options',
|
|
'discussion',
|
|
],
|
|
risks: [
|
|
'risks',
|
|
'trade-offs',
|
|
'drawbacks',
|
|
'cost',
|
|
'tensions',
|
|
'liabilities',
|
|
'negative consequences',
|
|
'side effects',
|
|
],
|
|
success_criteria: [
|
|
'success criteria',
|
|
'acceptance criteria',
|
|
'validation',
|
|
"how we'll know",
|
|
'metrics',
|
|
'kpis',
|
|
'verification',
|
|
'test strategy',
|
|
'compliance',
|
|
'definition of done',
|
|
'exit criteria',
|
|
'positive consequences',
|
|
],
|
|
plan_sequence: [
|
|
'implementation plan',
|
|
'implementation notes',
|
|
'steps',
|
|
'tasks',
|
|
'roadmap',
|
|
'sequence',
|
|
'migration plan',
|
|
'plan',
|
|
'action items',
|
|
'work breakdown',
|
|
'phases',
|
|
'milestones',
|
|
'stages',
|
|
],
|
|
key_files: [
|
|
'affected files',
|
|
'files touched',
|
|
'surface area',
|
|
'modules affected',
|
|
'code locations',
|
|
'file changes',
|
|
'diff summary',
|
|
'touched code',
|
|
],
|
|
out_of_scope: [
|
|
'out of scope',
|
|
'non-goals',
|
|
'excluded',
|
|
'not in this adr',
|
|
'out of bounds',
|
|
"won't do",
|
|
"won't have",
|
|
'beyond scope',
|
|
'anti-goals',
|
|
],
|
|
deferred: [
|
|
'future work',
|
|
'deferred',
|
|
'future',
|
|
'later',
|
|
'follow-up',
|
|
'next steps',
|
|
],
|
|
dependencies: [
|
|
'dependencies',
|
|
'depends on',
|
|
'prerequisites',
|
|
'sequencing',
|
|
'order',
|
|
'blocked by',
|
|
'cross-cuts',
|
|
'related adrs',
|
|
'links',
|
|
'references',
|
|
'see also',
|
|
'upstream',
|
|
'inbound',
|
|
],
|
|
update: [
|
|
'update',
|
|
'revision',
|
|
'amendment',
|
|
'locked design',
|
|
'final decision',
|
|
'post-grilling',
|
|
'addendum',
|
|
],
|
|
consequences: [
|
|
'consequences',
|
|
'implications',
|
|
'impact',
|
|
'what this means',
|
|
'result',
|
|
],
|
|
};
|
|
|
|
const CONSEQUENCE_NEGATIVE_HINTS: string[] = [
|
|
'negative',
|
|
'drawback',
|
|
'risk',
|
|
'cost',
|
|
'liability',
|
|
'trade-off',
|
|
'tension',
|
|
'side effect',
|
|
];
|
|
|
|
const CONSEQUENCE_POSITIVE_HINTS: string[] = [
|
|
'positive',
|
|
'success',
|
|
'metric',
|
|
'kpi',
|
|
'verification',
|
|
'acceptance',
|
|
'benefit',
|
|
];
|
|
|
|
function normalizeAdrHeader(raw: unknown): string {
|
|
const s = typeof raw === 'string' ? raw : '';
|
|
return s
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s:._-]+/g, ' ')
|
|
.replace(/[^\w\s]/g, '')
|
|
.trim();
|
|
}
|
|
|
|
// Normalized synonym index (audit M7). classifyHeader receives an ALREADY-normalized
|
|
// header (via normalizeAdrHeader), but historically compared it against the RAW synonym
|
|
// strings. Because normalizeAdrHeader collapses [\s:._-]+ to a space and strips [^\w\s],
|
|
// any synonym carrying a hyphen/apostrophe/etc. ('trade-offs', "won't do", 'post-grilling')
|
|
// could never match a normalized header — it was silently dead, and its ADR section went
|
|
// unmapped. Normalizing BOTH sides closes that abstraction asymmetry once, so every synonym
|
|
// (current and future) is reachable regardless of punctuation. Precomputed at module load to
|
|
// avoid re-normalizing the whole table per call; insertion order is preserved so first-match-
|
|
// wins and the exact-then-prefix precedence stay identical to the prior raw-compare loop.
|
|
const _NORMALIZED_SYNONYM_INDEX: Array<[string, CanonicalHeader]> = (() => {
|
|
const index: Array<[string, CanonicalHeader]> = [];
|
|
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS) as Array<[CanonicalHeader, string[]]>) {
|
|
for (const synonym of synonyms) {
|
|
index.push([normalizeAdrHeader(synonym), canonical]);
|
|
}
|
|
}
|
|
return index;
|
|
})();
|
|
|
|
function classifyHeader(normalizedHeader: string): CanonicalHeader | null {
|
|
for (const [synonym, canonical] of _NORMALIZED_SYNONYM_INDEX) {
|
|
if (normalizedHeader === synonym) return canonical;
|
|
if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function splitEntries(blockText: unknown): string[] {
|
|
return (typeof blockText === 'string' ? blockText : '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => line.replace(/^[-*+]\s+/, '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
interface MarkdownSection {
|
|
heading: string | null;
|
|
body: string[];
|
|
}
|
|
|
|
/**
|
|
* Thin adapter: wraps the seam's `collectSections` to produce the same
|
|
* `{ heading: string | null, body: string[] }` shape the rest of adr-parser
|
|
* consumes. ADR-1372 T2 migration.
|
|
*/
|
|
function parseSections(markdown: unknown): MarkdownSection[] {
|
|
const content = typeof markdown === 'string' ? markdown : '';
|
|
|
|
// collectSections(content, () => true) collects every heading as a stop
|
|
// boundary — mirrors the old line-by-line heading walk exactly.
|
|
const sections = collectSections(content, () => true);
|
|
|
|
// Map seam Section → MarkdownSection. The seam's HeadingToken.text is the
|
|
// heading text after trimming (same as the old m[1].trim() capture).
|
|
// The body is a trimEnd()-ed joined string; split it back to lines to match
|
|
// the old string[] shape consumed by parseStatusFromSections / parseAdrMarkdown.
|
|
//
|
|
// Note: the old parseSections emitted a leading { heading: null, body: [...] }
|
|
// entry for preamble text before the first heading. Both consumers skip it
|
|
// immediately (parseAdrMarkdown: `if (!heading) continue`; parseStatusFromSections:
|
|
// `classifyHeader(normalizeAdrHeader(null))` → null ≠ 'status' → continue), so
|
|
// the preamble entry was dead code and is not reconstructed here.
|
|
return sections.map((sec) => ({
|
|
heading: sec.heading.text,
|
|
body: sec.body === '' ? [] : sec.body.split('\n'),
|
|
}));
|
|
}
|
|
|
|
function parseStatusFromSections(sections: MarkdownSection[]): string {
|
|
for (const section of sections) {
|
|
const canonical = classifyHeader(normalizeAdrHeader(section.heading));
|
|
if (canonical !== 'status') continue;
|
|
const line = splitEntries(section.body.join('\n'))[0] || '';
|
|
const norm = normalizeAdrHeader(line);
|
|
if (!norm) return '';
|
|
if (norm.includes('accepted')) return 'accepted';
|
|
if (norm.includes('proposed')) return 'proposed';
|
|
if (norm.includes('superseded')) return 'superseded';
|
|
if (norm.includes('rejected')) return 'rejected';
|
|
if (norm.includes('deprecated')) return 'deprecated';
|
|
return norm;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function pushUnique(target: string[], values: string[]): void {
|
|
const seen = new Set(target);
|
|
for (const value of values) {
|
|
if (!seen.has(value)) {
|
|
target.push(value);
|
|
seen.add(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
interface AdrOut {
|
|
title: string;
|
|
status: string;
|
|
context: string;
|
|
decisions: string[];
|
|
options_considered: string[];
|
|
consequences_positive: string[];
|
|
consequences_negative: string[];
|
|
out_of_scope: string[];
|
|
deferred: string[];
|
|
dependencies: string[];
|
|
updates: Array<{ heading: string; entries: string[] }>;
|
|
source_path: string;
|
|
key_files: string[];
|
|
plan_sequence: string[];
|
|
format: string;
|
|
unmapped_headers: string[];
|
|
}
|
|
|
|
function parseConsequences(lines: string[], out: AdrOut): void {
|
|
for (const entry of lines) {
|
|
const lower = entry.toLowerCase();
|
|
if (CONSEQUENCE_NEGATIVE_HINTS.some((hint) => lower.includes(hint))) {
|
|
out.consequences_negative.push(entry);
|
|
continue;
|
|
}
|
|
if (CONSEQUENCE_POSITIVE_HINTS.some((hint) => lower.includes(hint))) {
|
|
out.consequences_positive.push(entry);
|
|
continue;
|
|
}
|
|
out.consequences_positive.push(entry);
|
|
}
|
|
}
|
|
|
|
interface ParseAdrMarkdownOptions {
|
|
sourcePath?: string;
|
|
format?: string;
|
|
}
|
|
|
|
function parseAdrMarkdown(markdown: unknown, { sourcePath = '', format = 'auto' }: ParseAdrMarkdownOptions = {}): AdrOut {
|
|
const sections = parseSections(markdown);
|
|
const titleLine = (typeof markdown === 'string' ? markdown : '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
|
|
const title = titleLine.replace(/^#\s+/, '').trim();
|
|
|
|
const out: AdrOut = {
|
|
title,
|
|
status: parseStatusFromSections(sections) || 'accepted',
|
|
context: '',
|
|
decisions: [],
|
|
options_considered: [],
|
|
consequences_positive: [],
|
|
consequences_negative: [],
|
|
out_of_scope: [],
|
|
deferred: [],
|
|
dependencies: [],
|
|
updates: [],
|
|
source_path: sourcePath,
|
|
key_files: [],
|
|
plan_sequence: [],
|
|
format,
|
|
unmapped_headers: [],
|
|
};
|
|
|
|
for (const section of sections) {
|
|
const heading = section.heading || '';
|
|
if (!heading) continue;
|
|
const canonical = classifyHeader(normalizeAdrHeader(heading));
|
|
const entries = splitEntries(section.body.join('\n'));
|
|
const prose = section.body.join('\n').trim();
|
|
|
|
if (!canonical) {
|
|
out.unmapped_headers.push(heading);
|
|
continue;
|
|
}
|
|
|
|
switch (canonical) {
|
|
case 'goal':
|
|
if (!out.context && prose) out.context = prose;
|
|
break;
|
|
case 'decisions':
|
|
pushUnique(out.decisions, entries);
|
|
break;
|
|
case 'considered_options':
|
|
pushUnique(out.options_considered, entries);
|
|
break;
|
|
case 'risks':
|
|
pushUnique(out.consequences_negative, entries);
|
|
break;
|
|
case 'success_criteria':
|
|
pushUnique(out.consequences_positive, entries);
|
|
break;
|
|
case 'plan_sequence':
|
|
pushUnique(out.plan_sequence, entries);
|
|
break;
|
|
case 'key_files':
|
|
pushUnique(out.key_files, entries);
|
|
break;
|
|
case 'out_of_scope':
|
|
pushUnique(out.out_of_scope, entries);
|
|
break;
|
|
case 'deferred':
|
|
pushUnique(out.deferred, entries);
|
|
break;
|
|
case 'dependencies':
|
|
pushUnique(out.dependencies, entries);
|
|
break;
|
|
case 'update':
|
|
out.updates.push({ heading, entries });
|
|
break;
|
|
case 'consequences':
|
|
parseConsequences(entries, out);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function shouldRejectAdrStatus(status: string): boolean {
|
|
return STATUS_REJECT_SET.has(normalizeAdrHeader(status));
|
|
}
|
|
|
|
interface CliOpts {
|
|
input: string | null;
|
|
format: string;
|
|
projectDir: string;
|
|
}
|
|
|
|
function parseCliArgs(argv: string[]): CliOpts {
|
|
const opts: CliOpts = { input: null, format: 'auto', projectDir: process.cwd() };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === '--input') {
|
|
opts.input = argv[++i] || null;
|
|
} else if (arg === '--format') {
|
|
opts.format = argv[++i] || 'auto';
|
|
} else if (arg === '--project-dir') {
|
|
opts.projectDir = argv[++i] || process.cwd();
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
if (!opts.input) {
|
|
throw new Error('Missing required --input <path>');
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function main(argv: string[]): void {
|
|
const opts = parseCliArgs(argv);
|
|
const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true });
|
|
const content = fs.readFileSync(safePath, 'utf8');
|
|
const parsed = parseAdrMarkdown(content, { sourcePath: opts.input ?? undefined, format: opts.format });
|
|
process.stdout.write(JSON.stringify(parsed, null, 2));
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main(process.argv.slice(2));
|
|
} catch (error) {
|
|
process.stderr.write(`Error: ${(error as Error).message}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
export = {
|
|
CANONICAL_HEADERS,
|
|
normalizeAdrHeader,
|
|
parseAdrMarkdown,
|
|
shouldRejectAdrStatus,
|
|
};
|