Phase 3 of epic #4636, stage 3b. Satisfies #4653's criterion that `opts.allowAbsolute` become "a named acceptance policy on the predicate, not a per-call-site boolean". The flag was actively misleading at the call site. `{ allowAbsolute: true }` reads as "containment is relaxed here". It never was: an absolute path that resolves outside the root is rejected exactly as a traversal is. The flag only ever controlled whether an absolute candidate was CONSIDERED. On a security predicate that is the wrong thing for a reviewer to have to infer, and 31 call sites were asking them to infer it. PathAcceptance.RelativeOnly relative candidates only PathAcceptance.AbsoluteInsideRoot absolute accepted, containment unchanged The three exported wrappers take the policy and translate it inward. validatePath keeps its internal `{ allowAbsolute }` opts and its body untouched — the engine is not re-derived here either, only the exported surface is renamed. MEASURED, NOT ESTIMATED. 31 call sites across 10 files, counted by walking the AST with the repo's own @typescript-eslint/parser rather than grepping: a text match would have folded in the options-type declaration, default parameter values and comments. All 31 pass the literal `true`; none passes `false` or a dynamic value, so the migration is uniform and `RelativeOnly` is purely the existing default made nameable. audit.cts alone holds 18 of them. This migration is compiler-verified in a way the containment-value migration in the previous commit was not: the parameter type changed from an object to a string union, so any missed site is a build error rather than a silent behavioral difference. That is why a 31-site mechanical edit is acceptable in the phase whose stated risk is the width of mechanical change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
485 lines
13 KiB
TypeScript
485 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, PathAcceptance } from './security.cjs';
|
|
import { collectSections } from './markdown-sectionizer.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import cliExitModule = require('./cli-exit.cjs');
|
|
const { ExitError, runMain } = cliExitModule;
|
|
|
|
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', PathAcceptance.AbsoluteInsideRoot);
|
|
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) {
|
|
runMain(() => {
|
|
try {
|
|
main(process.argv.slice(2));
|
|
} catch (err) {
|
|
// ExitError with a message so runMain's catch writes it verbatim
|
|
// (byte-identical to the prior `Error: ${message}\n` process.exit(1)).
|
|
throw new ExitError(1, `Error: ${(err as Error).message}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
export = {
|
|
CANONICAL_HEADERS,
|
|
normalizeAdrHeader,
|
|
parseAdrMarkdown,
|
|
shouldRejectAdrStatus,
|
|
};
|