Architecture decisions: files, diagram references, views and panels

- *.archify-decision files: id (unique, words), title, status, date,
  elements, diagrams, supersedes, and prose; parsed, merged and link-checked
  in the model index; diagnostics on the file
- diagrams point at decisions with decisions: [...] on the diagram, nodes
  and edges; stripped before Archify, unknown ids reported, completion,
  hover and go-to-definition for the ids and for elements/diagrams/
  supersedes in decision files
- Decisions panel on the main.archify page; Decisions rail panel under a
  diagram (hover lights up the node or connection); decisions on element
  cards and in the element dialog; decision view for the file, with a
  lost-model note outside a model
- Archify | New Decision… command and template; editor schemas; the three
  New Model examples carry decisions; docs, changelog, 0.8.0
This commit is contained in:
2026-10-19 23:13:05 +03:00
parent 33ebdd6328
commit ebf320a023
49 changed files with 1637 additions and 55 deletions
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { parse } from 'yaml';
import { effectiveStatus, mergeDecisions, parseDecision, slugify, sortDecisions, validateDecisions } from '../src/model/decisions';
import { mergeRegistries, parseRegistry, resolveRefs, summarizeDiagram } from '../src/model/format';
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
function registryOfModel() {
const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) })));
}
function decisionsOfModel() {
return mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
}
test('decision files parse with lines for every linked id', () => {
const { decision, problems } = parseDecision(read(DECISION_FILES[1]), 'd2');
assert.deepEqual(problems, []);
assert.ok(decision);
assert.equal(decision.id, 'queue-between-api-and-worker');
assert.equal(decision.title, 'A queue between the API and the worker');
assert.equal(decision.status, 'accepted');
assert.deepEqual(decision.elements, ['api', 'queue', 'worker']);
assert.deepEqual(decision.diagrams, ['0200-containers/online-shop.archify']);
assert.equal(decision.lines.elements.length, 3);
assert.ok(decision.context && decision.decision && decision.consequences);
});
test('decision files: broken YAML, missing block, unknown status', () => {
assert.equal(parseDecision('decision: {\n', 'x').problems[0].severity, 'error');
assert.match(parseDecision('context: only text\n', 'x').problems[0].message, /"decision" block/);
const bad = parseDecision('decision:\n id: a\n title: A\n status: maybe\n elements: api\n', 'x');
assert.deepEqual(bad.problems.map((p) => p.message.split(';')[0]), ['Unknown status "maybe"', '"elements" must be a list.']);
assert.equal(bad.problems[0].line, 3);
});
test('decisions merge, validate links, and the superseded rule applies', () => {
const set = decisionsOfModel();
assert.deepEqual(set.problems, []);
assert.equal(set.decisions.size, 3);
const registry = registryOfModel();
const diagrams = new Set(['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']);
assert.deepEqual(validateDecisions(set, registry, diagrams), []);
// Duplicate id, unknown element, missing diagram, dangling supersedes.
const extra = parseDecision('decision:\n id: queue-between-api-and-worker\n title: Dup\n elements: [ghost]\n diagrams: [0900/x.archify]\n supersedes: [nope]\n', 'dup');
const merged = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'dup', ...extra }]);
assert.match(merged.problems[0].message, /defined twice/);
const newer = parseDecision('decision:\n id: one-database\n title: Replace the first\n status: accepted\n supersedes: [postgres-as-source-of-truth]\n', 'd4');
const withNewer = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'd4', ...newer }]);
const problems = validateDecisions(mergeDecisions([{ file: 'dup', ...extra }]), registry, diagrams);
assert.deepEqual(problems.map((p) => p.message.split(':')[0]), ['Unknown element "ghost"', 'Diagram "0900/x.archify" does not exist in this model.', 'Decision "nope" (in supersedes) does not exist in this model.']);
assert.deepEqual(effectiveStatus(withNewer.decisions.get('postgres-as-source-of-truth')!, withNewer.decisions), { status: 'superseded', supersededBy: 'one-database' });
assert.deepEqual(effectiveStatus(withNewer.decisions.get('queue-between-api-and-worker')!, withNewer.decisions), { status: 'accepted' });
const order = sortDecisions([...withNewer.decisions.values()], withNewer.decisions).map((d) => d.id);
assert.equal(order[order.length - 1], 'postgres-as-source-of-truth', 'superseded sorts last');
assert.equal(slugify('OpenSearch for product search!'), 'opensearch-for-product-search');
// Numeric ids are a YAML trap and are refused on both sides.
assert.match(parseDecision('decision:\n id: 0001\n title: N\n', 'n').problems[0].message, /is a number to YAML/);
assert.match(parseDecision('decision:\n id: ok\n title: N\n supersedes: [0001]\n', 'n').problems[0].message, /is a number to YAML/);
});
test('diagrams: decisions keys are stripped, collected and checked', () => {
const registry = registryOfModel();
const known = new Set(decisionsOfModel().decisions.keys());
const doc = parse(read('0200-containers/online-shop.archify'));
const r = resolveRefs(doc, { registry, decisions: known, fileName: 'c.archify' });
assert.deepEqual(r.problems, []);
assert.ok(!('decisions' in r.diagram));
const components = r.diagram.components as Record<string, unknown>[];
const connections = r.diagram.connections as Record<string, unknown>[];
assert.ok(components.every((c) => !('decisions' in c)) && connections.every((c) => !('decisions' in c)));
assert.deepEqual(r.decisionRefs.map((d) => [d.id, d.attachedTo.kind, d.pointer]), [
['postgres-as-source-of-truth', 'diagram', '/decisions/0'],
['queue-between-api-and-worker', 'node', '/components/5/decisions/0'],
['queue-between-api-and-worker', 'edge', '/connections/4/decisions/0'],
]);
const numeric = resolveRefs({ decisions: [1], components: [] }, { registry, decisions: known, fileName: 'x.archify' });
assert.match(numeric.problems[0].message, /is a number to YAML/);
const unknown = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0], decisions: ['nope'] }] }, { registry, decisions: known, fileName: 'x.archify' });
assert.equal(unknown.problems[0].pointer, '/components/0/decisions/0');
assert.match(unknown.problems[0].message, /Unknown decision "nope"/);
const outside = resolveRefs({ decisions: ['postgres-as-source-of-truth'], components: [] }, { fileName: '/w/loose.archify' });
assert.match(outside.problems[0].message, /not inside a model/);
// Other kinds: nodes and edges carry the key too.
const workflow = resolveRefs({ nodes: [{ id: 'n', type: 'process', label: 'N', decisions: ['postgres-as-source-of-truth'] }], edges: [{ from: 'n', to: 'n', decisions: ['queue-between-api-and-worker'] }] }, { registry, decisions: known, fileName: 'w.archify' });
assert.deepEqual(workflow.decisionRefs.map((d) => d.attachedTo.kind), ['node', 'edge']);
assert.ok(!('decisions' in (workflow.diagram.nodes as Record<string, unknown>[])[0]));
const summary = summarizeDiagram(read('0200-containers/online-shop.archify'), '0200-containers/online-shop.archify');
assert.deepEqual(summary.decisions.map((d) => d.id), ['postgres-as-source-of-truth', 'queue-between-api-and-worker', 'queue-between-api-and-worker']);
});
+11 -1
View File
@@ -7,6 +7,7 @@ import { parse } from 'yaml';
import { validate } from '../src/archify/cli';
import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples';
import { MAIN_FILE, REGISTRY_EXT, hasDiagramBody, mergeRegistries, parseRegistry, readModelMeta, resolveRefs } from '../src/model/format';
import { DECISION_EXT, mergeDecisions, parseDecision, validateDecisions } from '../src/model/decisions';
import { readDiagramSource } from '../src/sources/detect';
const root = path.resolve(__dirname, '..', '..');
@@ -58,6 +59,15 @@ for (const example of MODEL_EXAMPLES) {
if (element.diagram) assert.ok(files.includes(element.diagram), `${element.id} links to missing ${element.diagram}`);
}
// Decisions: at least one, all links resolve.
const decisionFiles = files.filter((f) => f.endsWith(DECISION_EXT));
assert.ok(decisionFiles.length >= 1, 'an example carries at least one decision');
const decisions = mergeDecisions(decisionFiles.map((file) => ({ file, ...parseDecision(read(file), file) })));
assert.deepEqual(decisions.problems, []);
const diagramPaths = new Set(files.filter((f) => f.endsWith('.archify') && f !== MAIN_FILE));
assert.deepEqual(validateDecisions(decisions, registry, diagramPaths), []);
for (const d of decisions.decisions.values()) assert.ok(d.title && d.status && d.context && d.decision && d.consequences, `${d.id} is complete`);
// Diagrams: the three C4 levels, each resolving without problems and valid for Archify.
const diagrams = files.filter((f) => f.endsWith('.archify')).sort();
for (const level of ['0100-context', '0200-containers', '0300-components']) {
@@ -67,7 +77,7 @@ for (const example of MODEL_EXAMPLES) {
for (const file of diagrams) {
const source = readDiagramSource(read(file), file);
assert.ok(source, `${file} is not a diagram`);
const resolved = resolveRefs(source.diagram, { registry, fileName: file });
const resolved = resolveRefs(source.diagram, { registry, decisions: new Set(decisions.decisions.keys()), fileName: file });
assert.deepEqual(resolved.problems, [], file);
for (const link of resolved.links.values()) assert.ok(files.includes(link), `${file} links to missing ${link}`);
for (const id of resolved.used) used.add(id);
+3 -1
View File
@@ -3,6 +3,7 @@ diagram_type: architecture
meta:
title: Containers
output: containers.html
decisions: [postgres-as-source-of-truth]
components:
- ref: customer
pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue
pos: [460, 360]
size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker
pos: [670, 460]
size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
- { from: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: postgres-as-source-of-truth
title: PostgreSQL as the single source of truth
status: accepted
date: 2026-09-02
elements: [db, api, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Orders, customers and the catalogue were spread over the
shop database and two spreadsheets kept by support.
Refunds were reconciled by hand every Monday.
decision: >
One PostgreSQL database owns orders, customers and the
catalogue. The API is the only writer. Everything else
(cache, search, reports) is derived and may be rebuilt.
consequences: >
Support loses the spreadsheets and gets the admin console.
Any new store must be declared derived or get its own
decision. Backups and migrations are the API team's job.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: queue-between-api-and-worker
title: A queue between the API and the worker
status: accepted
date: 2026-09-16
elements: [api, queue, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Order confirmation emails and invoices were sent inside
the checkout request. A slow mail provider made checkout
slow; a failing one made checkout fail.
decision: >
The API enqueues a job per side effect on SQS. One worker
service consumes the queue and does the slow work.
Checkout returns as soon as the order is stored.
consequences: >
Emails arrive seconds later, not instantly. Jobs must be
idempotent because SQS delivers at least once. The worker
is the second place that writes to the database.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: opensearch-for-product-search
title: OpenSearch for product search
status: proposed
date: 2026-10-19
elements: [api, worker, db]
diagrams: [0200-containers/online-shop.archify]
context: >
Catalogue search runs as SQL LIKE queries on the
database. Typos find nothing and the catalogue page is
the slowest page in the shop.
decision: >
Add a dedicated search service on OpenSearch. The API
queries it; the worker rebuilds the index from the
database after every catalogue change.
consequences: >
The index is derived data under decision 0001 and may be
rebuilt at any time. One more service to run. Search
results can lag the database by the rebuild interval.
+21 -1
View File
@@ -329,6 +329,26 @@ export async function run(): Promise<void> {
assert.equal(editor.document.languageId, 'yaml');
});
await check('a .archify-decision file opens as the decision view; an unknown decision id is a problem in a diagram', async () => {
const decision = workspaceFile('model/decisions/0002-queue-between-api-and-worker.archify-decision');
await vscode.commands.executeCommand('vscode.open', decision);
const tab = await waitFor('decision view tab', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.decision' && t.input.uri.toString() === decision.toString()),
);
assert.ok(tab);
// A diagram that points at a decision nobody defines.
const bad = workspaceFile('model/0300-components/bad-decision.archify');
await vscode.workspace.fs.writeFile(bad, Buffer.from('schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ndecisions: [nobody-decided]\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n decisions: [queue-between-api-and-worker]\n', 'utf8'));
const doc = await vscode.workspace.openTextDocument(bad);
await vscode.window.showTextDocument(doc);
const problem = await waitFor('unknown decision problem', () =>
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown decision "nobody-decided"/.test(d.message)),
);
assert.equal(problem.range.start.line, 5);
assert.ok(!vscode.languages.getDiagnostics(doc.uri).some((d) => /queue-between-api-and-worker/.test(d.message)), 'the known decision is not reported');
await vscode.workspace.fs.delete(bad);
});
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
const folder = workspaceFile('new-shop');
await vscode.workspace.fs.createDirectory(folder);
@@ -385,7 +405,7 @@ export async function run(): Promise<void> {
assert.match(content, /name: Shop two/);
assert.match(content, /title: Shop two — overview/);
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort();
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'main.archify', 'registry']);
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'decisions', 'main.archify', 'registry']);
const registry = (await vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(folder, 'registry'))).map(([name]) => name).sort();
assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
const tab = await waitFor('model page tab', () =>