- parse, merge and validate decisions (ids, status, elements, diagrams, supersedes); strip decisions keys from diagrams and check the ids - model:validate counts decisions; model:list lists them with status and the diagrams that reference them; examples carry decisions - formats.md and SKILL.md describe the format and when an agent writes one
103 lines
7.6 KiB
JavaScript
103 lines
7.6 KiB
JavaScript
// Architecture decisions of a model: one *.archify-decision file each, anywhere
|
|
// under the model root. Mirrors the extension's src/model/decisions.ts.
|
|
import fs from 'node:fs';
|
|
import { isMap, isScalar, isSeq, parseDocument } from '../../vendor/yaml/index.js';
|
|
|
|
export const DECISION_EXT = '.archify-decision';
|
|
export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'];
|
|
|
|
const str = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
|
|
|
|
export function parseDecision(text, file) {
|
|
const problems = [];
|
|
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
|
|
for (const error of doc.errors) problems.push({ severity: 'error', file, message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, line: error.linePos?.[0].line ?? 1 });
|
|
if (doc.errors.length) return { problems };
|
|
const lineOf = (offset) => (offset === undefined ? 1 : text.slice(0, offset).split('\n').length);
|
|
const root = doc.contents;
|
|
if (!isMap(root)) return { problems: [{ severity: 'error', file, message: 'A decision file is a mapping with a "decision" block and context, decision and consequences.', line: 1 }] };
|
|
const headNode = root.get('decision', true);
|
|
if (!isMap(headNode)) return { problems: [{ severity: 'error', file, message: 'A decision needs a "decision" block with id, title and status.', line: 1, pointer: '/decision' }] };
|
|
const headPair = root.items.find((p) => isScalar(p.key) && p.key.value === 'decision');
|
|
const headLine = headPair ? lineOf(headPair.key.range?.[0]) : 1;
|
|
const keyLine = (key) => { const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key); return pair ? lineOf(pair.key.range?.[0]) : headLine; };
|
|
const list = (key) => {
|
|
const node = headNode.get(key, true);
|
|
const values = []; const lines = [];
|
|
if (isSeq(node)) {
|
|
for (const item of node.items) {
|
|
if (!isScalar(item)) continue;
|
|
if (typeof item.value === 'number') { problems.push({ severity: 'error', file, message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` }); continue; }
|
|
if (str(item.value)) { values.push(item.value); lines.push(lineOf(item.range?.[0])); }
|
|
}
|
|
} else if (node !== undefined) problems.push({ severity: 'error', file, message: `"${key}" must be a list.`, line: keyLine(key), pointer: `/decision/${key}` });
|
|
return { values, lines };
|
|
};
|
|
const head = headNode.toJSON();
|
|
if (typeof head.id === 'number') { problems.push({ severity: 'error', file, message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, line: keyLine('id'), pointer: '/decision/id' }); return { problems }; }
|
|
const id = str(head.id);
|
|
if (!id) { problems.push({ severity: 'error', file, message: 'decision.id is missing.', line: headLine, pointer: '/decision/id' }); return { problems }; }
|
|
const status = str(head.status);
|
|
if (status && !DECISION_STATUSES.includes(status)) problems.push({ severity: 'error', file, message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, line: keyLine('status'), pointer: '/decision/status' });
|
|
const elements = list('elements'); const diagrams = list('diagrams'); const supersedes = list('supersedes');
|
|
const body = root.toJSON();
|
|
return { problems, decision: { id, title: str(head.title), status, date: head.date === undefined ? undefined : String(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'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines } } };
|
|
}
|
|
|
|
export function loadDecisions(files, relative) {
|
|
const decisions = {}; const problems = [];
|
|
for (const full of files) {
|
|
const file = relative(full);
|
|
const parsed = parseDecision(fs.readFileSync(full, 'utf8'), file);
|
|
problems.push(...parsed.problems);
|
|
if (!parsed.decision) continue;
|
|
const existing = decisions[parsed.decision.id];
|
|
if (existing) { problems.push({ severity: 'error', file, message: `Decision "${parsed.decision.id}" is defined twice (also in ${existing.file}).`, line: parsed.decision.lines.id }); continue; }
|
|
decisions[parsed.decision.id] = parsed.decision;
|
|
}
|
|
return { decisions, files: files.map(relative), problems };
|
|
}
|
|
|
|
export function effectiveStatus(decision, all) {
|
|
for (const other of Object.values(all)) if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id };
|
|
return { status: decision.status ?? 'proposed' };
|
|
}
|
|
|
|
export function validateDecisions(set, elements, diagramPaths) {
|
|
const problems = [];
|
|
for (const d of Object.values(set.decisions)) {
|
|
d.elements.forEach((id, i) => { if (!elements?.[id]) problems.push({ severity: 'error', file: d.file, message: `Unknown element "${id}": no registry of this model defines it.`, line: d.lines.elements[i], pointer: `/decision/elements/${i}` }); });
|
|
d.diagrams.forEach((p, i) => { if (!diagramPaths.has(p)) problems.push({ severity: 'error', file: d.file, message: `Diagram "${p}" does not exist in this model.`, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` }); });
|
|
d.supersedes.forEach((id, i) => {
|
|
if (!set.decisions[id]) problems.push({ severity: 'error', file: d.file, message: `Decision "${id}" (in supersedes) does not exist in this model.`, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
|
|
else if (id === d.id) problems.push({ severity: 'error', file: d.file, message: 'A decision cannot supersede itself.', line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
|
|
});
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'];
|
|
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'];
|
|
|
|
/** Strips `decisions` keys from a diagram (mutating it) and returns what they pointed at, with problems. */
|
|
export function takeDecisionRefs(diagram, { known, inModel, problem }) {
|
|
const refs = [];
|
|
const take = (owner, pointer, attachedTo) => {
|
|
if (!('decisions' in owner)) return;
|
|
const raw = owner.decisions; delete owner.decisions;
|
|
(Array.isArray(raw) ? raw : [raw]).forEach((value, i) => {
|
|
if (value === undefined || value === null) return;
|
|
const at = `${pointer}/decisions/${i}`;
|
|
if (typeof value === 'number') return problem('error', `Decision id ${value} is a number to YAML (0001 becomes 1). Quote it ("${value}") or use words.`, at);
|
|
const id = String(value);
|
|
if (!inModel) problem('error', `Decision "${id}" cannot be resolved: no main.archify above this file.`, at);
|
|
else if (known && !known.has(id)) problem('error', `Unknown decision "${id}": no .archify-decision file of this model defines it.`, at);
|
|
refs.push({ id, pointer: at, attachedTo });
|
|
});
|
|
};
|
|
take(diagram, '', { kind: 'diagram' });
|
|
for (const key of NODE_COLLECTIONS) if (Array.isArray(diagram[key])) diagram[key] = diagram[key].map((item, index) => { if (!item || typeof item !== 'object') return item; const copy = { ...item }; take(copy, `/${key}/${index}`, { kind: 'node', id: copy.id ?? copy.ref ?? String(index) }); return copy; });
|
|
for (const key of EDGE_COLLECTIONS) if (Array.isArray(diagram[key])) diagram[key] = diagram[key].map((item, index) => { if (!item || typeof item !== 'object') return item; const copy = { ...item }; take(copy, `/${key}/${index}`, { kind: 'edge', from: copy.from ?? '', to: copy.to ?? '', label: copy.label }); return copy; });
|
|
return refs;
|
|
}
|