From b169034b4f5868234b65d90dff25ef29fa543e90 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Mon, 19 Oct 2026 23:13:00 +0300 Subject: [PATCH] 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 --- SKILL.md | 3 +- .../0200-containers/online-shop.archify | 4 +- ...stgres-as-source-of-truth.archify-decision | 20 ++++ ...ue-between-api-and-worker.archify-decision | 20 ++++ ...search-for-product-search.archify-decision | 20 ++++ .../0200-containers/platform.archify | 2 +- .../dead-letter-queue.archify-decision | 18 ++++ .../0200-containers/web-app.archify | 2 +- ...xternal-identity-provider.archify-decision | 17 +++ references/formats.md | 39 +++++++ scripts/src/actions/model-list/action.mjs | 5 +- scripts/src/actions/model-validate/action.mjs | 2 +- scripts/src/services/decisions.mjs | 102 ++++++++++++++++++ scripts/src/services/model.mjs | 17 ++- scripts/src/test/run.mjs | 16 ++- 15 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 examples/c4-e-shop/decisions/0001-postgres-as-source-of-truth.archify-decision create mode 100644 examples/c4-e-shop/decisions/0002-queue-between-api-and-worker.archify-decision create mode 100644 examples/c4-e-shop/decisions/0003-opensearch-for-product-search.archify-decision create mode 100644 examples/c4-event-based-integration/decisions/dead-letter-queue.archify-decision create mode 100644 examples/c4-standard-web-app/decisions/external-identity-provider.archify-decision create mode 100644 scripts/src/services/decisions.mjs diff --git a/SKILL.md b/SKILL.md index e6a6811..e93e253 100644 --- a/SKILL.md +++ b/SKILL.md @@ -38,7 +38,7 @@ task --list-all | Task | What it does | |---|---| | `task model:validate -- --dir=""` | Registry, every diagram (refs resolved, Archify validator), every `diagram:` link. Exit 1 on any error. Works on a single diagram outside a model too. | -| `task model:list -- --dir=""` | What the model page shows: metadata, diagrams by folder, registry elements with "used in N diagrams". | +| `task model:list -- --dir=""` | What the model page shows: metadata, diagrams by folder, registry elements with "used in N diagrams", decisions with status and the diagrams that reference them. | | `task model:scaffold -- --dir="" --id="" --name="" [--description="…"] [--c4]` | Creates `main.archify`; with `--c4` also `registry/` and the `0100-context`, `0200-containers`, `0300-components` folders with starter diagrams. Never overwrites. | | `task model:scaffold -- --dir="" --example="" [--id=""] [--name=""] [--description="…"]` | Copies a complete example model: `c4-standard-web-app`, `c4-e-shop` or `c4-event-based-integration` (the same three the extension offers under *Archify \| New Model…*). Id, name and description default to the example's own. Never overwrites. | | `task model:resolve -- --file="" [--out=""]` | The Archify JSON the extension hands to Archify, for the plain Archify CLI or CI. | @@ -175,6 +175,7 @@ Diagrams next to `main.archify` are listed first, ungrouped; deeper ones by fold - **Links are relative to the model root**, never to the file, and must end in `.archify` and exist. `..` is refused. - **A second `main.archify` deeper down** is reported, not nested. One model per tree. - **Diagram kinds other than architecture** take `ref` too (workflow and dataflow `nodes`, sequence `participants`, lifecycle `states`), but the registry `type` must fit that kind: lifecycle states use their own type enum, and sequence participants have no `tag`. +- **Decisions are files, ids are words.** One `*.archify-decision` per decision (`decision:` block with id, title, status, date, elements, diagrams, supersedes; prose in context, decision, consequences). Diagrams point at them with `decisions: []` on the diagram, a node or an edge; the resolver strips the key. An unquoted numeric id (`0001`) is refused because YAML reads it as 1. When an agent changes the shape of a model (a new store, a new queue, a new boundary), it writes a decision and links the elements it touched. - **Descriptions stay in the registry.** The extension shows an element's description as a tooltip in its Elements panel; nothing is generated into the diagram. Write `cards:` in a diagram only when you want text blocks below it. - **No em dashes or smart quotes in ids**; ids match `^[a-zA-Z][a-zA-Z0-9_-]*$`. diff --git a/examples/c4-e-shop/0200-containers/online-shop.archify b/examples/c4-e-shop/0200-containers/online-shop.archify index 8d960d8..d764ca6 100644 --- a/examples/c4-e-shop/0200-containers/online-shop.archify +++ b/examples/c4-e-shop/0200-containers/online-shop.archify @@ -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] } diff --git a/examples/c4-e-shop/decisions/0001-postgres-as-source-of-truth.archify-decision b/examples/c4-e-shop/decisions/0001-postgres-as-source-of-truth.archify-decision new file mode 100644 index 0000000..12a8db9 --- /dev/null +++ b/examples/c4-e-shop/decisions/0001-postgres-as-source-of-truth.archify-decision @@ -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. diff --git a/examples/c4-e-shop/decisions/0002-queue-between-api-and-worker.archify-decision b/examples/c4-e-shop/decisions/0002-queue-between-api-and-worker.archify-decision new file mode 100644 index 0000000..b6ae73b --- /dev/null +++ b/examples/c4-e-shop/decisions/0002-queue-between-api-and-worker.archify-decision @@ -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. diff --git a/examples/c4-e-shop/decisions/0003-opensearch-for-product-search.archify-decision b/examples/c4-e-shop/decisions/0003-opensearch-for-product-search.archify-decision new file mode 100644 index 0000000..c082e78 --- /dev/null +++ b/examples/c4-e-shop/decisions/0003-opensearch-for-product-search.archify-decision @@ -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. diff --git a/examples/c4-event-based-integration/0200-containers/platform.archify b/examples/c4-event-based-integration/0200-containers/platform.archify index 61246f2..8a33796 100644 --- a/examples/c4-event-based-integration/0200-containers/platform.archify +++ b/examples/c4-event-based-integration/0200-containers/platform.archify @@ -43,4 +43,4 @@ connections: - { from: router, to: store, label: append } - { from: router, to: notifier, label: notify } - { from: router, to: analytics, label: flatten } - - { from: router, to: dlq, label: failed, variant: dashed, labelDy: 24 } + - { from: router, to: dlq, label: failed, variant: dashed, labelDy: 24, decisions: [dead-letter-queue] } diff --git a/examples/c4-event-based-integration/decisions/dead-letter-queue.archify-decision b/examples/c4-event-based-integration/decisions/dead-letter-queue.archify-decision new file mode 100644 index 0000000..ce4fc12 --- /dev/null +++ b/examples/c4-event-based-integration/decisions/dead-letter-queue.archify-decision @@ -0,0 +1,18 @@ +schema_version: 1 +decision: + id: dead-letter-queue + title: Failed events go to a dead-letter queue, never dropped + status: accepted + date: 2026-09-24 + elements: [router, dlq, ops] + diagrams: [0200-containers/platform.archify, 0300-components/router.archify] +context: > + Events that failed schema validation were logged and lost. + Nobody noticed until a customer asked where an order went. +decision: > + The router moves every event it cannot process to a + dead-letter queue with the reason. Operations replays + them after the cause is fixed. +consequences: > + Nothing is lost silently. Someone has to watch the queue; + that is an on-call duty for operations. diff --git a/examples/c4-standard-web-app/0200-containers/web-app.archify b/examples/c4-standard-web-app/0200-containers/web-app.archify index cef2e3f..378063e 100644 --- a/examples/c4-standard-web-app/0200-containers/web-app.archify +++ b/examples/c4-standard-web-app/0200-containers/web-app.archify @@ -32,7 +32,7 @@ boundaries: connections: - { from: user, to: spa, label: HTTPS, variant: emphasis } - { from: spa, to: api, label: JSON } - - { from: api, to: idp, label: verify token, variant: security, labelAt: [531, 110] } + - { from: api, to: idp, label: verify token, variant: security, labelAt: [531, 110], decisions: [external-identity-provider] } - { from: api, to: cache, label: read } - { from: api, to: db, label: SQL } - { from: api, to: storage, label: files } diff --git a/examples/c4-standard-web-app/decisions/external-identity-provider.archify-decision b/examples/c4-standard-web-app/decisions/external-identity-provider.archify-decision new file mode 100644 index 0000000..b9bb1b1 --- /dev/null +++ b/examples/c4-standard-web-app/decisions/external-identity-provider.archify-decision @@ -0,0 +1,17 @@ +schema_version: 1 +decision: + id: external-identity-provider + title: Sign in through an identity provider, never store passwords + status: accepted + date: 2026-09-10 + elements: [idp, api, spa] + diagrams: [0200-containers/web-app.archify] +context: > + The first version kept password hashes in the database and + had to handle resets, lockouts and two-factor codes itself. +decision: > + Users sign in through an OpenID Connect identity provider. + The single-page app gets a token; the API only verifies it. +consequences: > + No password tables, no reset flows. The identity provider + is a hard dependency: when it is down, nobody can sign in. diff --git a/references/formats.md b/references/formats.md index bb0d8a4..b8d8d17 100644 --- a/references/formats.md +++ b/references/formats.md @@ -100,3 +100,42 @@ For each diagram file under the root (and a single file outside a model): 5. Hand the result to `archify validate --json` (or `render`). `task model:resolve -- --file=… --out=x.json` writes step 4's result. + +## `name.archify-decision` + +One architecture decision of the model, anywhere under the root (`decisions/` by convention): + +```yaml +schema_version: 1 +decision: + id: queue-between-api-and-worker # unique in the model; words, or a quoted number + title: A queue between the API and the worker + status: accepted # proposed | accepted | superseded | rejected + date: 2026-09-16 + elements: [api, queue, worker] # registry ids + diagrams: [0200-containers/online-shop.archify] + supersedes: [] # ids of older decisions this one replaces +context: > + Why a decision was needed. +decision: > + What was decided. +consequences: > + What becomes easier, harder, or must change. +``` + +Checks (`model:validate`): id present and not a bare number, status known, every element in a registry, every diagram present, every superseded id present. A decision that another one supersedes is shown as superseded whatever its own status says. + +Diagrams point back with a `decisions` list on the diagram itself, on a node (component, node, participant, state) and on an edge (connection, edge, flow, transition, message): + +```yaml +decisions: [postgres-as-source-of-truth] +components: + - ref: queue + pos: [460, 360] + size: [130, 60] + decisions: [queue-between-api-and-worker] +connections: + - { from: api, to: queue, label: enqueue, decisions: [queue-between-api-and-worker] } +``` + +The resolver strips the key before Archify sees the diagram and reports an unknown id at its line. Outside a model the key is an error, like `ref`. The extension shows decisions on the `main.archify` page, in a Decisions panel under each diagram, on element cards, and opens a decision file as its own page. diff --git a/scripts/src/actions/model-list/action.mjs b/scripts/src/actions/model-list/action.mjs index e1cc92d..cf6006f 100644 --- a/scripts/src/actions/model-list/action.mjs +++ b/scripts/src/actions/model-list/action.mjs @@ -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 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), }; } diff --git a/scripts/src/actions/model-validate/action.mjs b/scripts/src/actions/model-validate/action.mjs index 3b50a2d..27bc9b0 100644 --- a/scripts/src/actions/model-validate/action.mjs +++ b/scripts/src/actions/model-validate/action.mjs @@ -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, }; diff --git a/scripts/src/services/decisions.mjs b/scripts/src/services/decisions.mjs new file mode 100644 index 0000000..11510a9 --- /dev/null +++ b/scripts/src/services/decisions.mjs @@ -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; +} diff --git a/scripts/src/services/model.mjs b/scripts/src/services/model.mjs index fbe2fe5..5e124f5 100644 --- a/scripts/src/services/model.mjs +++ b/scripts/src/services/model.mjs @@ -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) { diff --git a/scripts/src/test/run.mjs b/scripts/src/test/run.mjs index a065cdd..f73020e 100644 --- a/scripts/src/test/run.mjs +++ b/scripts/src/test/run.mjs @@ -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');