Architecture decisions: *.archify-decision files, decisions: keys in diagrams

- 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
This commit is contained in:
root-at-skic committed 2026-10-19 23:13:00 +03:00
1 parent 90a0c0d109
commit b169034b4f
15 files changed
+276 -11

No files matched your search

+4 -1
View File
@@ -4,6 +4,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { MAIN, findRoot, loadModel, presentProblem, relative, summarize } from '../../services/model.mjs';
import { effectiveStatus } from '../../services/decisions.mjs';
export const help = `model-list — metadata, diagrams by folder and registry elements of a model
--dir <path> model folder, or any file/folder inside it (required)`;
@@ -42,15 +43,17 @@ export async function run(input) {
file: element.file,
usedIn: model.usage[element.id] ?? 0,
}));
const decisions = Object.values(model.decisions.decisions).map((d) => ({ id: d.id, title: d.title, ...effectiveStatus(d, model.decisions.decisions), date: d.date, elements: d.elements, diagrams: d.diagrams, supersedes: d.supersedes, file: d.file, referencedBy: (model.decisionUsage[d.id] ?? []).map((u) => u.file) }));
const all = [...model.problems, ...model.diagrams.flatMap((entry) => entry.problems)];
return {
success: summarize(all).errors === 0,
root,
model: model.main?.model ? { id: model.main.model.id, name: model.main.model.name, description: model.main.model.description, hasDiagram: model.main.hasDiagram } : undefined,
summary: { diagrams: model.diagrams.length, groups: groups.size, registryFiles: model.registry.files.length, elements: elements.length, ...summarize(all) },
summary: { diagrams: model.diagrams.length, groups: groups.size, registryFiles: model.registry.files.length, elements: elements.length, decisions: decisions.length, ...summarize(all) },
diagrams: rootDiagrams,
groups: [...groups.entries()].map(([folder, diagrams]) => ({ folder, diagrams })),
registry: elements,
decisions,
problems: all.map(presentProblem),
};
}
@@ -60,7 +60,7 @@ export async function run(input) {
root,
model: model.main?.model ? { id: model.main.model.id, name: model.main.model.name } : undefined,
archify: archifyVersion(),
summary: { ...summary, diagrams: diagrams.length, diagramsOk: diagrams.filter((item) => item.ok).length, registryFiles: model.registry.files.length, elements: Object.keys(model.registry.elements).length },
summary: { ...summary, diagrams: diagrams.length, diagramsOk: diagrams.filter((item) => item.ok).length, registryFiles: model.registry.files.length, elements: Object.keys(model.registry.elements).length, decisions: Object.keys(model.decisions.decisions).length },
problems: model.problems.map(presentProblem),
diagrams,
};
+102
View File
@@ -0,0 +1,102 @@
// 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;
}
+13 -4
View File
@@ -4,6 +4,7 @@
// Formats follow FORMAT.md of the Archify Diagram Viewer design.
import fs from 'node:fs';
import { DECISION_EXT, loadDecisions, takeDecisionRefs, validateDecisions } from './decisions.mjs';
import path from 'node:path';
import { readSource } from './source.mjs';
import { receiptProblems, validateJson, withTempJson } from './archify.mjs';
@@ -57,6 +58,7 @@ export function relative(root, file) {
export function scanModel(root) {
const diagrams = [];
const registries = [];
const decisionFiles = [];
const nestedMains = [];
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
@@ -66,6 +68,7 @@ export function scanModel(root) {
if (!SKIP_DIRS.has(entry.name)) walk(full);
} else if (entry.name === MAIN && dir !== root) nestedMains.push(full);
else if (entry.name.endsWith(REGISTRY_EXT)) registries.push(full);
else if (entry.name.endsWith(DECISION_EXT)) decisionFiles.push(full);
else if (entry.name.endsWith(DIAGRAM_EXT)) diagrams.push(full);
}
};
@@ -79,7 +82,7 @@ export function scanModel(root) {
if (path.basename(b) === MAIN) return 1;
return a.localeCompare(b);
});
return { diagrams, registries, nestedMains };
return { diagrams, registries, decisionFiles, nestedMains };
}
function linkTarget(root, link) {
@@ -151,7 +154,7 @@ export function loadRegistry(root, registries) {
// Reads one diagram file and expands its ref nodes from the registry.
// `root` and `elements` are undefined outside a model.
export function resolveDiagram(file, { root, elements } = {}) {
export function resolveDiagram(file, { root, elements, decisionIds } = {}) {
const rel = root ? relative(root, file) : path.basename(file);
const isMain = path.basename(file) === MAIN;
const source = readSource(file);
@@ -181,6 +184,7 @@ export function resolveDiagram(file, { root, elements } = {}) {
const diagram = { ...data };
delete diagram.model;
result.decisionRefs = takeDecisionRefs(diagram, { known: decisionIds, inModel: Boolean(root), problem: (severity, message, pointer) => problems.push(problem(severity, rel, message, { line: source.lineOf(pointer), pointer })) });
const type = diagram.diagram_type;
if (type === undefined) {
if (!isMain) problems.push(problem('error', rel, `diagram_type is missing; one of ${TYPES.join(', ')}.`, { line: 1, pointer: '/diagram_type' }));
@@ -264,11 +268,16 @@ export function loadModel(root, options = {}) {
for (const nested of scan.nestedMains) {
problems.push(problem('error', relative(root, nested), `A second ${MAIN} inside a model would start a nested model; nested models are not supported. Move it out or remove it.`, { line: 1 }));
}
const diagrams = scan.diagrams.map((file) => resolveDiagram(file, { root, elements: registry.elements }));
const decisions = loadDecisions(scan.decisionFiles, (full) => relative(root, full));
const diagrams = scan.diagrams.map((file) => resolveDiagram(file, { root, elements: registry.elements, decisionIds: new Set(Object.keys(decisions.decisions)) }));
const main = diagrams.find((entry) => entry.isMain);
const usage = {};
for (const entry of diagrams) for (const ref of new Set(entry.refs)) usage[ref] = (usage[ref] ?? 0) + 1;
return { root, scan, registry, diagrams, main, usage, problems, options };
decisions.problems.push(...validateDecisions(decisions, registry.elements, new Set(diagrams.map((entry) => entry.file))));
problems.push(...decisions.problems);
const decisionUsage = {};
for (const entry of diagrams) for (const ref of entry.decisionRefs ?? []) (decisionUsage[ref.id] ??= []).push({ file: entry.file, attachedTo: ref.attachedTo });
return { root, scan, registry, decisions, decisionUsage, diagrams, main, usage, problems, options };
}
export function summarize(problems) {
+15 -1
View File
@@ -58,6 +58,17 @@ try {
notes.push('4 diagrams validated with 0 problems');
});
check('model-validate example: 3 decisions, all links resolve', ['--action', 'model-validate', '--dir', example], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' decisions') === '3', 'expected 3 decisions');
notes.push('3 decisions');
});
check('model-list example: decisions with status and referencing diagrams', ['--action', 'model-list', '--dir', example], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(/id: queue-between-api-and-worker\n\s+title: A queue between the API and the worker\n\s+status: accepted/.test(r.stdout), 'decision row missing');
assert(/referencedBy:\n\s+- 0200-containers\/online-shop\.archify/.test(r.stdout), 'referencedBy missing');
});
check('model-validate from a file inside the model', ['--action', 'model-validate', '--dir', path.join(example, '0300-components', 'api.archify')], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, 'mode') === 'model', 'root was not found from a file inside');
@@ -114,10 +125,13 @@ try {
path.join(broken, '0300-components', 'overlap.archify'),
'schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Overlap\n output: overlap.html\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n - ref: db\n pos: [60, 70]\n size: [130, 60]\n',
);
fs.writeFileSync(path.join(broken, 'decisions', 'bad.archify-decision'), 'schema_version: 1\ndecision:\n id: 0007\n title: Numeric\n');
fs.writeFileSync(path.join(broken, 'decisions', 'links.archify-decision'), 'schema_version: 1\ndecision:\n id: links\n title: Links\n status: maybe\n elements: [nobody]\n diagrams: [0900/x.archify]\n supersedes: [ghost]\n');
fs.appendFileSync(path.join(broken, '0300-components', 'bad.archify'), 'decisions: [no-such-decision]\n');
check('model-validate reports duplicates, unknown refs, overrides, links, ids, layout', ['--action', 'model-validate', '--dir', broken], (r, notes) => {
assert(r.status === 1, `exit ${r.status}, expected 1`);
const text = r.stdout.replace(/\\"/g, '"');
for (const expected of ['defined twice', 'Unknown element "nobody"', '"type" of shared element "db"', 'does not exist under', 'Duplicate id "a"', 'less than 8px apart']) {
for (const expected of ['defined twice', 'Unknown element "nobody"', '"type" of shared element "db"', 'does not exist under', 'Duplicate id "a"', 'less than 8px apart', 'is a number to YAML', 'Unknown status "maybe"', 'Diagram "0900/x.archify" does not exist', 'Decision "ghost" (in supersedes) does not exist', 'Unknown decision "no-such-decision"']) {
assert(text.includes(expected), `missing problem: ${expected}`);
}
assert(/pointer: \/components\/0\/ref/.test(text) && /line: 7/.test(text), 'pointer/line of the unknown ref missing');