* feat(plan-phase): add ADR ingest express path for approved enhancement #3209 * fix(review): address coderabbit doc note and brittle section-number assertion
395 lines
8.9 KiB
JavaScript
395 lines
8.9 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { requireSafePath } = require('./security.cjs');
|
|
|
|
const STATUS_REJECT_SET = new Set(['superseded', 'rejected', 'deprecated']);
|
|
|
|
const CANONICAL_HEADERS = {
|
|
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',
|
|
'trade-offs',
|
|
'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 = [
|
|
'negative',
|
|
'drawback',
|
|
'risk',
|
|
'cost',
|
|
'liability',
|
|
'trade-off',
|
|
'tension',
|
|
'side effect',
|
|
];
|
|
|
|
const CONSEQUENCE_POSITIVE_HINTS = [
|
|
'positive',
|
|
'success',
|
|
'metric',
|
|
'kpi',
|
|
'verification',
|
|
'acceptance',
|
|
'benefit',
|
|
];
|
|
|
|
function normalizeAdrHeader(raw) {
|
|
return String(raw || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s:._-]+/g, ' ')
|
|
.replace(/[^\w\s]/g, '')
|
|
.trim();
|
|
}
|
|
|
|
function classifyHeader(normalizedHeader) {
|
|
for (const [canonical, synonyms] of Object.entries(CANONICAL_HEADERS)) {
|
|
for (const synonym of synonyms) {
|
|
if (normalizedHeader === synonym) return canonical;
|
|
if (normalizedHeader.startsWith(`${synonym} `)) return canonical;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function splitEntries(blockText) {
|
|
return String(blockText || '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => line.replace(/^[-*+]\s+/, '').trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function parseSections(markdown) {
|
|
const lines = String(markdown || '').split(/\r?\n/);
|
|
const sections = [];
|
|
let current = { heading: null, body: [] };
|
|
|
|
for (const line of lines) {
|
|
const m = line.match(/^#{1,6}\s+(.*)$/);
|
|
if (m) {
|
|
if (current.heading || current.body.length) sections.push(current);
|
|
current = { heading: m[1].trim(), body: [] };
|
|
} else {
|
|
current.body.push(line);
|
|
}
|
|
}
|
|
|
|
if (current.heading || current.body.length) sections.push(current);
|
|
return sections;
|
|
}
|
|
|
|
function parseStatusFromSections(sections) {
|
|
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, values) {
|
|
const seen = new Set(target);
|
|
for (const value of values) {
|
|
if (!seen.has(value)) {
|
|
target.push(value);
|
|
seen.add(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseConsequences(lines, out) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
function parseAdrMarkdown(markdown, { sourcePath = '', format = 'auto' } = {}) {
|
|
const sections = parseSections(markdown);
|
|
const titleLine = String(markdown || '').split(/\r?\n/).find((line) => /^#\s+/.test(line)) || '';
|
|
const title = titleLine.replace(/^#\s+/, '').trim();
|
|
|
|
const out = {
|
|
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) {
|
|
return STATUS_REJECT_SET.has(normalizeAdrHeader(status));
|
|
}
|
|
|
|
function parseCliArgs(argv) {
|
|
const opts = { 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) {
|
|
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, 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.message}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
CANONICAL_HEADERS,
|
|
normalizeAdrHeader,
|
|
parseAdrMarkdown,
|
|
shouldRejectAdrStatus,
|
|
};
|