Merge pull request '0.8.0: architecture decisions' (#25) from feat/decisions into main

This commit was merged in pull request #25.
This commit is contained in:
2026-10-19 13:13:17 -07:00
49 changed files with 1641 additions and 55 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.8.0 — 2026-10-19
- **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: [<id>]` 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
- **No generated cards.** The cards 0.7.0 generated under model diagrams from registry descriptions are gone; they repeated the Elements panel. The description now shows as the tooltip of the element's row in the Elements panel. Hand-written `cards:` are unchanged.
+5 -2
View File
@@ -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,15 +111,18 @@ 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
```
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, then two collapsible panels: **Diagrams**, with a collapsible header per folder and a card per diagram (click to open), and **Registry**, one collapsible header per registry file (with an **Open** button for its view) and the file's elements as cards inside, each with the diagrams it is used in. Open or closed is remembered per page.
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, then two collapsible panels: **Diagrams**, with a collapsible header per folder and a card per diagram (click to open), and **Registries**, one collapsible header per registry file (with an **Open** button for its view) and the file's elements as cards inside, each with the diagrams it is used in. Open or closed is remembered per page.
- **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: [<id>]` 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`.
+39
View File
@@ -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.
@@ -3,6 +3,7 @@ diagram_type: architecture
meta:
title: Containers
output: containers.html
decisions: [postgres-as-source-of-truth]
components:
- ref: customer
pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue
pos: [460, 360]
size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker
pos: [670, 460]
size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
- { from: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: postgres-as-source-of-truth
title: PostgreSQL as the single source of truth
status: accepted
date: 2026-09-02
elements: [db, api, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Orders, customers and the catalogue were spread over the
shop database and two spreadsheets kept by support.
Refunds were reconciled by hand every Monday.
decision: >
One PostgreSQL database owns orders, customers and the
catalogue. The API is the only writer. Everything else
(cache, search, reports) is derived and may be rebuilt.
consequences: >
Support loses the spreadsheets and gets the admin console.
Any new store must be declared derived or get its own
decision. Backups and migrations are the API team's job.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: queue-between-api-and-worker
title: A queue between the API and the worker
status: accepted
date: 2026-09-16
elements: [api, queue, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Order confirmation emails and invoices were sent inside
the checkout request. A slow mail provider made checkout
slow; a failing one made checkout fail.
decision: >
The API enqueues a job per side effect on SQS. One worker
service consumes the queue and does the slow work.
Checkout returns as soon as the order is stored.
consequences: >
Emails arrive seconds later, not instantly. Jobs must be
idempotent because SQS delivers at least once. The worker
is the second place that writes to the database.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: opensearch-for-product-search
title: OpenSearch for product search
status: proposed
date: 2026-10-19
elements: [api, worker, db]
diagrams: [0200-containers/online-shop.archify]
context: >
Catalogue search runs as SQL LIKE queries on the
database. Typos find nothing and the catalogue page is
the slowest page in the shop.
decision: >
Add a dedicated search service on OpenSearch. The API
queries it; the worker rebuilds the index from the
database after every catalogue change.
consequences: >
The index is derived data under decision 0001 and may be
rebuilt at any time. One more service to run. Search
results can lag the database by the rebuild interval.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "archify-vscode-ext",
"version": "0.7.1",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "archify-vscode-ext",
"version": "0.7.1",
"version": "0.8.0",
"license": "MIT",
"dependencies": {
"three": "^0.186.1",
+46 -14
View File
@@ -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.1",
"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"
}
]
},
+27
View File
@@ -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: [<id>]`.",
"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."
}
}
}
+24
View File
@@ -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."
}
}
}
+24
View File
@@ -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."
}
}
}
+24
View File
@@ -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."
}
}
}
+24
View File
@@ -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."
}
}
}
+24
View File
@@ -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": {
+39 -1
View File
@@ -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: [<id>]`.',
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');
+3 -1
View File
@@ -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 {
+34
View File
@@ -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(
+6 -3
View File
@@ -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;
+8
View File
@@ -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),
+102
View File
@@ -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<vscode.CompletionItem[] | undefined> {
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<vscode.Hover | undefined> {
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<vscode.Location | undefined> {
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;
}
}
+27 -1
View File
@@ -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<void> {
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<void> {
const text = doc.getText();
const root = await this.models.rootOf(doc.uri);
+183
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, Decision>;
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<string, Decision>();
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<string, Decision>): { 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<string>): 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<string, Decision>): 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));
}
+75 -6
View File
@@ -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<string>;
/** Node id → registry description, for the Elements panel. */
descriptions: Map<string, string>;
/** 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<string>;
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<string, string>();
const used = new Set<string>();
const descriptions = new Map<string, string>();
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. */
+89 -5
View File
@@ -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<string, string[]>, diagrams: DiagramSumma
});
}
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[]): ElementCard {
export function decisionRow(d: Decision, all: Map<string, Decision>): 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<ModelIndex, 'decisions' | 'decisionUsage'> | undefined): DecisionRow[] {
if (!index) return [];
const all = index.decisions.decisions;
const ids = new Set<string>();
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<string, string[]>, diagrams: DiagramSummary[], index?: Pick<ModelIndex, 'decisions' | 'decisionUsage'>): ElementCard {
return {
id: e.id,
label: e.label,
@@ -64,22 +110,25 @@ export function elementCard(e: RegistryElement, usage: Map<string, string[]>, 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),
};
}
+37 -5
View File
@@ -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<string, string>;
/** Node id → registry description. */
descriptions: Map<string, string>;
/** 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<PreparedDiagram | undefined> {
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<string, DiagramDecision>();
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 };
}
+27 -3
View File
@@ -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<string, string[]>;
/** The model's decisions, with link problems already checked. */
decisions: DecisionSet;
/** Decision id → diagrams that point at it, with where. */
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
/** 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<ModelIndex | undefined> {
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<string, string[]>();
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<string, { path: string; attachedTo: DecisionAttachment }[]>();
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 {
+152
View File
@@ -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<void> {
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<void> {
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<void> {
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<DecisionDocument>, vscode.Disposable {
private readonly views = new Set<DecisionView>();
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<void> {
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();
}
}
+7 -2
View File
@@ -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;
+2 -1
View File
@@ -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);
}),
);
+2
View File
@@ -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';
+1
View File
@@ -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. */
+230 -2
View File
@@ -22,9 +22,14 @@ export interface PageOptions {
descriptions?: Record<string, string>;
/** 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]');
@@ -468,7 +641,7 @@ function bridgeScript(options: PageOptions): string {
byFile[element.file].push(element);
});
files.sort();
var registryPanel = panel('registry', 'archify-model-registry', 'Registry', files.length + (files.length === 1 ? ' file' : ' files'), true);
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), true);
body.appendChild(registryPanel);
files.forEach(function (file) {
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
@@ -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<PageOptions, 'cspSource' | 'nonce' |
</html>`;
}
/** The decision page for one .archify-decision file. */
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, decision: DecisionPanel): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} });
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
<script nonce="${options.nonce}">${bridge}</script>
<style>
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
${MODEL_STYLE}
</style>
</head>
<body></body>
</html>`;
}
/** A small VS Code-styled page for loading and error states. */
export function messagePage(
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
+17
View File
@@ -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.
@@ -3,6 +3,7 @@ diagram_type: architecture
meta:
title: Containers
output: containers.html
decisions: [postgres-as-source-of-truth]
components:
- ref: customer
pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue
pos: [460, 360]
size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker
pos: [670, 460]
size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
- { from: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: postgres-as-source-of-truth
title: PostgreSQL as the single source of truth
status: accepted
date: 2026-09-02
elements: [db, api, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Orders, customers and the catalogue were spread over the
shop database and two spreadsheets kept by support.
Refunds were reconciled by hand every Monday.
decision: >
One PostgreSQL database owns orders, customers and the
catalogue. The API is the only writer. Everything else
(cache, search, reports) is derived and may be rebuilt.
consequences: >
Support loses the spreadsheets and gets the admin console.
Any new store must be declared derived or get its own
decision. Backups and migrations are the API team's job.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: queue-between-api-and-worker
title: A queue between the API and the worker
status: accepted
date: 2026-09-16
elements: [api, queue, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Order confirmation emails and invoices were sent inside
the checkout request. A slow mail provider made checkout
slow; a failing one made checkout fail.
decision: >
The API enqueues a job per side effect on SQS. One worker
service consumes the queue and does the slow work.
Checkout returns as soon as the order is stored.
consequences: >
Emails arrive seconds later, not instantly. Jobs must be
idempotent because SQS delivers at least once. The worker
is the second place that writes to the database.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: opensearch-for-product-search
title: OpenSearch for product search
status: proposed
date: 2026-10-19
elements: [api, worker, db]
diagrams: [0200-containers/online-shop.archify]
context: >
Catalogue search runs as SQL LIKE queries on the
database. Typos find nothing and the catalogue page is
the slowest page in the shop.
decision: >
Add a dedicated search service on OpenSearch. The API
queries it; the worker rebuilds the index from the
database after every catalogue change.
consequences: >
The index is derived data under decision 0001 and may be
rebuilt at any time. One more service to run. Search
results can lag the database by the rebuild interval.
@@ -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] }
@@ -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.
@@ -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 }
@@ -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.
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { parse } from 'yaml';
import { effectiveStatus, mergeDecisions, parseDecision, slugify, sortDecisions, validateDecisions } from '../src/model/decisions';
import { mergeRegistries, parseRegistry, resolveRefs, summarizeDiagram } from '../src/model/format';
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
function registryOfModel() {
const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) })));
}
function decisionsOfModel() {
return mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
}
test('decision files parse with lines for every linked id', () => {
const { decision, problems } = parseDecision(read(DECISION_FILES[1]), 'd2');
assert.deepEqual(problems, []);
assert.ok(decision);
assert.equal(decision.id, 'queue-between-api-and-worker');
assert.equal(decision.title, 'A queue between the API and the worker');
assert.equal(decision.status, 'accepted');
assert.deepEqual(decision.elements, ['api', 'queue', 'worker']);
assert.deepEqual(decision.diagrams, ['0200-containers/online-shop.archify']);
assert.equal(decision.lines.elements.length, 3);
assert.ok(decision.context && decision.decision && decision.consequences);
});
test('decision files: broken YAML, missing block, unknown status', () => {
assert.equal(parseDecision('decision: {\n', 'x').problems[0].severity, 'error');
assert.match(parseDecision('context: only text\n', 'x').problems[0].message, /"decision" block/);
const bad = parseDecision('decision:\n id: a\n title: A\n status: maybe\n elements: api\n', 'x');
assert.deepEqual(bad.problems.map((p) => p.message.split(';')[0]), ['Unknown status "maybe"', '"elements" must be a list.']);
assert.equal(bad.problems[0].line, 3);
});
test('decisions merge, validate links, and the superseded rule applies', () => {
const set = decisionsOfModel();
assert.deepEqual(set.problems, []);
assert.equal(set.decisions.size, 3);
const registry = registryOfModel();
const diagrams = new Set(['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']);
assert.deepEqual(validateDecisions(set, registry, diagrams), []);
// Duplicate id, unknown element, missing diagram, dangling supersedes.
const extra = parseDecision('decision:\n id: queue-between-api-and-worker\n title: Dup\n elements: [ghost]\n diagrams: [0900/x.archify]\n supersedes: [nope]\n', 'dup');
const merged = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'dup', ...extra }]);
assert.match(merged.problems[0].message, /defined twice/);
const newer = parseDecision('decision:\n id: one-database\n title: Replace the first\n status: accepted\n supersedes: [postgres-as-source-of-truth]\n', 'd4');
const withNewer = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'd4', ...newer }]);
const problems = validateDecisions(mergeDecisions([{ file: 'dup', ...extra }]), registry, diagrams);
assert.deepEqual(problems.map((p) => p.message.split(':')[0]), ['Unknown element "ghost"', 'Diagram "0900/x.archify" does not exist in this model.', 'Decision "nope" (in supersedes) does not exist in this model.']);
assert.deepEqual(effectiveStatus(withNewer.decisions.get('postgres-as-source-of-truth')!, withNewer.decisions), { status: 'superseded', supersededBy: 'one-database' });
assert.deepEqual(effectiveStatus(withNewer.decisions.get('queue-between-api-and-worker')!, withNewer.decisions), { status: 'accepted' });
const order = sortDecisions([...withNewer.decisions.values()], withNewer.decisions).map((d) => d.id);
assert.equal(order[order.length - 1], 'postgres-as-source-of-truth', 'superseded sorts last');
assert.equal(slugify('OpenSearch for product search!'), 'opensearch-for-product-search');
// Numeric ids are a YAML trap and are refused on both sides.
assert.match(parseDecision('decision:\n id: 0001\n title: N\n', 'n').problems[0].message, /is a number to YAML/);
assert.match(parseDecision('decision:\n id: ok\n title: N\n supersedes: [0001]\n', 'n').problems[0].message, /is a number to YAML/);
});
test('diagrams: decisions keys are stripped, collected and checked', () => {
const registry = registryOfModel();
const known = new Set(decisionsOfModel().decisions.keys());
const doc = parse(read('0200-containers/online-shop.archify'));
const r = resolveRefs(doc, { registry, decisions: known, fileName: 'c.archify' });
assert.deepEqual(r.problems, []);
assert.ok(!('decisions' in r.diagram));
const components = r.diagram.components as Record<string, unknown>[];
const connections = r.diagram.connections as Record<string, unknown>[];
assert.ok(components.every((c) => !('decisions' in c)) && connections.every((c) => !('decisions' in c)));
assert.deepEqual(r.decisionRefs.map((d) => [d.id, d.attachedTo.kind, d.pointer]), [
['postgres-as-source-of-truth', 'diagram', '/decisions/0'],
['queue-between-api-and-worker', 'node', '/components/5/decisions/0'],
['queue-between-api-and-worker', 'edge', '/connections/4/decisions/0'],
]);
const numeric = resolveRefs({ decisions: [1], components: [] }, { registry, decisions: known, fileName: 'x.archify' });
assert.match(numeric.problems[0].message, /is a number to YAML/);
const unknown = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0], decisions: ['nope'] }] }, { registry, decisions: known, fileName: 'x.archify' });
assert.equal(unknown.problems[0].pointer, '/components/0/decisions/0');
assert.match(unknown.problems[0].message, /Unknown decision "nope"/);
const outside = resolveRefs({ decisions: ['postgres-as-source-of-truth'], components: [] }, { fileName: '/w/loose.archify' });
assert.match(outside.problems[0].message, /not inside a model/);
// Other kinds: nodes and edges carry the key too.
const workflow = resolveRefs({ nodes: [{ id: 'n', type: 'process', label: 'N', decisions: ['postgres-as-source-of-truth'] }], edges: [{ from: 'n', to: 'n', decisions: ['queue-between-api-and-worker'] }] }, { registry, decisions: known, fileName: 'w.archify' });
assert.deepEqual(workflow.decisionRefs.map((d) => d.attachedTo.kind), ['node', 'edge']);
assert.ok(!('decisions' in (workflow.diagram.nodes as Record<string, unknown>[])[0]));
const summary = summarizeDiagram(read('0200-containers/online-shop.archify'), '0200-containers/online-shop.archify');
assert.deepEqual(summary.decisions.map((d) => d.id), ['postgres-as-source-of-truth', 'queue-between-api-and-worker', 'queue-between-api-and-worker']);
});
+11 -1
View File
@@ -7,6 +7,7 @@ import { parse } from 'yaml';
import { validate } from '../src/archify/cli';
import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples';
import { MAIN_FILE, REGISTRY_EXT, hasDiagramBody, mergeRegistries, parseRegistry, readModelMeta, resolveRefs } from '../src/model/format';
import { DECISION_EXT, mergeDecisions, parseDecision, validateDecisions } from '../src/model/decisions';
import { readDiagramSource } from '../src/sources/detect';
const root = path.resolve(__dirname, '..', '..');
@@ -58,6 +59,15 @@ for (const example of MODEL_EXAMPLES) {
if (element.diagram) assert.ok(files.includes(element.diagram), `${element.id} links to missing ${element.diagram}`);
}
// Decisions: at least one, all links resolve.
const decisionFiles = files.filter((f) => f.endsWith(DECISION_EXT));
assert.ok(decisionFiles.length >= 1, 'an example carries at least one decision');
const decisions = mergeDecisions(decisionFiles.map((file) => ({ file, ...parseDecision(read(file), file) })));
assert.deepEqual(decisions.problems, []);
const diagramPaths = new Set(files.filter((f) => f.endsWith('.archify') && f !== MAIN_FILE));
assert.deepEqual(validateDecisions(decisions, registry, diagramPaths), []);
for (const d of decisions.decisions.values()) assert.ok(d.title && d.status && d.context && d.decision && d.consequences, `${d.id} is complete`);
// Diagrams: the three C4 levels, each resolving without problems and valid for Archify.
const diagrams = files.filter((f) => f.endsWith('.archify')).sort();
for (const level of ['0100-context', '0200-containers', '0300-components']) {
@@ -67,7 +77,7 @@ for (const example of MODEL_EXAMPLES) {
for (const file of diagrams) {
const source = readDiagramSource(read(file), file);
assert.ok(source, `${file} is not a diagram`);
const resolved = resolveRefs(source.diagram, { registry, fileName: file });
const resolved = resolveRefs(source.diagram, { registry, decisions: new Set(decisions.decisions.keys()), fileName: file });
assert.deepEqual(resolved.problems, [], file);
for (const link of resolved.links.values()) assert.ok(files.includes(link), `${file} links to missing ${link}`);
for (const id of resolved.used) used.add(id);
+3 -1
View File
@@ -3,6 +3,7 @@ diagram_type: architecture
meta:
title: Containers
output: containers.html
decisions: [postgres-as-source-of-truth]
components:
- ref: customer
pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue
pos: [460, 360]
size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker
pos: [670, 460]
size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
- { from: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: postgres-as-source-of-truth
title: PostgreSQL as the single source of truth
status: accepted
date: 2026-09-02
elements: [db, api, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Orders, customers and the catalogue were spread over the
shop database and two spreadsheets kept by support.
Refunds were reconciled by hand every Monday.
decision: >
One PostgreSQL database owns orders, customers and the
catalogue. The API is the only writer. Everything else
(cache, search, reports) is derived and may be rebuilt.
consequences: >
Support loses the spreadsheets and gets the admin console.
Any new store must be declared derived or get its own
decision. Backups and migrations are the API team's job.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: queue-between-api-and-worker
title: A queue between the API and the worker
status: accepted
date: 2026-09-16
elements: [api, queue, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Order confirmation emails and invoices were sent inside
the checkout request. A slow mail provider made checkout
slow; a failing one made checkout fail.
decision: >
The API enqueues a job per side effect on SQS. One worker
service consumes the queue and does the slow work.
Checkout returns as soon as the order is stored.
consequences: >
Emails arrive seconds later, not instantly. Jobs must be
idempotent because SQS delivers at least once. The worker
is the second place that writes to the database.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: opensearch-for-product-search
title: OpenSearch for product search
status: proposed
date: 2026-10-19
elements: [api, worker, db]
diagrams: [0200-containers/online-shop.archify]
context: >
Catalogue search runs as SQL LIKE queries on the
database. Typos find nothing and the catalogue page is
the slowest page in the shop.
decision: >
Add a dedicated search service on OpenSearch. The API
queries it; the worker rebuilds the index from the
database after every catalogue change.
consequences: >
The index is derived data under decision 0001 and may be
rebuilt at any time. One more service to run. Search
results can lag the database by the rebuild interval.
+21 -1
View File
@@ -329,6 +329,26 @@ export async function run(): Promise<void> {
assert.equal(editor.document.languageId, 'yaml');
});
await check('a .archify-decision file opens as the decision view; an unknown decision id is a problem in a diagram', async () => {
const decision = workspaceFile('model/decisions/0002-queue-between-api-and-worker.archify-decision');
await vscode.commands.executeCommand('vscode.open', decision);
const tab = await waitFor('decision view tab', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.decision' && t.input.uri.toString() === decision.toString()),
);
assert.ok(tab);
// A diagram that points at a decision nobody defines.
const bad = workspaceFile('model/0300-components/bad-decision.archify');
await vscode.workspace.fs.writeFile(bad, Buffer.from('schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ndecisions: [nobody-decided]\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n decisions: [queue-between-api-and-worker]\n', 'utf8'));
const doc = await vscode.workspace.openTextDocument(bad);
await vscode.window.showTextDocument(doc);
const problem = await waitFor('unknown decision problem', () =>
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown decision "nobody-decided"/.test(d.message)),
);
assert.equal(problem.range.start.line, 5);
assert.ok(!vscode.languages.getDiagnostics(doc.uri).some((d) => /queue-between-api-and-worker/.test(d.message)), 'the known decision is not reported');
await vscode.workspace.fs.delete(bad);
});
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
const folder = workspaceFile('new-shop');
await vscode.workspace.fs.createDirectory(folder);
@@ -385,7 +405,7 @@ export async function run(): Promise<void> {
assert.match(content, /name: Shop two/);
assert.match(content, /title: Shop two — overview/);
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort();
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'main.archify', 'registry']);
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'decisions', 'main.archify', 'registry']);
const registry = (await vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(folder, 'registry'))).map(([name]) => name).sort();
assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
const tab = await waitFor('model page tab', () =>