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:
@@ -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
@@ -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
@@ -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] }
|
||||
|
||||
+20
@@ -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.
|
||||
+20
@@ -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.
|
||||
+20
@@ -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.
|
||||
@@ -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', () =>
|
||||
|
||||
Reference in New Issue
Block a user