import { LineCounter, isMap, isScalar, isSeq, parseDocument } from 'yaml'; import { ModelProblem, Registry } from './format'; /** * Architecture decisions of a model (see docs/model.md): one * `*.archify-decision` file per decision, anywhere under the model root. * Pure functions, no VS Code imports. */ export const DECISION_EXT = '.archify-decision'; export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'] as const; export type DecisionStatus = (typeof DECISION_STATUSES)[number]; export interface Decision { id: string; title?: string; status?: string; date?: string; /** Registry element ids the decision is about. */ elements: string[]; /** Diagram paths (relative to the model root) the decision names. */ diagrams: string[]; /** Ids of older decisions this one replaces. */ supersedes: string[]; context?: string; decision?: string; consequences?: string; /** Decision file relative to the model root, and 0-based lines for diagnostics. */ file: string; line: number; lines: { id: number; status: number; elements: number[]; diagrams: number[]; supersedes: number[] }; } export function isDecisionFile(fileName: string): boolean { return fileName.endsWith(DECISION_EXT); } const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim().length > 0 ? v : v !== undefined && v !== null && typeof v !== 'object' ? String(v) : undefined); /** "OpenSearch for product search" → "opensearch-for-product-search". */ export function slugify(title: string): string { const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); return slug || 'decision'; } /** Parses one decision file. Problems carry 0-based lines. */ export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } { const problems: ModelProblem[] = []; // prettyErrors would put line numbers into the messages; the line counter gives them without. const lines = new LineCounter(); const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false, lineCounter: lines }); for (const error of doc.errors) { problems.push({ message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: lines.linePos(error.pos[0]).line - 1 }); } if (doc.errors.length) return { problems }; const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1); const root = doc.contents; if (!isMap(root)) { problems.push({ message: 'A decision file is a mapping with a "decision" block and context, decision and consequences.', severity: 'error', file, line: 0 }); return { problems }; } const headNode = root.get('decision', true); if (!isMap(headNode)) { problems.push({ message: 'A decision needs a "decision" block with id, title and status.', severity: 'error', file, line: 0, pointer: '/decision' }); return { problems }; } const headPair = root.items.find((pair) => isScalar(pair.key) && pair.key.value === 'decision'); const headLine = headPair && isScalar(headPair.key) ? lineOf(headPair.key.range?.[0]) : 0; const keyLine = (key: string) => { const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key); return pair && isScalar(pair.key) ? lineOf(pair.key.range?.[0]) : headLine; }; const list = (key: string): { values: string[]; lines: number[] } => { const node = headNode.get(key, true); const values: string[] = []; const lines: number[] = []; if (isSeq(node)) { for (const item of node.items) { if (!isScalar(item)) continue; if (typeof item.value === 'number') { problems.push({ message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, severity: 'error', file, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` }); continue; } if (str(item.value)) { values.push(String(item.value)); lines.push(lineOf(item.range?.[0])); } } } else if (node !== undefined) { problems.push({ message: `"${key}" must be a list.`, severity: 'error', file, line: keyLine(key), pointer: `/decision/${key}` }); } return { values, lines }; }; const head = headNode.toJSON() as Record; if (typeof head.id === 'number') { problems.push({ message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, severity: 'error', file, line: keyLine('id'), pointer: '/decision/id' }); return { problems }; } const id = str(head.id); if (!id) { problems.push({ message: 'decision.id is missing.', severity: 'error', file, line: headLine, pointer: '/decision/id' }); return { problems }; } const status = str(head.status); if (status && !(DECISION_STATUSES as readonly string[]).includes(status)) { problems.push({ message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, severity: 'error', file, line: keyLine('status'), pointer: '/decision/status' }); } const elements = list('elements'); const diagrams = list('diagrams'); const supersedes = list('supersedes'); const body = root.toJSON() as Record; return { problems, decision: { id, title: str(head.title), status, date: str(head.date), elements: elements.values, diagrams: diagrams.values, supersedes: supersedes.values, context: str(body.context), decision: str(body.decision), consequences: str(body.consequences), file, line: headLine, lines: { id: keyLine('id'), status: keyLine('status'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines }, }, }; } export interface DecisionSet { decisions: Map; files: string[]; problems: ModelProblem[]; } /** Merges parsed decision files; an id used twice is an error on the second file. */ export function mergeDecisions(parts: { file: string; decision?: Decision; problems: ModelProblem[] }[]): DecisionSet { const decisions = new Map(); const problems: ModelProblem[] = []; for (const part of parts) { problems.push(...part.problems); if (!part.decision) continue; const existing = decisions.get(part.decision.id); if (existing) { problems.push({ message: `Decision "${part.decision.id}" is defined twice (also in ${existing.file}).`, severity: 'error', file: part.file, line: part.decision.lines.id }); continue; } decisions.set(part.decision.id, part.decision); } return { decisions, files: parts.map((p) => p.file), problems }; } /** The status the pages show: a decision another one supersedes is superseded, whatever its file says. */ export function effectiveStatus(decision: Decision, all: Map): { status: string; supersededBy?: string } { for (const other of all.values()) { if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id }; } return { status: decision.status ?? 'proposed' }; } /** Link checks across the model: elements, diagrams and supersedes must exist. */ export function validateDecisions(set: DecisionSet, registry: Registry | undefined, diagramPaths: Set): ModelProblem[] { const problems: ModelProblem[] = []; for (const d of set.decisions.values()) { d.elements.forEach((id, i) => { if (!registry?.elements.has(id)) problems.push({ message: `Unknown element "${id}": no registry of this model defines it.`, severity: 'error', file: d.file, line: d.lines.elements[i], pointer: `/decision/elements/${i}` }); }); d.diagrams.forEach((path, i) => { if (!diagramPaths.has(path)) problems.push({ message: `Diagram "${path}" does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` }); }); d.supersedes.forEach((id, i) => { if (!set.decisions.has(id)) problems.push({ message: `Decision "${id}" (in supersedes) does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` }); else if (id === d.id) problems.push({ message: 'A decision cannot supersede itself.', severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` }); }); } return problems; } /** Sort for lists: live decisions newest first, superseded and rejected last. */ export function sortDecisions(decisions: Decision[], all: Map): Decision[] { const rank = (d: Decision) => (['superseded', 'rejected'].includes(effectiveStatus(d, all).status) ? 1 : 0); return [...decisions].sort((a, b) => rank(a) - rank(b) || (b.date ?? '').localeCompare(a.date ?? '') || a.id.localeCompare(b.id)); }