diff --git a/CHANGELOG.md b/CHANGELOG.md index 279bb61..ff17f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog -## 0.7.2 — 2026-10-19 +## 0.8.0 — 2026-10-19 -- Model page: the panel that lists the registry files is titled **Registries**. +- **Architecture decisions.** A model can hold decisions: one `*.archify-decision` file each, anywhere under the model root, with an id (unique in the model), title, status, date, the registry `elements` and `diagrams` it is about, `supersedes`, and prose for context, decision and consequences. +- **Diagrams point at decisions** with `decisions: []` on the diagram, on a component, node or participant, and on a connection, edge, flow, transition or message. The key is stripped before Archify renders. Unknown ids, and the key outside a model, are reported in Problems. Completion, hover and go-to-definition for decision ids; in decision files the same for `elements`, `diagrams` and `supersedes`. +- **Where they show.** A **Decisions** panel on the `main.archify` page (one list, live decisions first, superseded and rejected dimmed and last). A **Decisions** rail panel under a diagram with the decisions it points at or that name it; hovering a row lights up the node or connection it is attached to, clicking opens the decision. A decision view when a `.archify-decision` file is opened: status, date, context, decision, consequences, related decisions, the elements as cards, the diagrams with where the decision is attached. Decisions also appear on element cards and in the element dialog. +- **Authoring.** *Archify | New Decision…* on a folder inside a model creates a decision file from a template, with the id taken from the title. Editor schema for decision files and the new `decisions` key. A decision outside a model opens, but the page and Problems say it has lost its model. +- The three New Model examples carry decisions. Model page: the registry files panel is titled **Registries**. ## 0.7.1 — 2026-10-19 diff --git a/README.md b/README.md index e2052e2..b00b56d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam - **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. -- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*. +- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*. ## 3D view @@ -111,6 +111,8 @@ online-shop/ people.archify-registry shared elements: one name for one thing systems.archify-registry containers.archify-registry + decisions/ + 0002-queue-between-api-and-worker.archify-decision 0100-context/online-shop.archify 0200-containers/online-shop.archify 0300-components/api.archify @@ -120,6 +122,7 @@ online-shop/ - **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems. - **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it. - **Descriptions.** Under a model diagram, hovering a row of the **Elements** panel shows the registry description of that element. +- **Decisions.** One `*.archify-decision` file per architecture decision: id, title, status, date, the registry elements and diagrams it is about, and prose for context, decision and consequences. Diagrams point at decisions with `decisions: []` on the diagram, a component or a connection. They show as a **Decisions** panel on the `main.archify` page, a **Decisions** rail panel under each diagram (hover lights up what the decision is attached to), on element cards and in the element dialog, and a decision opens as its own page. *Archify | New Decision…* creates one; links are checked in Problems. - **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it. - **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it. - **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`. diff --git a/docs/model.md b/docs/model.md index f2e0c36..af117d9 100644 --- a/docs/model.md +++ b/docs/model.md @@ -88,3 +88,42 @@ A diagram with `ref` components outside any model (no `main.archify` above it) r ## Starting a model *Archify | New Model…* (right-click a folder, *File → New File…*, Command Palette) offers three complete examples: **C4 - Standard Web App**, **C4 - E-Shop** and **C4 - Event-Based Integration**. Pick one and a folder name; the example is copied into that folder with `model.id`, `model.name` and the overview title taken from the folder name (an example kept under its own name is copied as is). Each example has `registry/{people,systems,containers}.archify-registry` and diagrams under `0100-context`, `0200-containers` and `0300-components`; every registry element is used by at least one diagram and every diagram validates. + +## Decisions + +An architecture decision is one `*.archify-decision` file, anywhere under the model root (`decisions/` is the convention): + +```yaml +schema_version: 1 +decision: + id: queue-between-api-and-worker # unique in the model, any short string + 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. +``` + +Ids are strings; an unquoted number such as `0001` is refused because YAML reads it as 1. A decision another decision supersedes shows as superseded whatever its own status says. Unknown elements, missing diagrams, duplicate ids, unknown statuses and dangling `supersedes` are problems on the decision file. + +Diagrams point back at decisions with a `decisions` list of ids on the diagram itself, on a component (or node, participant, state) and on a connection (or 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 key is stripped before Archify renders. An unknown id is a problem at its line; outside a model the key is an error, like `ref`. The `main.archify` page lists all decisions; a diagram page lists the ones it points at or that name it; an element's card and dialog list the decisions about it. A `.archify-decision` file opens as its own page. Outside a model it still opens, with a note that it has lost its model. diff --git a/examples/online-shop/0200-containers/online-shop.archify b/examples/online-shop/0200-containers/online-shop.archify index 8d960d8..d764ca6 100644 --- a/examples/online-shop/0200-containers/online-shop.archify +++ b/examples/online-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/online-shop/decisions/0001-postgres-as-source-of-truth.archify-decision b/examples/online-shop/decisions/0001-postgres-as-source-of-truth.archify-decision new file mode 100644 index 0000000..12a8db9 --- /dev/null +++ b/examples/online-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/online-shop/decisions/0002-queue-between-api-and-worker.archify-decision b/examples/online-shop/decisions/0002-queue-between-api-and-worker.archify-decision new file mode 100644 index 0000000..b6ae73b --- /dev/null +++ b/examples/online-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/online-shop/decisions/0003-opensearch-for-product-search.archify-decision b/examples/online-shop/decisions/0003-opensearch-for-product-search.archify-decision new file mode 100644 index 0000000..c082e78 --- /dev/null +++ b/examples/online-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/package-lock.json b/package-lock.json index b940ae7..fa849a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "archify-vscode-ext", - "version": "0.7.2", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "archify-vscode-ext", - "version": "0.7.2", + "version": "0.8.0", "license": "MIT", "dependencies": { "three": "^0.186.1", diff --git a/package.json b/package.json index a636a5a..eed3412 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "archify-vscode-ext", "displayName": "Archify Diagram Viewer", - "description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, \u2026) and opens rendered Archify HTML.", - "version": "0.7.2", + "description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.", + "version": "0.8.0", "publisher": "root-at-skic", "license": "MIT", "sponsor": { @@ -33,7 +33,9 @@ "registry", "sequence", "workflow", - "yaml" + "yaml", + "adr", + "decisions" ], "main": "./dist/extension.js", "activationEvents": [ @@ -42,20 +44,26 @@ "onLanguage:html", "workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}", "onWebviewPanel:archify.preview", - "workspaceContains:**/*.archify-registry" + "workspaceContains:**/*.archify-registry", + "workspaceContains:**/*.archify-decision" ], "contributes": { "commands": [ { "command": "archify.newDiagram", - "title": "Archify | New Diagram\u2026", + "title": "Archify | New Diagram…", "icon": "$(new-file)" }, { "command": "archify.newModel", - "title": "Archify | New Model\u2026", + "title": "Archify | New Model…", "icon": "$(new-folder)" }, + { + "command": "archify.newDecision", + "title": "Archify | New Decision…", + "icon": "$(law)" + }, { "command": "archify.showPreview", "title": "Archify | Open Preview", @@ -73,7 +81,7 @@ }, { "command": "archify.renderToHtml", - "title": "Archify | Render to HTML File\u2026", + "title": "Archify | Render to HTML File…", "icon": "$(file-code)" }, { @@ -125,11 +133,11 @@ }, { "command": "archify.showSource", - "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'" + "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'" }, { "command": "archify.openModel", - "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'" + "when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'" } ], "editor/title": [ @@ -150,7 +158,7 @@ }, { "command": "archify.showSource", - "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'", + "when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'", "group": "navigation" }, { @@ -165,7 +173,7 @@ }, { "command": "archify.openModel", - "when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry') && !notebookEditorFocused", + "when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused", "group": "navigation@60" } ], @@ -180,9 +188,14 @@ "when": "explorerResourceIsFolder", "group": "zzz_archify@2" }, + { + "command": "archify.newDecision", + "when": "explorerResourceIsFolder", + "group": "zzz_archify@2" + }, { "command": "archify.showPreview", - "when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", + "when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "group": "zzz_archify@3" }, { @@ -197,7 +210,7 @@ }, { "command": "archify.openModel", - "when": "resourceFilename =~ /\\.archify(-registry)?$/", + "when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/", "group": "zzz_archify@5" } ], @@ -216,6 +229,10 @@ { "command": "archify.newModel", "group": "file" + }, + { + "command": "archify.newDecision", + "group": "file" } ] }, @@ -254,6 +271,16 @@ ], "priority": "default" }, + { + "viewType": "archify.decision", + "displayName": "Archify Decision", + "selector": [ + { + "filenamePattern": "*.archify-decision" + } + ], + "priority": "default" + }, { "viewType": "archify.htmlViewer", "displayName": "Archify HTML Viewer", @@ -381,7 +408,8 @@ "id": "yaml", "extensions": [ ".archify", - ".archify-registry" + ".archify-registry", + ".archify-decision" ] } ], @@ -393,6 +421,10 @@ { "fileMatch": "*.archify-registry", "url": "./schemas/archify-registry.schema.json" + }, + { + "fileMatch": "*.archify-decision", + "url": "./schemas/archify-decision.schema.json" } ] }, diff --git a/schemas/archify-decision.schema.json b/schemas/archify-decision.schema.json new file mode 100644 index 0000000..478961d --- /dev/null +++ b/schemas/archify-decision.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Archify architecture decision", + "description": "One architecture decision of a model. Diagrams point at it with `decisions: []`.", + "type": "object", + "additionalProperties": false, + "required": [ + "decision" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "decision": { + "type": "string", + "description": "What was decided." + }, + "context": { + "type": "string", + "description": "The situation and what forced a decision." + }, + "consequences": { + "type": "string", + "description": "What becomes easier, harder, or must change." + } + } +} diff --git a/schemas/architecture.schema.json b/schemas/architecture.schema.json index 3d34a22..dd11bf7 100644 --- a/schemas/architecture.schema.json +++ b/schemas/architecture.schema.json @@ -219,6 +219,14 @@ "diagram": { "type": "string", "description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it." + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } }, "anyOf": [ @@ -332,6 +340,14 @@ }, "width": { "$ref": "common.schema.json#/$defs/relationshipWidth" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -363,6 +379,14 @@ "description": "What the model covers; shown on the model page." } } + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } diff --git a/schemas/dataflow.schema.json b/schemas/dataflow.schema.json index 808a61f..92c2921 100644 --- a/schemas/dataflow.schema.json +++ b/schemas/dataflow.schema.json @@ -180,6 +180,14 @@ }, "yOffset": { "type": "number" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -256,6 +264,14 @@ }, "width": { "$ref": "common.schema.json#/$defs/relationshipWidth" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -287,6 +303,14 @@ "description": "What the model covers; shown on the model page." } } + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } diff --git a/schemas/lifecycle.schema.json b/schemas/lifecycle.schema.json index 6e2182f..bcc978d 100644 --- a/schemas/lifecycle.schema.json +++ b/schemas/lifecycle.schema.json @@ -208,6 +208,14 @@ }, "yOffset": { "type": "number" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -288,6 +296,14 @@ }, "width": { "$ref": "common.schema.json#/$defs/relationshipWidth" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -319,6 +335,14 @@ "description": "What the model covers; shown on the model page." } } + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } diff --git a/schemas/sequence.schema.json b/schemas/sequence.schema.json index 04d8858..7afb434 100644 --- a/schemas/sequence.schema.json +++ b/schemas/sequence.schema.json @@ -144,6 +144,14 @@ }, "sources": { "$ref": "common.schema.json#/$defs/sourceReferences" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -213,6 +221,14 @@ }, "note": { "type": "string" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -270,6 +286,14 @@ "description": "What the model covers; shown on the model page." } } + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } diff --git a/schemas/workflow.schema.json b/schemas/workflow.schema.json index ac80f75..d0e0f5b 100644 --- a/schemas/workflow.schema.json +++ b/schemas/workflow.schema.json @@ -331,6 +331,14 @@ }, "yOffset": { "type": "number" + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -419,6 +427,14 @@ "width": { "type": "number", "minimum": 0.5 + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } } } @@ -450,6 +466,14 @@ "description": "What the model covers; shown on the model page." } } + }, + "decisions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram." } }, "$defs": { diff --git a/scripts/patch-schemas.mjs b/scripts/patch-schemas.mjs index 1fa3c21..1127adc 100644 --- a/scripts/patch-schemas.mjs +++ b/scripts/patch-schemas.mjs @@ -25,11 +25,19 @@ const MODEL = { }; const DIAGRAM_LINK = { type: 'string', description: 'Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it.' }; +const DECISIONS = { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram.' }; +const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states']; +const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages']; for (const file of fs.readdirSync(schemas)) { if (!/^(architecture|workflow|sequence|dataflow|lifecycle)\.schema\.json$/.test(file)) continue; const schema = read(file); schema.properties.model = MODEL; + schema.properties.decisions = DECISIONS; + for (const key of [...NODE_COLLECTIONS, ...EDGE_COLLECTIONS]) { + const items = schema.properties[key]?.items; + if (items?.properties) items.properties.decisions = DECISIONS; + } if (file === 'architecture.schema.json') { const item = schema.properties.components.items; item.properties.ref = { type: 'string', pattern: '^[a-zA-Z][a-zA-Z0-9_-]*$', description: 'A shared element from the model’s registry (*.archify-registry). Its type, label, sublabel, brand and sources come from there; the diagram adds pos/size and may override label, sublabel and tag.' }; @@ -76,4 +84,34 @@ const registry = { }, }; write('archify-registry.schema.json', registry); -console.log('Patched editor schemas: model, ref, diagram; wrote archify-registry.schema.json'); + +const decision = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + title: 'Archify architecture decision', + description: 'One architecture decision of a model. Diagrams point at it with `decisions: []`.', + type: 'object', + additionalProperties: false, + required: ['decision'], + properties: { + schema_version: { const: 1 }, + decision: { + type: 'object', + additionalProperties: false, + required: ['id', 'title'], + properties: { + id: { type: 'string', minLength: 1, description: 'Unique in the model. Any short string.' }, + title: { type: 'string', minLength: 1 }, + status: { enum: ['proposed', 'accepted', 'superseded', 'rejected'], description: 'A decision another one supersedes shows as superseded whatever this says.' }, + date: { type: 'string', description: 'YYYY-MM-DD.' }, + elements: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Registry element ids the decision is about.' }, + diagrams: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Diagram paths (from the model root) the decision names.' }, + supersedes: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of older decisions this one replaces.' }, + }, + }, + context: { type: 'string', description: 'The situation and what forced a decision.' }, + decision: { type: 'string', description: 'What was decided.' }, + consequences: { type: 'string', description: 'What becomes easier, harder, or must change.' }, + }, +}; +write('archify-decision.schema.json', decision); +console.log('Patched editor schemas: model, ref, diagram, decisions; wrote archify-registry.schema.json and archify-decision.schema.json'); diff --git a/src/commands/context.ts b/src/commands/context.ts index 62c5457..f81f623 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -3,6 +3,7 @@ import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer'; import { ModelService } from '../model/service'; import { DiagramViewManager } from '../views/diagramView'; import { RegistryViewProvider } from '../views/registryView'; +import { DecisionViewProvider } from '../views/decisionView'; /** What command handlers share: the extension context and the open views. */ export interface CommandContext { @@ -10,6 +11,7 @@ export interface CommandContext { views: DiagramViewManager; htmlViewer: HtmlViewerProvider; registry: RegistryViewProvider; + decision: DecisionViewProvider; models: ModelService; } @@ -20,7 +22,7 @@ export function isHtmlUri(uri: vscode.Uri): boolean { /** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */ export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined { if (arg instanceof vscode.Uri) return arg; - return ctx.views.activePreview?.uri ?? ctx.registry.activeUri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri; + return ctx.views.activePreview?.uri ?? ctx.registry.activeUri ?? ctx.decision.activeUri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri; } export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined { diff --git a/src/commands/modelCommands.ts b/src/commands/modelCommands.ts index 4bad8eb..53f776f 100644 --- a/src/commands/modelCommands.ts +++ b/src/commands/modelCommands.ts @@ -1,7 +1,9 @@ import * as vscode from 'vscode'; import { MODEL_EXAMPLES, mainFromExample } from '../language/examples'; import { diagramName, invalidNameReason } from '../language/templates'; +import { DECISION_EXT, slugify } from '../model/decisions'; import { MAIN_FILE } from '../model/format'; +import { DECISION_VIEW_TYPE } from '../views/decisionView'; import { readText } from '../util/files'; import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView'; import { CommandContext, exists, targetUri } from './context'; @@ -30,6 +32,38 @@ export function modelCommands(ctx: CommandContext): vscode.Disposable[] { await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active); }), + // Creates a decision file in the clicked folder of a model and opens it. + vscode.commands.registerCommand('archify.newDecision', async (arg?: unknown) => { + const folder = await parentFolder(arg); + if (!folder) return; + const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x')); + if (!root) { + vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} is not inside a model (no ${MAIN_FILE} in it or a folder above it). Decisions belong to a model.`); + return; + } + const index = await ctx.models.index(root); + const title = await vscode.window.showInputBox({ + title: 'Archify | New Decision', + prompt: `Title of the decision (file goes into ${vscode.workspace.asRelativePath(folder, true)})`, + placeHolder: 'OpenSearch for product search', + validateInput: async (value) => { + if (!value.trim()) return 'Enter a title.'; + const id = slugify(value); + if (index.decisions.decisions.has(id)) return `A decision with id "${id}" already exists in this model.`; + if (await exists(vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`))) return `${id}${DECISION_EXT} already exists.`; + return undefined; + }, + }); + if (title === undefined) return; + const id = slugify(title); + const target = vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`); + const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'decision.archify-decision')); + const today = new Date().toISOString().slice(0, 10); + const text = template.replace('__ID__', id).replace('__TITLE__', JSON.stringify(title.trim())).replace('__DATE__', today); + await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8')); + await vscode.commands.executeCommand('vscode.openWith', target, DECISION_VIEW_TYPE); + }), + // Creates a new model folder from one of the shipped examples and opens its model page. vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => { const picked = await vscode.window.showQuickPick( diff --git a/src/commands/viewCommands.ts b/src/commands/viewCommands.ts index dd8df2d..a066574 100644 --- a/src/commands/viewCommands.ts +++ b/src/commands/viewCommands.ts @@ -1,8 +1,10 @@ import * as vscode from 'vscode'; +import { isDecisionFile } from '../model/decisions'; import { isRegistryFile } from '../model/format'; import { isArchifyFile } from '../sources/detect'; import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView'; import { REGISTRY_VIEW_TYPE } from '../views/registryView'; +import { DECISION_VIEW_TYPE } from '../views/decisionView'; import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context'; export function viewCommands(ctx: CommandContext): vscode.Disposable[] { @@ -12,6 +14,7 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] { if (!uri) return; if (isHtmlUri(uri)) return openHtmlViewer(uri); if (isRegistryFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, REGISTRY_VIEW_TYPE); + if (isDecisionFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DECISION_VIEW_TYPE); if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE); ctx.views.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active); }), @@ -48,9 +51,9 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] { await vscode.window.showTextDocument(preview.uri, { viewColumn: column }); return; } - const registry = ctx.registry.activeUri; - if (registry) { - await vscode.commands.executeCommand('vscode.openWith', registry, 'default', vscode.ViewColumn.Beside); + const page = ctx.registry.activeUri ?? ctx.decision.activeUri; + if (page) { + await vscode.commands.executeCommand('vscode.openWith', page, 'default', vscode.ViewColumn.Beside); return; } const html = ctx.htmlViewer.activeUri; diff --git a/src/extension.ts b/src/extension.ts index 69574b4..36c94e6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,6 +13,8 @@ import { log } from './util/log'; import { HtmlViewerProvider } from './views/htmlViewer'; import { DiagramViewManager } from './views/diagramView'; import { RegistryViewProvider } from './views/registryView'; +import { DecisionViewProvider } from './views/decisionView'; +import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage'; export function activate(context: vscode.ExtensionContext): void { const models = new ModelService(); @@ -21,20 +23,26 @@ export function activate(context: vscode.ExtensionContext): void { views: new DiagramViewManager(context, models), htmlViewer: new HtmlViewerProvider(context.extensionUri), registry: new RegistryViewProvider(models), + decision: new DecisionViewProvider(models), models, }; const refs = new RefLanguage(models); + const decisions = new DecisionLanguage(models); context.subscriptions.push( models, ctx.views, ctx.htmlViewer, ctx.registry, + ctx.decision, new DiagnosticsController(context, models), log(), vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'), vscode.languages.registerCompletionItemProvider(REF_SELECTOR, refs, ' ', '"'), vscode.languages.registerHoverProvider(REF_SELECTOR, refs), vscode.languages.registerDefinitionProvider(REF_SELECTOR, refs), + vscode.languages.registerCompletionItemProvider(DECISION_SELECTOR, decisions, ' ', '[', ',', '"'), + vscode.languages.registerHoverProvider(DECISION_SELECTOR, decisions), + vscode.languages.registerDefinitionProvider(DECISION_SELECTOR, decisions), ...viewCommands(ctx), ...newDiagramCommands(ctx), ...renderCommands(ctx), diff --git a/src/language/decisionLanguage.ts b/src/language/decisionLanguage.ts new file mode 100644 index 0000000..468c0bb --- /dev/null +++ b/src/language/decisionLanguage.ts @@ -0,0 +1,102 @@ +import * as vscode from 'vscode'; +import { Decision, DECISION_STATUSES, effectiveStatus, isDecisionFile } from '../model/decisions'; +import { ModelIndex, ModelService } from '../model/service'; + +/** Diagrams (decisions: […]) and decision files (elements: […], diagrams: […], supersedes: […]). */ +export const DECISION_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }, { pattern: '**/*.archify-decision' }]; + +type ListKey = 'decisions' | 'elements' | 'diagrams' | 'supersedes'; + +/** Which list the cursor is in on a `key: [a, b, …` line, and what has been typed of the current item. */ +export function listValueAt(linePrefix: string, fileName: string): { key: ListKey; typed: string } | undefined { + const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions']; + const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)$`).exec(linePrefix); + if (match) { + const items = match[2].split(','); + return { key: match[1] as ListKey, typed: items[items.length - 1].replace(/^[\s"']+/, '') }; + } + // Block list item under the key: " - 00" (the key is on a line above; the caller checks). + return undefined; +} + +/** The list item under a position on a flow-list line, with its range. */ +export function listItemAt(line: string, character: number, fileName: string): { key: ListKey; value: string; start: number; end: number } | undefined { + const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions']; + const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)\\]?`).exec(line); + if (!match) return undefined; + const listStart = match.index + match[0].indexOf('[') + 1; + const re = /[^,\s"'\]]+/g; + const inner = match[2]; + let m: RegExpExecArray | null; + while ((m = re.exec(inner))) { + const start = listStart + m.index; + const end = start + m[0].length; + if (character >= start && character <= end) return { key: match[1] as ListKey, value: m[0], start, end }; + } + return undefined; +} + +function describeDecision(d: Decision, index: ModelIndex): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + const { status, supersededBy } = effectiveStatus(d, index.decisions.decisions); + md.appendMarkdown(`**${d.id}**${d.title ? ` · ${d.title}` : ''} \`${status}\`${supersededBy ? ` by ${supersededBy}` : ''}${d.date ? ` · ${d.date}` : ''}\n\n`); + if (d.decision) md.appendMarkdown(`${d.decision.trim()}\n\n`); + md.appendMarkdown(`*${d.file}*`); + return md; +} + +/** Completion, hover and go-to-definition for decision ids in diagrams and for links inside decision files. */ +export class DecisionLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider { + constructor(private readonly models: ModelService) {} + + async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise { + const at = listValueAt(document.lineAt(position.line).text.slice(0, position.character), document.fileName); + if (!at) return undefined; + const index = await this.models.indexFor(document.uri); + if (!index) return undefined; + const range = new vscode.Range(position.translate(0, -at.typed.length), position); + const item = (label: string, description: string | undefined, detail: string, doc?: vscode.MarkdownString) => { + const it = new vscode.CompletionItem({ label, description }, vscode.CompletionItemKind.Reference); + it.detail = detail; + it.documentation = doc; + it.filterText = `${label} ${description ?? ''}`; + it.range = range; + return it; + }; + if (at.key === 'decisions' || at.key === 'supersedes') { + return [...index.decisions.decisions.values()].map((d) => item(d.id, d.title, effectiveStatus(d, index.decisions.decisions).status, describeDecision(d, index))); + } + if (at.key === 'elements') { + return [...index.registry.elements.values()].map((e) => item(e.id, e.label, [e.type, e.sublabel].filter(Boolean).join(' · '))); + } + return index.diagrams.map((d) => item(d.relativePath, d.title, d.kind ?? 'diagram')); + } + + async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise { + const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName); + if (!hit || (hit.key !== 'decisions' && hit.key !== 'supersedes')) return undefined; + const index = await this.models.indexFor(document.uri); + const range = new vscode.Range(position.line, hit.start, position.line, hit.end); + const d = index?.decisions.decisions.get(hit.value); + if (!index) return new vscode.Hover(`\`${hit.value}\` cannot be resolved: this file is not inside a model.`, range); + if (!d) return new vscode.Hover(`Unknown decision \`${hit.value}\`: no .archify-decision file of this model defines it.`, range); + return new vscode.Hover(describeDecision(d, index), range); + } + + async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise { + const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName); + if (!hit) return undefined; + const index = await this.models.indexFor(document.uri); + if (!index) return undefined; + if (hit.key === 'decisions' || hit.key === 'supersedes') { + const d = index.decisions.decisions.get(hit.value); + return d ? new vscode.Location(vscode.Uri.joinPath(index.root, ...d.file.split('/')), new vscode.Position(d.lines.id, 2)) : undefined; + } + if (hit.key === 'elements') { + const e = index.registry.elements.get(hit.value); + return e ? new vscode.Location(vscode.Uri.joinPath(index.root, ...e.file.split(/[\\/]/)), new vscode.Position(e.line, 2)) : undefined; + } + const diagram = index.diagrams.find((d) => d.relativePath === hit.value); + return diagram ? new vscode.Location(vscode.Uri.joinPath(index.root, ...diagram.relativePath.split('/')), new vscode.Position(0, 0)) : undefined; + } +} diff --git a/src/language/diagnostics.ts b/src/language/diagnostics.ts index a206c8b..5b2725d 100644 --- a/src/language/diagnostics.ts +++ b/src/language/diagnostics.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli'; import { evidenceMode, runtimeFor } from '../archify/runtime'; import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format'; +import { isDecisionFile, parseDecision } from '../model/decisions'; import { prepareDiagram } from '../model/prepare'; import { ModelService } from '../model/service'; import { isArchifyFile } from '../sources/detect'; @@ -70,7 +71,7 @@ export class DiagnosticsController implements vscode.Disposable { } private schedule(doc: vscode.TextDocument, delay: number): void { - if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName) && !isRegistryFile(doc.fileName)) return; + if (!JSON_LANGUAGES.has(doc.languageId) && !isArchifyFile(doc.fileName) && !isRegistryFile(doc.fileName) && !isDecisionFile(doc.fileName)) return; const key = doc.uri.toString(); clearTimeout(this.timers.get(key)); this.timers.set(key, setTimeout(() => void this.validate(doc), delay)); @@ -91,6 +92,10 @@ export class DiagnosticsController implements vscode.Disposable { await this.validateRegistry(doc, seq); return; } + if (isDecisionFile(doc.fileName)) { + await this.validateDecision(doc, seq); + return; + } if (isArchifyFile(doc.fileName)) { // Nothing else reports YAML syntax errors in .archify files, and a file // that does not parse has no diagram to validate yet. @@ -179,6 +184,27 @@ export class DiagnosticsController implements vscode.Disposable { } /** A registry file: its own YAML and element problems, plus duplicates across the model. */ + private async validateDecision(doc: vscode.TextDocument, seq: number): Promise { + const text = doc.getText(); + const root = await this.models.rootOf(doc.uri); + const rel = root ? path.relative(root.fsPath, doc.uri.fsPath).split(path.sep).join('/') : path.basename(doc.uri.fsPath); + const problems: ModelProblem[] = [...parseDecision(text, rel).problems]; + if (root) { + try { + const index = await this.models.index(root); + for (const problem of index.decisions.problems) { + if (problem.file === rel && !problems.some((p) => p.message === problem.message)) problems.push(problem); + } + } catch { + // The index reports its own failures when the model page opens. + } + } else { + problems.push({ message: `This decision has lost its model: no main.archify above ${path.basename(doc.uri.fsPath)}, so its elements and diagrams cannot be resolved.`, severity: 'warning', line: 0 }); + } + if (seq !== this.sequence.get(doc.uri.toString()) || doc.isClosed) return; + this.collection.set(doc.uri, problems.map((problem) => this.modelDiagnostic(doc, text, 'yaml', problem))); + } + private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise { const text = doc.getText(); const root = await this.models.rootOf(doc.uri); diff --git a/src/model/decisions.ts b/src/model/decisions.ts new file mode 100644 index 0000000..d223526 --- /dev/null +++ b/src/model/decisions.ts @@ -0,0 +1,183 @@ +import { isMap, isScalar, isSeq, parseDocument } from 'yaml'; +import { ModelProblem, Registry } from './format'; + +/** + * Architecture decisions of a model (see docs/model.md): one + * `*.archify-decision` file per decision, anywhere under the model root. + * Pure functions, no VS Code imports. + */ + +export const DECISION_EXT = '.archify-decision'; +export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'] as const; +export type DecisionStatus = (typeof DECISION_STATUSES)[number]; + +export interface Decision { + id: string; + title?: string; + status?: string; + date?: string; + /** Registry element ids the decision is about. */ + elements: string[]; + /** Diagram paths (relative to the model root) the decision names. */ + diagrams: string[]; + /** Ids of older decisions this one replaces. */ + supersedes: string[]; + context?: string; + decision?: string; + consequences?: string; + /** Decision file relative to the model root, and 0-based lines for diagnostics. */ + file: string; + line: number; + lines: { id: number; status: number; elements: number[]; diagrams: number[]; supersedes: number[] }; +} + +export function isDecisionFile(fileName: string): boolean { + return fileName.endsWith(DECISION_EXT); +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim().length > 0 ? v : v !== undefined && v !== null && typeof v !== 'object' ? String(v) : undefined); + +/** "OpenSearch for product search" → "opensearch-for-product-search". */ +export function slugify(title: string): string { + const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return slug || 'decision'; +} + +/** Parses one decision file. Problems carry 0-based lines. */ +export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } { + const problems: ModelProblem[] = []; + const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false }); + for (const error of doc.errors) { + problems.push({ message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 }); + } + if (doc.errors.length) return { problems }; + const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1); + const root = doc.contents; + if (!isMap(root)) { + problems.push({ message: 'A decision file is a mapping with a "decision" block and context, decision and consequences.', severity: 'error', file, line: 0 }); + return { problems }; + } + const headNode = root.get('decision', true); + if (!isMap(headNode)) { + problems.push({ message: 'A decision needs a "decision" block with id, title and status.', severity: 'error', file, line: 0, pointer: '/decision' }); + return { problems }; + } + const headPair = root.items.find((pair) => isScalar(pair.key) && pair.key.value === 'decision'); + const headLine = headPair && isScalar(headPair.key) ? lineOf(headPair.key.range?.[0]) : 0; + const keyLine = (key: string) => { + const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key); + return pair && isScalar(pair.key) ? lineOf(pair.key.range?.[0]) : headLine; + }; + const list = (key: string): { values: string[]; lines: number[] } => { + const node = headNode.get(key, true); + const values: string[] = []; + const lines: number[] = []; + if (isSeq(node)) { + for (const item of node.items) { + if (!isScalar(item)) continue; + if (typeof item.value === 'number') { + problems.push({ message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, severity: 'error', file, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` }); + continue; + } + if (str(item.value)) { + values.push(String(item.value)); + lines.push(lineOf(item.range?.[0])); + } + } + } else if (node !== undefined) { + problems.push({ message: `"${key}" must be a list.`, severity: 'error', file, line: keyLine(key), pointer: `/decision/${key}` }); + } + return { values, lines }; + }; + const head = headNode.toJSON() as Record; + if (typeof head.id === 'number') { + problems.push({ message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, severity: 'error', file, line: keyLine('id'), pointer: '/decision/id' }); + return { problems }; + } + const id = str(head.id); + if (!id) { + problems.push({ message: 'decision.id is missing.', severity: 'error', file, line: headLine, pointer: '/decision/id' }); + return { problems }; + } + const status = str(head.status); + if (status && !(DECISION_STATUSES as readonly string[]).includes(status)) { + problems.push({ message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, severity: 'error', file, line: keyLine('status'), pointer: '/decision/status' }); + } + const elements = list('elements'); + const diagrams = list('diagrams'); + const supersedes = list('supersedes'); + const body = root.toJSON() as Record; + return { + problems, + decision: { + id, + title: str(head.title), + status, + date: str(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'), status: keyLine('status'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines }, + }, + }; +} + +export interface DecisionSet { + decisions: Map; + files: string[]; + problems: ModelProblem[]; +} + +/** Merges parsed decision files; an id used twice is an error on the second file. */ +export function mergeDecisions(parts: { file: string; decision?: Decision; problems: ModelProblem[] }[]): DecisionSet { + const decisions = new Map(); + const problems: ModelProblem[] = []; + for (const part of parts) { + problems.push(...part.problems); + if (!part.decision) continue; + const existing = decisions.get(part.decision.id); + if (existing) { + problems.push({ message: `Decision "${part.decision.id}" is defined twice (also in ${existing.file}).`, severity: 'error', file: part.file, line: part.decision.lines.id }); + continue; + } + decisions.set(part.decision.id, part.decision); + } + return { decisions, files: parts.map((p) => p.file), problems }; +} + +/** The status the pages show: a decision another one supersedes is superseded, whatever its file says. */ +export function effectiveStatus(decision: Decision, all: Map): { status: string; supersededBy?: string } { + for (const other of all.values()) { + if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id }; + } + return { status: decision.status ?? 'proposed' }; +} + +/** Link checks across the model: elements, diagrams and supersedes must exist. */ +export function validateDecisions(set: DecisionSet, registry: Registry | undefined, diagramPaths: Set): ModelProblem[] { + const problems: ModelProblem[] = []; + for (const d of set.decisions.values()) { + d.elements.forEach((id, i) => { + if (!registry?.elements.has(id)) problems.push({ message: `Unknown element "${id}": no registry of this model defines it.`, severity: 'error', file: d.file, line: d.lines.elements[i], pointer: `/decision/elements/${i}` }); + }); + d.diagrams.forEach((path, i) => { + if (!diagramPaths.has(path)) problems.push({ message: `Diagram "${path}" does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` }); + }); + d.supersedes.forEach((id, i) => { + if (!set.decisions.has(id)) problems.push({ message: `Decision "${id}" (in supersedes) does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` }); + else if (id === d.id) problems.push({ message: 'A decision cannot supersede itself.', severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` }); + }); + } + return problems; +} + +/** Sort for lists: live decisions newest first, superseded and rejected last. */ +export function sortDecisions(decisions: Decision[], all: Map): Decision[] { + const rank = (d: Decision) => (['superseded', 'rejected'].includes(effectiveStatus(d, all).status) ? 1 : 0); + return [...decisions].sort((a, b) => rank(a) - rank(b) || (b.date ?? '').localeCompare(a.date ?? '') || a.id.localeCompare(b.id)); +} diff --git a/src/model/format.ts b/src/model/format.ts index a026fd0..fae57fe 100644 --- a/src/model/format.ts +++ b/src/model/format.ts @@ -150,6 +150,19 @@ export function mergeRegistries(parts: { elements: RegistryElement[]; problems: return { elements, files: parts.map((p) => p.file), problems }; } +/** Where a diagram attaches a decision: the diagram itself, a node or an edge. */ +export type DecisionAttachment = { kind: 'diagram' } | { kind: 'node'; id: string } | { kind: 'edge'; from: string; to: string; label?: string }; + +export interface DecisionRef { + id: string; + pointer: string; + attachedTo: DecisionAttachment; +} + +/** Collections whose items may carry `decisions` (and `diagram` links), per diagram kind. */ +const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'] as const; +const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'] as const; + export interface ResolvedDiagram { /** The diagram as Archify expects it: refs expanded, model block and links removed. */ diagram: Obj; @@ -162,14 +175,66 @@ export interface ResolvedDiagram { used: Set; /** Node id → registry description, for the Elements panel. */ descriptions: Map; + /** Decisions the diagram points at, with where they are attached. */ + decisionRefs: DecisionRef[]; } export interface ResolveOptions { /** Undefined when the diagram is not inside a model. */ registry?: Registry; + /** Known decision ids of the model; undefined outside a model or when unknown. */ + decisions?: Set; fileName: string; } +/** Strips `decisions` keys from a diagram and collects what they point at. Mutates `diagram`. */ +function takeDecisionRefs(diagram: Obj, options: ResolveOptions, problems: ModelProblem[]): DecisionRef[] { + const refs: DecisionRef[] = []; + const take = (owner: Obj, pointer: string, attachedTo: DecisionAttachment) => { + if (!('decisions' in owner)) return; + const raw = owner.decisions; + delete owner.decisions; + const ids = Array.isArray(raw) ? raw : [raw]; + ids.forEach((value, i) => { + const id = value === undefined || value === null ? undefined : String(value); + if (!id) return; + const at = `${pointer}/decisions/${i}`; + if (typeof value === 'number') { + problems.push({ message: `Decision id ${value} is a number to YAML (0001 becomes 1). Quote it ("${value}") or use words.`, severity: 'error', pointer: at }); + return; + } + if (!options.registry) { + problems.push({ message: `Decision "${id}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`, severity: 'error', pointer: at }); + } else if (options.decisions && !options.decisions.has(id)) { + problems.push({ message: `Unknown decision "${id}": no .archify-decision file of this model defines it.`, severity: 'error', pointer: at }); + } + refs.push({ id, pointer: at, attachedTo }); + }); + }; + take(diagram, '', { kind: 'diagram' }); + for (const key of NODE_COLLECTIONS) { + const items = diagram[key]; + if (!Array.isArray(items)) continue; + diagram[key] = items.map((item, index) => { + if (!isObj(item)) return item; + const copy = { ...item }; + take(copy, `/${key}/${index}`, { kind: 'node', id: str(copy.id) ?? str(copy.ref) ?? String(index) }); + return copy; + }); + } + for (const key of EDGE_COLLECTIONS) { + const items = diagram[key]; + if (!Array.isArray(items)) continue; + diagram[key] = items.map((item, index) => { + if (!isObj(item)) return item; + const copy = { ...item }; + take(copy, `/${key}/${index}`, { kind: 'edge', from: str(copy.from) ?? '', to: str(copy.to) ?? '', label: str(copy.label) }); + return copy; + }); + } + return refs; +} + /** * Expands `ref` components from the registry and strips what Archify does * not know. Component order and indexes are kept, so Archify's diagnostics @@ -181,12 +246,13 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag const links = new Map(); const used = new Set(); const descriptions = new Map(); - if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions }; + if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions, decisionRefs: [] }; const diagram: Obj = { ...doc }; delete diagram.model; + const decisionRefs = takeDecisionRefs(diagram, options, problems); const { registry } = options; - const components = Array.isArray(doc.components) ? doc.components : undefined; - if (!components) return { diagram, problems, refs, links, used, descriptions }; + const components = Array.isArray(diagram.components) ? diagram.components : undefined; + if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs }; diagram.components = components.map((raw, index) => { if (!isObj(raw)) return raw; @@ -233,7 +299,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag if (link) links.set(id, link); return merged; }); - return { diagram, problems, refs, links, used, descriptions }; + return { diagram, problems, refs, links, used, descriptions, decisionRefs }; } /** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */ @@ -249,6 +315,8 @@ export interface DiagramSummary { kind?: string; title?: string; used: string[]; + /** Decisions the diagram points at. */ + decisions: DecisionRef[]; parseError?: string; } @@ -258,13 +326,14 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum try { doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false }); } catch (error) { - return { relativePath, group: groupOf(relativePath), used: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) }; + return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) }; } const obj = isObj(doc) ? doc : {}; const meta = isObj(obj.meta) ? obj.meta : {}; const components = Array.isArray(obj.components) ? obj.components : []; const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))]; - return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used }; + const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []); + return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions }; } /** Groups in display order: the root group first, then folders in path order. */ diff --git a/src/model/panel.ts b/src/model/panel.ts index ad9066b..d9eccfd 100644 --- a/src/model/panel.ts +++ b/src/model/panel.ts @@ -1,6 +1,18 @@ -import { DiagramSummary, RegistryElement } from './format'; +import { Decision, effectiveStatus, sortDecisions } from './decisions'; +import { DecisionAttachment, DiagramSummary, RegistryElement } from './format'; import { ModelIndex } from './service'; +/** A decision as the lists show it. */ +export interface DecisionRow { + id: string; + title?: string; + /** Status shown: superseded when another decision supersedes it. */ + status: string; + supersededBy?: string; + date?: string; + file: string; +} + /** A diagram an element is used in, as the pages show it. */ export interface UsedIn { path: string; @@ -22,6 +34,8 @@ export interface ElementCard { file: string; line: number; usedIn: UsedIn[]; + /** Decisions about this element, from the decision files and from diagram attachments. */ + decisions: DecisionRow[]; } /** What the model page shows; serialised into the webview. */ @@ -31,10 +45,27 @@ export interface ModelPanel { hasDiagram: boolean; groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[]; registry: ElementCard[]; + decisions: DecisionRow[]; problems: string[]; nested: string[]; } +/** What the decision page (one .archify-decision file) shows. */ +export interface DecisionPanel { + file: string; + inModel: boolean; + model?: { id?: string; name?: string }; + decision?: DecisionRow & { context?: string; decision?: string; consequences?: string; supersedes: DecisionRow[] }; + /** Decisions that supersede this one. */ + supersededBy: DecisionRow[]; + elements: ElementCard[]; + /** Diagrams the decision names. */ + diagrams: UsedIn[]; + /** Diagrams that point at the decision, with where. */ + referencedBy: { path: string; title?: string; kind?: string; attachedTo: DecisionAttachment }[]; + problems: string[]; +} + /** What the registry page (one .archify-registry file) shows. */ export interface RegistryPanel { /** The registry file, relative to the model root (or its base name outside a model). */ @@ -52,7 +83,22 @@ function usedIn(id: string, usage: Map, diagrams: DiagramSumma }); } -export function elementCard(e: RegistryElement, usage: Map, diagrams: DiagramSummary[]): ElementCard { +export function decisionRow(d: Decision, all: Map): DecisionRow { + const { status, supersededBy } = effectiveStatus(d, all); + return { id: d.id, title: d.title, status, supersededBy, date: d.date, file: d.file }; +} + +/** Decisions about an element: named by the decision, or attached to its node in some diagram. */ +function decisionsOfElement(id: string, index: Pick | undefined): DecisionRow[] { + if (!index) return []; + const all = index.decisions.decisions; + const ids = new Set(); + for (const d of all.values()) if (d.elements.includes(id)) ids.add(d.id); + for (const [decisionId, uses] of index.decisionUsage) if (uses.some((u) => u.attachedTo.kind === 'node' && u.attachedTo.id === id) && all.has(decisionId)) ids.add(decisionId); + return sortDecisions([...ids].map((x) => all.get(x)!), all).map((d) => decisionRow(d, all)); +} + +export function elementCard(e: RegistryElement, usage: Map, diagrams: DiagramSummary[], index?: Pick): ElementCard { return { id: e.id, label: e.label, @@ -64,22 +110,25 @@ export function elementCard(e: RegistryElement, usage: Map, di file: e.file, line: e.line, usedIn: usedIn(e.id, usage, diagrams), + decisions: decisionsOfElement(e.id, index), }; } const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id); export function panelFromIndex(index: ModelIndex): ModelPanel { - const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams)); + const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams, index)); + const all = index.decisions.decisions; return { meta: index.meta, + decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)), hasDiagram: index.mainHasDiagram, groups: index.groups.map((g) => ({ group: g.group, diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })), })), registry, - problems: index.registry.problems.map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)), + problems: [...index.registry.problems, ...index.decisions.problems].map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)), nested: index.nested, }; } @@ -89,7 +138,7 @@ export function registryPanelFromIndex(index: ModelIndex, file: string): Registr const elements = [...index.registry.elements.values()] .filter((e) => e.file === file) .sort((a, b) => a.line - b.line) - .map((e) => elementCard(e, index.usage, index.diagrams)); + .map((e) => elementCard(e, index.usage, index.diagrams, index)); return { file, inModel: true, @@ -108,3 +157,38 @@ export function registryPanelStandalone(file: string, parsed: { elements: Regist problems: parsed.problems.map((p) => p.message), }; } + +/** The decision page of one file inside a model. */ +export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel { + const all = index.decisions.decisions; + const d = [...all.values()].find((x) => x.file === file); + const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message); + const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems }; + if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] }; + const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all)); + const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index)); + const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path); + return { + ...base, + decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) }, + supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)), + elements, + diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })), + referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })), + }; +} + +/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */ +export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel { + const d = parsed.decision; + return { + file, + inModel: false, + decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined, + supersededBy: [], + elements: [], + diagrams: d ? d.diagrams.map((path) => ({ path })) : [], + referencedBy: [], + problems: parsed.problems.map((p) => p.message), + }; +} diff --git a/src/model/prepare.ts b/src/model/prepare.ts index 8cb4b03..714a89b 100644 --- a/src/model/prepare.ts +++ b/src/model/prepare.ts @@ -1,6 +1,8 @@ +import path from 'node:path'; import * as vscode from 'vscode'; import { DiagramSource, readDiagramSource } from '../sources/detect'; -import { ModelProblem, Registry, resolveRefs } from './format'; +import { DecisionAttachment, ModelProblem, Registry, resolveRefs } from './format'; +import { effectiveStatus } from './decisions'; import { ModelService } from './service'; /** @@ -18,6 +20,16 @@ export interface PreparedDiagram { links: Map; /** Node id → registry description. */ descriptions: Map; + /** Decisions of this diagram for the Decisions rail panel: pointed at by the diagram, or naming it. */ + decisions: DiagramDecision[]; +} + +export interface DiagramDecision { + id: string; + title?: string; + status: string; + file: string; + attachedTo: DecisionAttachment[]; } /** @@ -28,12 +40,32 @@ export interface PreparedDiagram { export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise { const source = readDiagramSource(text, uri.path); if (!source) return undefined; - const found = await models.registryFor(uri); - const resolved = resolveRefs(source.diagram, { registry: found?.registry, fileName: uri.path }); + const index = await models.indexFor(uri); + const found = index ? { root: index.root, registry: index.registry } : undefined; + const resolved = resolveRefs(source.diagram, { registry: found?.registry, decisions: index ? new Set(index.decisions.decisions.keys()) : undefined, fileName: uri.path }); // Only diagrams that use refs (or carry a model block) need the resolved form. - const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0; + const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0 || resolved.decisionRefs.length > 0; const prepared: DiagramSource = changed ? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) } : source; - return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions }; + // Decisions for the rail: the ones this diagram points at, plus the ones that name this diagram. + const decisions: DiagramDecision[] = []; + if (index) { + const rel = path.relative(index.root.fsPath, uri.fsPath).split(path.sep).join('/'); + const byId = new Map(); + const add = (id: string, attachedTo?: DecisionAttachment) => { + const d = index.decisions.decisions.get(id); + if (!d) return; + let row = byId.get(id); + if (!row) { + row = { id, title: d.title, status: effectiveStatus(d, index.decisions.decisions).status, file: d.file, attachedTo: [] }; + byId.set(id, row); + decisions.push(row); + } + if (attachedTo && !row.attachedTo.some((a) => JSON.stringify(a) === JSON.stringify(attachedTo))) row.attachedTo.push(attachedTo); + }; + for (const ref of resolved.decisionRefs) add(ref.id, ref.attachedTo); + for (const d of index.decisions.decisions.values()) if (d.diagrams.includes(rel)) add(d.id, { kind: 'diagram' }); + } + return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions, decisions }; } diff --git a/src/model/service.ts b/src/model/service.ts index 5aa4e2a..1477763 100644 --- a/src/model/service.ts +++ b/src/model/service.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import * as vscode from 'vscode'; import { log } from '../util/log'; import { + DecisionAttachment, DiagramSummary, MAIN_FILE, ModelMeta, @@ -17,6 +18,7 @@ import { summarizeDiagram, } from './format'; import { parse as parseYaml } from 'yaml'; +import { DECISION_EXT, DecisionSet, isDecisionFile, mergeDecisions, parseDecision, validateDecisions } from './decisions'; /** * Finds models in the workspace and keeps what they contain. A model is the @@ -36,6 +38,10 @@ export interface ModelIndex { groups: { group: string; diagrams: DiagramSummary[] }[]; /** Element id → diagrams (relative paths) that refer to it. */ usage: Map; + /** The model's decisions, with link problems already checked. */ + decisions: DecisionSet; + /** Decision id → diagrams that point at it, with where. */ + decisionUsage: Map; /** Nested main.archify files found below the root; not supported. */ nested: string[]; } @@ -71,7 +77,7 @@ export class ModelService implements vscode.Disposable { private readonly disposables: vscode.Disposable[]; constructor() { - const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT}}`); + const watcher = vscode.workspace.createFileSystemWatcher(`**/*{.archify,${REGISTRY_EXT},${DECISION_EXT}}`); const changed = (uri: vscode.Uri) => void this.onFileChanged(uri); this.disposables = [ watcher, @@ -80,7 +86,7 @@ export class ModelService implements vscode.Disposable { watcher.onDidChange(changed), vscode.workspace.onDidChangeTextDocument((event) => { // Registry edits in the editor should re-render open diagrams before a save. - if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName)) changed(event.document.uri); + if (isRegistryFile(event.document.fileName) || isMainFile(event.document.fileName) || isDecisionFile(event.document.fileName)) changed(event.document.uri); }), this.changeEmitter, ]; @@ -118,6 +124,12 @@ export class ModelService implements vscode.Disposable { return found ?? undefined; } + /** The model index of the model a file belongs to, or undefined outside a model. */ + async indexFor(uri: vscode.Uri): Promise { + const root = await this.rootOf(uri); + return root ? this.index(root) : undefined; + } + /** The registry of the model a file belongs to, or undefined outside a model. */ async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> { const root = await this.rootOf(uri); @@ -175,7 +187,19 @@ export class ModelService implements vscode.Disposable { } const usage = new Map(); for (const d of diagrams) for (const id of d.used) usage.set(id, [...(usage.get(id) ?? []), d.relativePath]); - return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, nested }; + + const decisionFiles = await vscode.workspace.findFiles(new vscode.RelativePattern(root, `**/*${DECISION_EXT}`), SKIP); + const decisionParts = await Promise.all( + decisionFiles.sort((a, b) => a.fsPath.localeCompare(b.fsPath)).map(async (file) => { + const rel = path.relative(root.fsPath, file.fsPath).split(path.sep).join('/'); + return { file: rel, ...parseDecision(await readText(file), rel) }; + }), + ); + const decisions = mergeDecisions(decisionParts); + decisions.problems.push(...validateDecisions(decisions, registry, new Set(diagrams.map((d) => d.relativePath)))); + const decisionUsage = new Map(); + for (const d of diagrams) for (const ref of d.decisions) decisionUsage.set(ref.id, [...(decisionUsage.get(ref.id) ?? []), { path: d.relativePath, attachedTo: ref.attachedTo }]); + return { root, main, meta, mainHasDiagram, registry, diagrams, groups: sortedGroups(diagrams), usage, decisions, decisionUsage, nested }; } dispose(): void { diff --git a/src/views/decisionView.ts b/src/views/decisionView.ts new file mode 100644 index 0000000..28196c8 --- /dev/null +++ b/src/views/decisionView.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import * as vscode from 'vscode'; +import { isDecisionFile, parseDecision } from '../model/decisions'; +import { isMainFile, isRegistryFile } from '../model/format'; +import { DecisionPanel, decisionPanelFromIndex, decisionPanelStandalone } from '../model/panel'; +import { ModelService } from '../model/service'; +import { readText } from '../util/files'; +import { log } from '../util/log'; +import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; +import { decisionPage, makeNonce } from '../webview/page'; +import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; + +export { DECISION_VIEW_TYPE }; + +class DecisionDocument implements vscode.CustomDocument { + constructor(readonly uri: vscode.Uri) {} + dispose(): void {} +} + +class DecisionView implements vscode.Disposable { + private seq = 0; + private timer: NodeJS.Timeout | undefined; + private last: string | undefined; + root: vscode.Uri | undefined; + private readonly disposables: vscode.Disposable[] = []; + + constructor( + private readonly models: ModelService, + readonly panel: vscode.WebviewPanel, + readonly uri: vscode.Uri, + ) { + this.disposables.push( + panel.webview.onDidReceiveMessage((message: WebviewMessage) => { + if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE); + if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line); + if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE); + void handleWebviewMessage(message, this.uri, this.panel); + }), + ); + void this.update(); + } + + private async openInModel(relativePath: string, viewType: string): Promise { + if (!this.root) return; + const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/')); + try { + await vscode.workspace.fs.stat(target); + } catch { + vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`); + return; + } + await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active); + } + + private async openRegistryLine(file: string, line?: number): Promise { + if (!this.root) return; + const target = vscode.Uri.joinPath(this.root, ...file.split('/')); + const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One; + const at = Math.max(line ?? 0, 0); + await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) }); + } + + scheduleUpdate(): void { + clearTimeout(this.timer); + this.timer = setTimeout(() => void this.update(), 300); + } + + async update(): Promise { + clearTimeout(this.timer); + const seq = ++this.seq; + let decision: DecisionPanel; + try { + this.root = await this.models.rootOf(this.uri); + if (this.root) { + const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/'); + decision = decisionPanelFromIndex(await this.models.index(this.root), rel); + } else { + const name = path.basename(this.uri.path); + decision = decisionPanelStandalone(name, parseDecision(await readText(this.uri), name)); + } + } catch (error) { + log().warn(`Decision view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (seq !== this.seq) return; + const fingerprint = JSON.stringify(decision); + if (fingerprint === this.last) return; + this.last = fingerprint; + this.panel.webview.html = decisionPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, decision); + } + + dispose(): void { + clearTimeout(this.timer); + for (const d of this.disposables.splice(0)) d.dispose(); + } +} + +/** Read-only custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */ +export class DecisionViewProvider implements vscode.CustomReadonlyEditorProvider, vscode.Disposable { + private readonly views = new Set(); + private active: DecisionView | undefined; + private readonly disposables: vscode.Disposable[]; + + constructor(private readonly models: ModelService) { + this.disposables = [ + vscode.window.registerCustomEditorProvider(DECISION_VIEW_TYPE, this, { + webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true }, + supportsMultipleEditorsPerDocument: true, + }), + models.onDidChangeModel(({ root }) => { + for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate(); + }), + vscode.workspace.onDidChangeTextDocument((event) => { + if (!isDecisionFile(event.document.fileName) && !isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return; + for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate(); + }), + ]; + } + + /** File shown in the focused decision view, if any. */ + get activeUri(): vscode.Uri | undefined { + return this.active?.uri; + } + + openCustomDocument(uri: vscode.Uri): DecisionDocument { + return new DecisionDocument(uri); + } + + async resolveCustomEditor(document: DecisionDocument, panel: vscode.WebviewPanel): Promise { + panel.webview.options = { enableScripts: true }; + const view = new DecisionView(this.models, panel, document.uri); + this.views.add(view); + const onViewState = () => { + if (panel.active) this.active = view; + else if (this.active === view) this.active = undefined; + if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root)); + }; + panel.onDidChangeViewState(onViewState); + onViewState(); + panel.onDidDispose(() => { + this.views.delete(view); + if (this.active === view) this.active = undefined; + view.dispose(); + }); + } + + dispose(): void { + for (const view of this.views) view.dispose(); + this.views.clear(); + for (const d of this.disposables.splice(0)) d.dispose(); + } +} diff --git a/src/views/diagramView.ts b/src/views/diagramView.ts index 8d310b1..154d449 100644 --- a/src/views/diagramView.ts +++ b/src/views/diagramView.ts @@ -14,7 +14,7 @@ import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page'; import { viewer3dOption, viewerRoots } from '../webview/viewer3d'; -import { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; +import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE }; @@ -66,6 +66,10 @@ class DiagramPreview implements vscode.Disposable { void this.openLinked(message.file, REGISTRY_VIEW_TYPE); return; } + if (message.type === 'openDecision') { + void this.openLinked(message.file, DECISION_VIEW_TYPE); + return; + } void handleWebviewMessage(message, this.uri, this.panel); }), ); @@ -200,10 +204,11 @@ class DiagramPreview implements vscode.Disposable { viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri), links: Object.fromEntries(prepared.links), descriptions: Object.fromEntries(prepared.descriptions), + decisions: prepared.decisions, model, }); // Nonces differ per render, so compare the renderer output instead. - const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions]])}\u0000${result.html}`; + const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`; if (!force && this.rendered && fingerprint === this.lastHtml) { void this.panel.webview.postMessage({ type: 'notice', notice: undefined }); return; diff --git a/src/views/registryView.ts b/src/views/registryView.ts index d29e8ff..fb3a7dc 100644 --- a/src/views/registryView.ts +++ b/src/views/registryView.ts @@ -7,7 +7,7 @@ import { readText } from '../util/files'; import { log } from '../util/log'; import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; import { makeNonce, registryPage } from '../webview/page'; -import { DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; +import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes'; export { REGISTRY_VIEW_TYPE }; @@ -32,6 +32,7 @@ class RegistryView implements vscode.Disposable { panel.webview.onDidReceiveMessage((message: WebviewMessage) => { if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE); if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line); + if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE); void handleWebviewMessage(message, this.uri, this.panel); }), ); diff --git a/src/views/viewTypes.ts b/src/views/viewTypes.ts index 0830573..1bed0c1 100644 --- a/src/views/viewTypes.ts +++ b/src/views/viewTypes.ts @@ -4,3 +4,5 @@ export const PREVIEW_VIEW_TYPE = 'archify.preview'; export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram'; /** Default editor for .archify-registry files: the elements as cards. */ export const REGISTRY_VIEW_TYPE = 'archify.registry'; +/** Default editor for .archify-decision files: the decision as a page. */ +export const DECISION_VIEW_TYPE = 'archify.decision'; diff --git a/src/webview/messages.ts b/src/webview/messages.ts index 25be46b..5e4bc7f 100644 --- a/src/webview/messages.ts +++ b/src/webview/messages.ts @@ -11,6 +11,7 @@ export type WebviewMessage = | { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string } | { type: 'openDiagram'; path: string } | { type: 'openRegistry'; file: string; line?: number } + | { type: 'openDecision'; file: string } | { type: 'error'; message: string }; /** Column for opening files next to a webview without covering it. */ diff --git a/src/webview/page.ts b/src/webview/page.ts index 233b1e5..b026337 100644 --- a/src/webview/page.ts +++ b/src/webview/page.ts @@ -22,9 +22,14 @@ export interface PageOptions { descriptions?: Record; /** Registry page: the elements of one .archify-registry file. */ registry?: RegistryPanel; + /** Decision page: one .archify-decision file. */ + decision?: DecisionPanel; + /** Diagram page: decisions of this diagram, for the Decisions rail panel. */ + decisions?: DiagramDecision[]; } -import type { ModelPanel, RegistryPanel } from '../model/panel'; +import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel'; +import type { DiagramDecision } from '../model/prepare'; export function makeNonce(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; @@ -79,6 +84,8 @@ function bridgeScript(options: PageOptions): string { links: options.links ?? {}, model: options.model, registry: options.registry, + decision: options.decision, + decisions: options.decisions || [], descriptions: options.descriptions || {}, }; return `(function () { @@ -234,12 +241,48 @@ function bridgeScript(options: PageOptions): string { }); } else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet')); list.appendChild(used); + if (element.decisions && element.decisions.length) { + var decided = el('li', 'archify-model-card-used'); + element.decisions.forEach(function (row) { decided.appendChild(decisionChip(row)); }); + list.appendChild(decided); + } card.appendChild(list); card.addEventListener('click', function () { openElementModal(element); }); card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } }); return card; } + // A decision as a chip (id, title, status) that opens its page. + function decisionChip(row) { + var chip = el('button', 'archify-model-used-link archify-decision-chip is-' + row.status, ''); + chip.type = 'button'; + chip.appendChild(el('span', 'archify-decision-dot')); + chip.appendChild(document.createTextNode(row.id + (row.title ? ' · ' + row.title : ''))); + chip.title = 'Decision ' + row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')'; + chip.addEventListener('click', function (event) { event.stopPropagation(); if (row.file) post({ type: 'openDecision', file: row.file }); }); + return chip; + } + function statusBadge(row) { + var badge = el('span', 'archify-decision-status is-' + row.status, row.status + (row.supersededBy ? ' by ' + row.supersededBy : '')); + return badge; + } + // A decision as a row in a list: id, title, status, date; click opens it. + function decisionRowItem(row) { + var item = el('li', 'archify-model-item archify-decision-item is-' + row.status); + var button = el('button', 'archify-model-open archify-decision-open'); + button.type = 'button'; + button.title = row.file || row.id; + var line = el('span', 'archify-decision-line'); + line.appendChild(el('span', 'archify-decision-id', row.id)); + line.appendChild(el('span', 'archify-model-item-title', row.title || '')); + line.appendChild(statusBadge(row)); + if (row.date) line.appendChild(el('span', 'archify-model-item-meta', row.date)); + button.appendChild(line); + button.addEventListener('click', function () { if (row.file) post({ type: 'openDecision', file: row.file }); }); + item.appendChild(button); + return item; + } + // Modal: one element, where it is defined and the diagrams that use it. function openElementModal(element) { closeElementModal(); @@ -292,6 +335,12 @@ function bridgeScript(options: PageOptions): string { }); modal.appendChild(list); } + if (element.decisions && element.decisions.length) { + modal.appendChild(el('h3', 'archify-model-title', 'Decisions · ' + element.decisions.length)); + var decisions = el('ul', 'archify-model-list archify-decision-list'); + element.decisions.forEach(function (row) { decisions.appendChild(decisionRowItem(row)); }); + modal.appendChild(decisions); + } backdrop.appendChild(modal); backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); }); document.body.appendChild(backdrop); @@ -325,6 +374,130 @@ function bridgeScript(options: PageOptions): string { document.body.appendChild(body); } + // The decision page: one .archify-decision file. + function renderDecision(page) { + if (!page) return; + var d = page.decision; + var head = el('section', 'archify-model archify-model-head'); + head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : ''))); + head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file)); + if (d) { + var meta = el('p', 'archify-decision-meta'); + meta.appendChild(statusBadge(d)); + if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date)); + meta.appendChild(el('span', 'archify-model-item-meta', page.file)); + head.appendChild(meta); + } + if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.')); + page.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); }); + document.body.appendChild(head); + var body = el('section', 'archify-model archify-model-body'); + if (d) { + [['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) { + if (!pair[1]) return; + var section = el('section', 'archify-decision-section'); + section.appendChild(el('h2', 'archify-model-title', pair[0])); + section.appendChild(el('p', 'archify-decision-text', pair[1].trim())); + body.appendChild(section); + }); + if (d.supersedes.length || page.supersededBy.length) { + var rel = el('section', 'archify-decision-section'); + rel.appendChild(el('h2', 'archify-model-title', 'Related decisions')); + var relList = el('ul', 'archify-model-list archify-decision-list'); + d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); }); + page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); }); + rel.appendChild(relList); + body.appendChild(rel); + } + } + if (page.elements.length) { + var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true); + var cards = el('div', 'cards archify-model-cards'); + page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); }); + elementsPanel.appendChild(cards); + body.appendChild(elementsPanel); + } + var diagramRows = []; + page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); }); + page.referencedBy.forEach(function (use) { + var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram'; + var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0]; + if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how }); + }); + if (diagramRows.length) { + var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true); + var list = el('ul', 'archify-model-list'); + diagramRows.forEach(function (row) { + var item = el('li', 'archify-model-item'); + var button = el('button', 'archify-model-open'); + button.type = 'button'; + button.title = row.path; + button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop())); + button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how)); + button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); }); + item.appendChild(button); + list.appendChild(item); + }); + diagramsPanel.appendChild(list); + body.appendChild(diagramsPanel); + } + document.body.appendChild(body); + } + + // Decisions rail panel on a diagram page: the decisions this diagram points + // at or that name it. Hover lights up what the decision is attached to. + function renderDecisionsRail() { + var rows = config.decisions || []; + var outline = document.getElementById('node-outline'); + var svg = document.querySelector('.diagram-container > svg'); + if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return; + var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions'); + nav.id = 'archify-decisions'; + var head = el('div', 'node-outline-head'); + head.appendChild(el('strong', '', 'Decisions')); + head.appendChild(el('span', 'node-outline-count', String(rows.length))); + nav.appendChild(head); + var list = el('ol', 'node-outline-list'); + var group = el('li', 'node-outline-group'); + var items = el('ul'); + function targets(row) { + var found = []; + row.attachedTo.forEach(function (a) { + if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]')); + if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]')); + }); + return found; + } + function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); } + rows.forEach(function (row) { + var item = el('li'); + var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status); + button.type = 'button'; + button.style.setProperty('--outline-tone', 'var(--text-muted)'); + var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', '); + button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : ''); + var dot = el('span', 'node-outline-dot'); + dot.setAttribute('aria-hidden', 'true'); + button.appendChild(dot); + button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : ''))); + button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : ''))); + button.addEventListener('pointerenter', function () { mark(row, true); }); + button.addEventListener('pointerleave', function () { mark(row, false); }); + button.addEventListener('focus', function () { mark(row, true); }); + button.addEventListener('blur', function () { mark(row, false); }); + button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); }); + item.appendChild(button); + items.appendChild(item); + }); + group.appendChild(items); + list.appendChild(group); + nav.appendChild(list); + var connectors = document.getElementById('archify-connectors'); + (connectors || outline).insertAdjacentElement('afterend', nav); + var archify = window.Archify; + if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule(); + } + // Elements: registry descriptions as tooltips on Archify's node index rows. function describeElements() { var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]'); @@ -484,6 +657,14 @@ function bridgeScript(options: PageOptions): string { registryPanel.appendChild(filePanel); }); } + // Decisions: one list, live ones first, superseded and rejected dimmed and last. + if (model.decisions && model.decisions.length) { + var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true); + body.appendChild(decisionsPanel); + var decisionList = el('ul', 'archify-model-list archify-decision-list'); + model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); }); + decisionsPanel.appendChild(decisionList); + } if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body); } @@ -497,7 +678,9 @@ function bridgeScript(options: PageOptions): string { } if (config.model) onReady(function () { renderModel(config.model); }); if (config.registry) onReady(function () { renderRegistry(config.registry); }); + if (config.decision) onReady(function () { renderDecision(config.decision); }); onReady(renderConnectors); + onReady(renderDecisionsRail); onReady(describeElements); if (config.notice) { if (document.readyState === 'loading') { @@ -559,6 +742,24 @@ const MODEL_STYLE = ` .archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; } .archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; } .archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } +/* Decisions: rows, chips and status badges. */ +.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; } +.archify-decision-open { padding: 0.55rem 0.85rem; } +.archify-decision-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; } +.archify-decision-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.8125rem; color: var(--m-muted); } +.archify-decision-status { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.05rem 0.45rem; border-radius: 999px; border: 1px solid var(--m-border); color: var(--m-muted); } +.archify-decision-status.is-accepted { color: var(--m-backend); border-color: var(--m-backend); } +.archify-decision-status.is-proposed { color: var(--m-cloud); border-color: var(--m-cloud); } +.archify-decision-status.is-rejected { color: var(--m-security); border-color: var(--m-security); } +.archify-decision-item.is-superseded .archify-model-open, .archify-decision-item.is-rejected .archify-model-open { opacity: 0.6; } +.archify-decision-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-family: inherit; } +.archify-decision-dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--m-muted); } +.archify-decision-chip.is-accepted .archify-decision-dot { background: var(--m-backend); } +.archify-decision-chip.is-proposed .archify-decision-dot { background: var(--m-cloud); } +.archify-decision-chip.is-rejected .archify-decision-dot { background: var(--m-security); } +.archify-decision-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin: 0.4rem 0 0; } +.archify-decision-section { margin-top: 0.5rem; } +.archify-decision-text { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.6; color: var(--m-text); white-space: pre-line; } /* Registry cards: Archify's own .card look, with fallbacks for the page without a diagram. */ .archify-model { --m-frontend: var(--frontend-stroke, #22d3ee); --m-backend: var(--backend-stroke, #34d399); --m-database: var(--database-stroke, #a78bfa); --m-cloud: var(--cloud-stroke, #fbbf24); --m-security: var(--security-stroke, #fb7185); --m-messagebus: var(--messagebus-stroke, #fb923c); --m-external: var(--external-stroke, #94a3b8); } .archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; } @@ -582,6 +783,14 @@ const CONNECTOR_STYLE = ` .diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); } .diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; } .archify-connectors .node-outline-item strong { font-weight: 600; } +.diagram-container svg [data-node-id][data-archify-hit] { filter: drop-shadow(0 0 6px var(--frontend-stroke)); } +/* Bottom rail with a Decisions panel: three columns. */ +html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions) { grid-template-areas: "cards cards cards" "index connectors decisions"; grid-template-columns: repeat(3, minmax(0, 1fr)); } +html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions):has(> #node-outline[hidden]) { grid-template-areas: "cards cards" "connectors decisions"; grid-template-columns: repeat(2, minmax(0, 1fr)); } +html[data-reader-rail="bottom"] .reader-rail > #archify-decisions { display: block; grid-area: decisions; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); } +html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-head { padding: 1rem 0.25rem 0.375rem; } +html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; } +.archify-decision-rail-item.is-superseded, .archify-decision-rail-item.is-rejected { opacity: 0.6; } /* Bottom rail: Elements and Connectors side by side; Connectors alone when Archify hides its index. */ html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors) { grid-template-areas: "cards cards" "index connectors"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 1rem; } html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors):has(> #node-outline[hidden]) { grid-template-areas: "cards" "connectors"; grid-template-columns: minmax(0, 1fr); } @@ -675,6 +884,25 @@ export function registryPage(options: Pick`; } +/** The decision page for one .archify-decision file. */ +export function decisionPage(options: Pick, decision: DecisionPanel): string { + const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} }); + return ` + + + + + + + + + +`; +} + /** A small VS Code-styled page for loading and error states. */ export function messagePage( options: Pick, diff --git a/templates/decision.archify-decision b/templates/decision.archify-decision new file mode 100644 index 0000000..61e45f1 --- /dev/null +++ b/templates/decision.archify-decision @@ -0,0 +1,17 @@ +# An architecture decision of this model. The id is unique in the model; +# elements are registry ids, diagrams are paths from the model root. +schema_version: 1 +decision: + id: __ID__ + title: __TITLE__ + status: proposed # proposed | accepted | superseded | rejected + date: __DATE__ + elements: [] + diagrams: [] + supersedes: [] +context: > + What was the situation, and what forced a decision. +decision: > + What was decided, in one or two sentences. +consequences: > + What becomes easier, what becomes harder, what must change. diff --git a/templates/models/c4-e-shop/0200-containers/online-shop.archify b/templates/models/c4-e-shop/0200-containers/online-shop.archify index 8d960d8..d764ca6 100644 --- a/templates/models/c4-e-shop/0200-containers/online-shop.archify +++ b/templates/models/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/templates/models/c4-e-shop/decisions/0001-postgres-as-source-of-truth.archify-decision b/templates/models/c4-e-shop/decisions/0001-postgres-as-source-of-truth.archify-decision new file mode 100644 index 0000000..12a8db9 --- /dev/null +++ b/templates/models/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/templates/models/c4-e-shop/decisions/0002-queue-between-api-and-worker.archify-decision b/templates/models/c4-e-shop/decisions/0002-queue-between-api-and-worker.archify-decision new file mode 100644 index 0000000..b6ae73b --- /dev/null +++ b/templates/models/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/templates/models/c4-e-shop/decisions/0003-opensearch-for-product-search.archify-decision b/templates/models/c4-e-shop/decisions/0003-opensearch-for-product-search.archify-decision new file mode 100644 index 0000000..c082e78 --- /dev/null +++ b/templates/models/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/templates/models/c4-event-based-integration/0200-containers/platform.archify b/templates/models/c4-event-based-integration/0200-containers/platform.archify index 61246f2..8a33796 100644 --- a/templates/models/c4-event-based-integration/0200-containers/platform.archify +++ b/templates/models/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/templates/models/c4-event-based-integration/decisions/dead-letter-queue.archify-decision b/templates/models/c4-event-based-integration/decisions/dead-letter-queue.archify-decision new file mode 100644 index 0000000..ce4fc12 --- /dev/null +++ b/templates/models/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/templates/models/c4-standard-web-app/0200-containers/web-app.archify b/templates/models/c4-standard-web-app/0200-containers/web-app.archify index cef2e3f..378063e 100644 --- a/templates/models/c4-standard-web-app/0200-containers/web-app.archify +++ b/templates/models/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/templates/models/c4-standard-web-app/decisions/external-identity-provider.archify-decision b/templates/models/c4-standard-web-app/decisions/external-identity-provider.archify-decision new file mode 100644 index 0000000..b9bb1b1 --- /dev/null +++ b/templates/models/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/test/decisions.test.ts b/test/decisions.test.ts new file mode 100644 index 0000000..8399804 --- /dev/null +++ b/test/decisions.test.ts @@ -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[]; + const connections = r.diagram.connections as Record[]; + 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[])[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']); +}); diff --git a/test/examples.test.ts b/test/examples.test.ts index cd29c98..b90a215 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -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); diff --git a/test/fixtures/model/0200-containers/online-shop.archify b/test/fixtures/model/0200-containers/online-shop.archify index 8d960d8..d764ca6 100644 --- a/test/fixtures/model/0200-containers/online-shop.archify +++ b/test/fixtures/model/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/test/fixtures/model/decisions/0001-postgres-as-source-of-truth.archify-decision b/test/fixtures/model/decisions/0001-postgres-as-source-of-truth.archify-decision new file mode 100644 index 0000000..12a8db9 --- /dev/null +++ b/test/fixtures/model/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/test/fixtures/model/decisions/0002-queue-between-api-and-worker.archify-decision b/test/fixtures/model/decisions/0002-queue-between-api-and-worker.archify-decision new file mode 100644 index 0000000..b6ae73b --- /dev/null +++ b/test/fixtures/model/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/test/fixtures/model/decisions/0003-opensearch-for-product-search.archify-decision b/test/fixtures/model/decisions/0003-opensearch-for-product-search.archify-decision new file mode 100644 index 0000000..c082e78 --- /dev/null +++ b/test/fixtures/model/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/test/integration/suite.ts b/test/integration/suite.ts index 42fc345..f37b1f0 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -329,6 +329,26 @@ export async function run(): Promise { 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 { 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', () =>