Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66e819abe | ||
|
|
2ba66c1ee2 | ||
|
|
ebf320a023 | ||
|
|
33ebdd6328 | ||
|
|
9684464630 | ||
|
|
094ca2925f | ||
|
|
de1a57e4c6 |
No files matched your search
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.1 — 2026-10-20
|
||||
|
||||
- **Archify | New Registry…** on a folder (also *File → New File…* and the Command Palette) creates an `.archify-registry` file from the template and opens it.
|
||||
- The Archify entries in the Explorer menu and *File → New File…* come in the order New Model, New Registry, New Diagram, New Decision.
|
||||
|
||||
## 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.
|
||||
- **Model page, Registry panel:** lists the registry files of the model, one collapsible header per file with its element count and an **Open** button that opens the registry view; expand a file to see its elements as cards. Before, all elements of all files were shown in one grid.
|
||||
|
||||
## 0.7.0 — 2026-10-19
|
||||
|
||||
- **Models:** a folder with a `main.archify` at its top is a model, a set of diagrams that belong together. Opening `main.archify` shows the model page: name and description, the overview diagram if the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used.
|
||||
|
||||
@@ -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 Model…*, *Archify | New Registry…*, *Archify | New Diagram…*, *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**, the shared elements as cards with the diagrams each 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.
|
||||
- **Cards.** A model diagram without `cards` of its own shows one card per shared element it draws, from the registry descriptions. The model page shows the registry as the same cards, each with the diagrams it is used in.
|
||||
- **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`.
|
||||
|
||||
+41
-2
@@ -64,9 +64,9 @@ components:
|
||||
- Before Archify runs, the extension replaces each `ref` component with the registry element plus the diagram's own fields, drops `description` and `diagram`, and hands Archify an ordinary component.
|
||||
- Connections, boundaries and everything else stay as they are and use the ids as before.
|
||||
|
||||
## Cards under a model diagram
|
||||
## Descriptions under a model diagram
|
||||
|
||||
Archify shows `cards` (a coloured dot, a title, a few lines) below a diagram. A diagram inside a model that draws shared elements and defines no `cards` of its own gets one card per drawn element that has a `description` in the registry, in drawing order, with the dot colour of the element's type. Write your own `cards:` in the diagram to replace them.
|
||||
Archify's **Elements** panel under a diagram lists its nodes. For a `ref` component the row's tooltip is the element's registry `description`. Archify's own `cards:` stay what they are: write them in the diagram when you want text blocks below it; nothing is generated.
|
||||
|
||||
## Links between diagrams
|
||||
|
||||
@@ -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.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "archify-vscode-ext",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "archify-vscode-ext",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"three": "^0.186.1",
|
||||
|
||||
+70
-24
@@ -2,7 +2,7 @@
|
||||
"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, …) and opens rendered Archify HTML.",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.1",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
@@ -33,7 +33,9 @@
|
||||
"registry",
|
||||
"sequence",
|
||||
"workflow",
|
||||
"yaml"
|
||||
"yaml",
|
||||
"adr",
|
||||
"decisions"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
@@ -42,19 +44,30 @@
|
||||
"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.newModel",
|
||||
"title": "Archify | New Model…",
|
||||
"icon": "$(new-folder)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newRegistry",
|
||||
"title": "Archify | New Registry…",
|
||||
"icon": "$(symbol-field)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"title": "Archify | New Diagram…",
|
||||
"icon": "$(new-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"title": "Archify | New Model…",
|
||||
"icon": "$(new-folder)"
|
||||
"command": "archify.newDecision",
|
||||
"title": "Archify | New Decision…",
|
||||
"icon": "$(law)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
@@ -125,11 +138,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 +163,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,40 +178,50 @@
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"command": "archify.newModel",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"command": "archify.newRegistry",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"command": "archify.newDiagram",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"command": "archify.newDecision",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@4"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@5"
|
||||
},
|
||||
{
|
||||
"command": "archify.openHtmlViewer",
|
||||
"when": "resourceExtname == .html",
|
||||
"group": "zzz_archify@3"
|
||||
"group": "zzz_archify@6"
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@7"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry)?$/",
|
||||
"group": "zzz_archify@5"
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@8"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
@@ -210,12 +233,20 @@
|
||||
],
|
||||
"file/newFile": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"group": "file"
|
||||
"command": "archify.newModel",
|
||||
"group": "file@1"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"group": "file"
|
||||
"command": "archify.newRegistry",
|
||||
"group": "file@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"group": "file@3"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"group": "file@4"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -254,6 +285,16 @@
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.decision",
|
||||
"displayName": "Archify Decision",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.archify-decision"
|
||||
}
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.htmlViewer",
|
||||
"displayName": "Archify HTML Viewer",
|
||||
@@ -381,7 +422,8 @@
|
||||
"id": "yaml",
|
||||
"extensions": [
|
||||
".archify",
|
||||
".archify-registry"
|
||||
".archify-registry",
|
||||
".archify-decision"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -393,6 +435,10 @@
|
||||
{
|
||||
"fileMatch": "*.archify-registry",
|
||||
"url": "./schemas/archify-registry.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.archify-decision",
|
||||
"url": "./schemas/archify-decision.schema.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Archify architecture decision",
|
||||
"description": "One architecture decision of a model. Diagrams point at it with `decisions: [<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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
|
||||
import { diagramName, invalidNameReason } from '../language/templates';
|
||||
import { MAIN_FILE } from '../model/format';
|
||||
import { DECISION_EXT, slugify } from '../model/decisions';
|
||||
import { MAIN_FILE, REGISTRY_EXT } from '../model/format';
|
||||
import { REGISTRY_VIEW_TYPE } from '../views/registryView';
|
||||
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 +33,57 @@ 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 registry file in the clicked folder and opens it.
|
||||
vscode.commands.registerCommand('archify.newRegistry', async (arg?: unknown) => {
|
||||
const folder = await parentFolder(arg);
|
||||
if (!folder) return;
|
||||
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
|
||||
const name = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Registry',
|
||||
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}${root ? '' : ' (not inside a model: add a main.archify above it so diagrams can refer to its elements)'}`,
|
||||
value: 'elements',
|
||||
validateInput: async (value) =>
|
||||
invalidNameReason(value) ?? ((await exists(vscode.Uri.joinPath(folder, `${diagramName(value).replace(/\.archify-registry$/i, '')}${REGISTRY_EXT}`))) ? `${diagramName(value)}${REGISTRY_EXT} already exists.` : undefined),
|
||||
});
|
||||
if (name === undefined) return;
|
||||
const target = vscode.Uri.joinPath(folder, `${diagramName(name).replace(/\.archify-registry$/i, '')}${REGISTRY_EXT}`);
|
||||
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'registry.archify-registry'));
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(template, 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, REGISTRY_VIEW_TYPE);
|
||||
}),
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+79
-51
@@ -55,42 +55,6 @@ export interface Registry {
|
||||
problems: ModelProblem[];
|
||||
}
|
||||
|
||||
/** Archify card dot colour for a component type (the colours Archify gives the nodes). */
|
||||
const DOT_BY_TYPE: Record<string, string> = {
|
||||
frontend: 'cyan',
|
||||
backend: 'emerald',
|
||||
database: 'violet',
|
||||
cloud: 'amber',
|
||||
security: 'rose',
|
||||
messagebus: 'orange',
|
||||
external: 'slate',
|
||||
};
|
||||
|
||||
export function dotForType(type?: string): string {
|
||||
return DOT_BY_TYPE[type ?? ''] ?? 'slate';
|
||||
}
|
||||
|
||||
export interface Card {
|
||||
dot: string;
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Archify info cards for registry elements: one per element that has a
|
||||
* description, titled by its label. What a diagram gets below itself when
|
||||
* it draws shared elements and defines no cards of its own.
|
||||
*/
|
||||
export function cardsFromElements(elements: Iterable<RegistryElement>): Card[] {
|
||||
const cards: Card[] = [];
|
||||
for (const element of elements) {
|
||||
if (!element.description) continue;
|
||||
const items = [element.description.trim()];
|
||||
cards.push({ dot: dotForType(element.type), title: element.label ?? element.id, items });
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function isMainFile(fileName: string): boolean {
|
||||
return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE;
|
||||
}
|
||||
@@ -186,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;
|
||||
@@ -196,32 +173,86 @@ export interface ResolvedDiagram {
|
||||
links: Map<string, string>;
|
||||
/** Registry element ids used by this diagram. */
|
||||
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
|
||||
* (JSON pointers) still point at the right component in the source file.
|
||||
* A diagram without `cards` of its own gets cards from the descriptions of
|
||||
* the shared elements it draws, so the registry text shows under the diagram.
|
||||
*/
|
||||
export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram {
|
||||
const problems: ModelProblem[] = [];
|
||||
const refs = new Map<number, RegistryElement>();
|
||||
const links = new Map<string, string>();
|
||||
const used = new Set<string>();
|
||||
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used };
|
||||
const descriptions = new Map<string, string>();
|
||||
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 };
|
||||
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;
|
||||
@@ -248,6 +279,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
}
|
||||
used.add(ref);
|
||||
refs.set(index, element);
|
||||
if (element.description) descriptions.set(id, element.description.trim());
|
||||
for (const key of Object.keys(own)) {
|
||||
if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
|
||||
problems.push({
|
||||
@@ -267,14 +299,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
if (link) links.set(id, link);
|
||||
return merged;
|
||||
});
|
||||
if (diagram.cards === undefined && refs.size) {
|
||||
const seen = new Set<string>();
|
||||
const elements: RegistryElement[] = [];
|
||||
for (const element of refs.values()) if (!seen.has(element.id)) { seen.add(element.id); elements.push(element); }
|
||||
const cards = cardsFromElements(elements);
|
||||
if (cards.length) diagram.cards = cards;
|
||||
}
|
||||
return { diagram, problems, refs, links, used };
|
||||
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. */
|
||||
@@ -290,6 +315,8 @@ export interface DiagramSummary {
|
||||
kind?: string;
|
||||
title?: string;
|
||||
used: string[];
|
||||
/** Decisions the diagram points at. */
|
||||
decisions: DecisionRef[];
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
@@ -299,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
@@ -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),
|
||||
};
|
||||
}
|
||||
+39
-5
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -16,6 +18,18 @@ export interface PreparedDiagram {
|
||||
problems: ModelProblem[];
|
||||
/** Node id → diagram path relative to the model root. */
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,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 };
|
||||
// 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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
/** Default editor for .archify files: the rendered diagram. */
|
||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
|
||||
|
||||
interface PreviewState {
|
||||
uri: string;
|
||||
@@ -59,7 +59,15 @@ class DiagramPreview implements vscode.Disposable {
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openDiagram') {
|
||||
void this.openLinked(message.path);
|
||||
void this.openLinked(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openRegistry') {
|
||||
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);
|
||||
@@ -96,7 +104,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
}
|
||||
|
||||
/** Opens a diagram of the model, given its path relative to the model root. */
|
||||
private async openLinked(relativePath: string): Promise<void> {
|
||||
private async openLinked(relativePath: string, viewType: string): Promise<void> {
|
||||
if (!this.root) return;
|
||||
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
|
||||
try {
|
||||
@@ -105,7 +113,7 @@ class DiagramPreview implements vscode.Disposable {
|
||||
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
|
||||
return;
|
||||
}
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
|
||||
}
|
||||
|
||||
private showModelPage(model: ModelPanel): void {
|
||||
@@ -195,10 +203,12 @@ class DiagramPreview implements vscode.Disposable {
|
||||
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
|
||||
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]])}\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;
|
||||
|
||||
@@ -7,10 +7,9 @@ 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 } from './diagramView';
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
/** Default editor for .archify-registry files: the elements as cards, each with where it is used. */
|
||||
export const REGISTRY_VIEW_TYPE = 'archify.registry';
|
||||
export { REGISTRY_VIEW_TYPE };
|
||||
|
||||
class RegistryDocument implements vscode.CustomDocument {
|
||||
constructor(readonly uri: vscode.Uri) {}
|
||||
@@ -33,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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Custom editor view types, in one place so views can refer to each other without importing each other. */
|
||||
export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
/** Default editor for .archify files: the rendered diagram. */
|
||||
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';
|
||||
@@ -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. */
|
||||
|
||||
+269
-6
@@ -18,11 +18,18 @@ export interface PageOptions {
|
||||
links?: Record<string, string>;
|
||||
/** The model page: metadata above the diagram, the diagram list and registry below. */
|
||||
model?: ModelPanel;
|
||||
/** Node id → registry description, shown as tooltips in the Elements panel. */
|
||||
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';
|
||||
@@ -77,6 +84,9 @@ 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 () {
|
||||
var config = ${scriptJson(config)};
|
||||
@@ -189,6 +199,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
// Collapsible panels of the model page; open/closed is kept in the webview state.
|
||||
function panelStates() { return (vscode.getState() || {}).modelPanels || {}; }
|
||||
function panel(key, className, title, count, defaultOpen) {
|
||||
// count: a number or a short text such as "3 files".
|
||||
var details = el('details', 'archify-model-panel ' + className);
|
||||
var saved = panelStates()[key];
|
||||
details.open = saved === undefined ? defaultOpen : saved;
|
||||
@@ -230,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();
|
||||
@@ -288,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);
|
||||
@@ -321,6 +374,139 @@ 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]');
|
||||
Array.prototype.forEach.call(items, function (item) {
|
||||
var text = config.descriptions[item.getAttribute('data-outline-node')];
|
||||
if (text) item.title = text;
|
||||
});
|
||||
}
|
||||
|
||||
// Connectors: a rail panel next to Archify's Elements index, read from the
|
||||
// SVG's edges. Hover highlights the connector, click keeps it highlighted.
|
||||
function renderConnectors() {
|
||||
@@ -446,13 +632,38 @@ function bridgeScript(options: PageOptions): string {
|
||||
});
|
||||
holder.appendChild(list);
|
||||
});
|
||||
// Registry elements as Archify info cards: the same cards a diagram shows below itself.
|
||||
// Registry: one collapsible header per registry file, its elements as cards inside.
|
||||
if (model.registry.length) {
|
||||
var registryPanel = panel('registry', 'archify-model-registry', 'Registry', model.registry.length, false);
|
||||
var files = [];
|
||||
var byFile = {};
|
||||
model.registry.forEach(function (element) {
|
||||
if (!byFile[element.file]) { byFile[element.file] = []; files.push(element.file); }
|
||||
byFile[element.file].push(element);
|
||||
});
|
||||
files.sort();
|
||||
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), true);
|
||||
body.appendChild(registryPanel);
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
model.registry.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
registryPanel.appendChild(cards);
|
||||
files.forEach(function (file) {
|
||||
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
|
||||
var filePanel = panel('registry:' + file, 'archify-model-folder archify-model-registry-file', file.replace(/\.archify-registry$/, ''), elements.length + (elements.length === 1 ? ' element' : ' elements'), false);
|
||||
var open = el('button', 'archify-model-used-link archify-model-open-file', 'Open');
|
||||
open.type = 'button';
|
||||
open.title = 'Open ' + file;
|
||||
open.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'openRegistry', file: file, line: 0 }); });
|
||||
filePanel.firstChild.appendChild(open);
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
filePanel.appendChild(cards);
|
||||
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);
|
||||
}
|
||||
@@ -467,7 +678,10 @@ 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') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
@@ -502,6 +716,10 @@ const MODEL_STYLE = `
|
||||
.archify-model-folder > .archify-model-summary { padding: 0.55rem 0.75rem; font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
||||
.archify-model-folder > .archify-model-summary:hover { color: var(--m-accent); }
|
||||
.archify-model-folder > .archify-model-list { margin: 0 0.75rem; }
|
||||
.archify-model-registry-file > .archify-model-summary { text-transform: none; letter-spacing: 0; font-size: 0.8125rem; }
|
||||
.archify-model-registry-file > .archify-model-cards { margin: 0.25rem 0.75rem 0; }
|
||||
.archify-model-open-file { margin-left: 0.5rem; text-transform: none; letter-spacing: 0; font-family: inherit; }
|
||||
.archify-model-open-file:hover { color: var(--m-accent); }
|
||||
.archify-model-diagrams > .archify-model-list { margin-top: 0.25rem; }
|
||||
.archify-model-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
|
||||
.archify-model-open { display: flex; flex-direction: column; gap: 0.2rem; width: 100%; padding: 0.7rem 0.85rem; text-align: left; cursor: pointer; font: inherit; color: var(--m-text); background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.6rem; transition: border-color 0.15s, transform 0.15s; }
|
||||
@@ -524,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; }
|
||||
@@ -547,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); }
|
||||
@@ -640,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'>,
|
||||
|
||||
@@ -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] }
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: postgres-as-source-of-truth
|
||||
title: PostgreSQL as the single source of truth
|
||||
status: accepted
|
||||
date: 2026-09-02
|
||||
elements: [db, api, worker]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Orders, customers and the catalogue were spread over the
|
||||
shop database and two spreadsheets kept by support.
|
||||
Refunds were reconciled by hand every Monday.
|
||||
decision: >
|
||||
One PostgreSQL database owns orders, customers and the
|
||||
catalogue. The API is the only writer. Everything else
|
||||
(cache, search, reports) is derived and may be rebuilt.
|
||||
consequences: >
|
||||
Support loses the spreadsheets and gets the admin console.
|
||||
Any new store must be declared derived or get its own
|
||||
decision. Backups and migrations are the API team's job.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: queue-between-api-and-worker
|
||||
title: A queue between the API and the worker
|
||||
status: accepted
|
||||
date: 2026-09-16
|
||||
elements: [api, queue, worker]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Order confirmation emails and invoices were sent inside
|
||||
the checkout request. A slow mail provider made checkout
|
||||
slow; a failing one made checkout fail.
|
||||
decision: >
|
||||
The API enqueues a job per side effect on SQS. One worker
|
||||
service consumes the queue and does the slow work.
|
||||
Checkout returns as soon as the order is stored.
|
||||
consequences: >
|
||||
Emails arrive seconds later, not instantly. Jobs must be
|
||||
idempotent because SQS delivers at least once. The worker
|
||||
is the second place that writes to the database.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: opensearch-for-product-search
|
||||
title: OpenSearch for product search
|
||||
status: proposed
|
||||
date: 2026-10-19
|
||||
elements: [api, worker, db]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Catalogue search runs as SQL LIKE queries on the
|
||||
database. Typos find nothing and the catalogue page is
|
||||
the slowest page in the shop.
|
||||
decision: >
|
||||
Add a dedicated search service on OpenSearch. The API
|
||||
queries it; the worker rebuilds the index from the
|
||||
database after every catalogue change.
|
||||
consequences: >
|
||||
The index is derived data under decision 0001 and may be
|
||||
rebuilt at any time. One more service to run. Search
|
||||
results can lag the database by the rebuild interval.
|
||||
@@ -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] }
|
||||
+18
@@ -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 }
|
||||
+17
@@ -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.
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { parse } from 'yaml';
|
||||
import { effectiveStatus, mergeDecisions, parseDecision, slugify, sortDecisions, validateDecisions } from '../src/model/decisions';
|
||||
import { mergeRegistries, parseRegistry, resolveRefs, summarizeDiagram } from '../src/model/format';
|
||||
|
||||
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
|
||||
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
|
||||
|
||||
function registryOfModel() {
|
||||
const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
|
||||
return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) })));
|
||||
}
|
||||
function decisionsOfModel() {
|
||||
return mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
|
||||
}
|
||||
|
||||
test('decision files parse with lines for every linked id', () => {
|
||||
const { decision, problems } = parseDecision(read(DECISION_FILES[1]), 'd2');
|
||||
assert.deepEqual(problems, []);
|
||||
assert.ok(decision);
|
||||
assert.equal(decision.id, 'queue-between-api-and-worker');
|
||||
assert.equal(decision.title, 'A queue between the API and the worker');
|
||||
assert.equal(decision.status, 'accepted');
|
||||
assert.deepEqual(decision.elements, ['api', 'queue', 'worker']);
|
||||
assert.deepEqual(decision.diagrams, ['0200-containers/online-shop.archify']);
|
||||
assert.equal(decision.lines.elements.length, 3);
|
||||
assert.ok(decision.context && decision.decision && decision.consequences);
|
||||
});
|
||||
|
||||
test('decision files: broken YAML, missing block, unknown status', () => {
|
||||
assert.equal(parseDecision('decision: {\n', 'x').problems[0].severity, 'error');
|
||||
assert.match(parseDecision('context: only text\n', 'x').problems[0].message, /"decision" block/);
|
||||
const bad = parseDecision('decision:\n id: a\n title: A\n status: maybe\n elements: api\n', 'x');
|
||||
assert.deepEqual(bad.problems.map((p) => p.message.split(';')[0]), ['Unknown status "maybe"', '"elements" must be a list.']);
|
||||
assert.equal(bad.problems[0].line, 3);
|
||||
});
|
||||
|
||||
test('decisions merge, validate links, and the superseded rule applies', () => {
|
||||
const set = decisionsOfModel();
|
||||
assert.deepEqual(set.problems, []);
|
||||
assert.equal(set.decisions.size, 3);
|
||||
const registry = registryOfModel();
|
||||
const diagrams = new Set(['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']);
|
||||
assert.deepEqual(validateDecisions(set, registry, diagrams), []);
|
||||
// Duplicate id, unknown element, missing diagram, dangling supersedes.
|
||||
const extra = parseDecision('decision:\n id: queue-between-api-and-worker\n title: Dup\n elements: [ghost]\n diagrams: [0900/x.archify]\n supersedes: [nope]\n', 'dup');
|
||||
const merged = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'dup', ...extra }]);
|
||||
assert.match(merged.problems[0].message, /defined twice/);
|
||||
const newer = parseDecision('decision:\n id: one-database\n title: Replace the first\n status: accepted\n supersedes: [postgres-as-source-of-truth]\n', 'd4');
|
||||
const withNewer = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'd4', ...newer }]);
|
||||
const problems = validateDecisions(mergeDecisions([{ file: 'dup', ...extra }]), registry, diagrams);
|
||||
assert.deepEqual(problems.map((p) => p.message.split(':')[0]), ['Unknown element "ghost"', 'Diagram "0900/x.archify" does not exist in this model.', 'Decision "nope" (in supersedes) does not exist in this model.']);
|
||||
assert.deepEqual(effectiveStatus(withNewer.decisions.get('postgres-as-source-of-truth')!, withNewer.decisions), { status: 'superseded', supersededBy: 'one-database' });
|
||||
assert.deepEqual(effectiveStatus(withNewer.decisions.get('queue-between-api-and-worker')!, withNewer.decisions), { status: 'accepted' });
|
||||
const order = sortDecisions([...withNewer.decisions.values()], withNewer.decisions).map((d) => d.id);
|
||||
assert.equal(order[order.length - 1], 'postgres-as-source-of-truth', 'superseded sorts last');
|
||||
assert.equal(slugify('OpenSearch for product search!'), 'opensearch-for-product-search');
|
||||
// Numeric ids are a YAML trap and are refused on both sides.
|
||||
assert.match(parseDecision('decision:\n id: 0001\n title: N\n', 'n').problems[0].message, /is a number to YAML/);
|
||||
assert.match(parseDecision('decision:\n id: ok\n title: N\n supersedes: [0001]\n', 'n').problems[0].message, /is a number to YAML/);
|
||||
});
|
||||
|
||||
test('diagrams: decisions keys are stripped, collected and checked', () => {
|
||||
const registry = registryOfModel();
|
||||
const known = new Set(decisionsOfModel().decisions.keys());
|
||||
const doc = parse(read('0200-containers/online-shop.archify'));
|
||||
const r = resolveRefs(doc, { registry, decisions: known, fileName: 'c.archify' });
|
||||
assert.deepEqual(r.problems, []);
|
||||
assert.ok(!('decisions' in r.diagram));
|
||||
const components = r.diagram.components as Record<string, unknown>[];
|
||||
const connections = r.diagram.connections as Record<string, unknown>[];
|
||||
assert.ok(components.every((c) => !('decisions' in c)) && connections.every((c) => !('decisions' in c)));
|
||||
assert.deepEqual(r.decisionRefs.map((d) => [d.id, d.attachedTo.kind, d.pointer]), [
|
||||
['postgres-as-source-of-truth', 'diagram', '/decisions/0'],
|
||||
['queue-between-api-and-worker', 'node', '/components/5/decisions/0'],
|
||||
['queue-between-api-and-worker', 'edge', '/connections/4/decisions/0'],
|
||||
]);
|
||||
const numeric = resolveRefs({ decisions: [1], components: [] }, { registry, decisions: known, fileName: 'x.archify' });
|
||||
assert.match(numeric.problems[0].message, /is a number to YAML/);
|
||||
const unknown = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0], decisions: ['nope'] }] }, { registry, decisions: known, fileName: 'x.archify' });
|
||||
assert.equal(unknown.problems[0].pointer, '/components/0/decisions/0');
|
||||
assert.match(unknown.problems[0].message, /Unknown decision "nope"/);
|
||||
const outside = resolveRefs({ decisions: ['postgres-as-source-of-truth'], components: [] }, { fileName: '/w/loose.archify' });
|
||||
assert.match(outside.problems[0].message, /not inside a model/);
|
||||
// Other kinds: nodes and edges carry the key too.
|
||||
const workflow = resolveRefs({ nodes: [{ id: 'n', type: 'process', label: 'N', decisions: ['postgres-as-source-of-truth'] }], edges: [{ from: 'n', to: 'n', decisions: ['queue-between-api-and-worker'] }] }, { registry, decisions: known, fileName: 'w.archify' });
|
||||
assert.deepEqual(workflow.decisionRefs.map((d) => d.attachedTo.kind), ['node', 'edge']);
|
||||
assert.ok(!('decisions' in (workflow.diagram.nodes as Record<string, unknown>[])[0]));
|
||||
const summary = summarizeDiagram(read('0200-containers/online-shop.archify'), '0200-containers/online-shop.archify');
|
||||
assert.deepEqual(summary.decisions.map((d) => d.id), ['postgres-as-source-of-truth', 'queue-between-api-and-worker', 'queue-between-api-and-worker']);
|
||||
});
|
||||
+11
-1
@@ -7,6 +7,7 @@ import { parse } from 'yaml';
|
||||
import { validate } from '../src/archify/cli';
|
||||
import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples';
|
||||
import { MAIN_FILE, REGISTRY_EXT, hasDiagramBody, mergeRegistries, parseRegistry, readModelMeta, resolveRefs } from '../src/model/format';
|
||||
import { DECISION_EXT, mergeDecisions, parseDecision, validateDecisions } from '../src/model/decisions';
|
||||
import { readDiagramSource } from '../src/sources/detect';
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
@@ -58,6 +59,15 @@ for (const example of MODEL_EXAMPLES) {
|
||||
if (element.diagram) assert.ok(files.includes(element.diagram), `${element.id} links to missing ${element.diagram}`);
|
||||
}
|
||||
|
||||
// Decisions: at least one, all links resolve.
|
||||
const decisionFiles = files.filter((f) => f.endsWith(DECISION_EXT));
|
||||
assert.ok(decisionFiles.length >= 1, 'an example carries at least one decision');
|
||||
const decisions = mergeDecisions(decisionFiles.map((file) => ({ file, ...parseDecision(read(file), file) })));
|
||||
assert.deepEqual(decisions.problems, []);
|
||||
const diagramPaths = new Set(files.filter((f) => f.endsWith('.archify') && f !== MAIN_FILE));
|
||||
assert.deepEqual(validateDecisions(decisions, registry, diagramPaths), []);
|
||||
for (const d of decisions.decisions.values()) assert.ok(d.title && d.status && d.context && d.decision && d.consequences, `${d.id} is complete`);
|
||||
|
||||
// Diagrams: the three C4 levels, each resolving without problems and valid for Archify.
|
||||
const diagrams = files.filter((f) => f.endsWith('.archify')).sort();
|
||||
for (const level of ['0100-context', '0200-containers', '0300-components']) {
|
||||
@@ -67,7 +77,7 @@ for (const example of MODEL_EXAMPLES) {
|
||||
for (const file of diagrams) {
|
||||
const source = readDiagramSource(read(file), file);
|
||||
assert.ok(source, `${file} is not a diagram`);
|
||||
const resolved = resolveRefs(source.diagram, { registry, fileName: file });
|
||||
const resolved = resolveRefs(source.diagram, { registry, decisions: new Set(decisions.decisions.keys()), fileName: file });
|
||||
assert.deepEqual(resolved.problems, [], file);
|
||||
for (const link of resolved.links.values()) assert.ok(files.includes(link), `${file} links to missing ${link}`);
|
||||
for (const id of resolved.used) used.add(id);
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ diagram_type: architecture
|
||||
meta:
|
||||
title: Containers
|
||||
output: containers.html
|
||||
decisions: [postgres-as-source-of-truth]
|
||||
components:
|
||||
- ref: customer
|
||||
pos: [40, 160]
|
||||
@@ -22,6 +23,7 @@ components:
|
||||
- ref: queue
|
||||
pos: [460, 360]
|
||||
size: [130, 60]
|
||||
decisions: [queue-between-api-and-worker]
|
||||
- ref: worker
|
||||
pos: [670, 460]
|
||||
size: [130, 60]
|
||||
@@ -34,6 +36,6 @@ connections:
|
||||
- { from: web, to: api, label: JSON }
|
||||
- { from: api, to: cache, label: read }
|
||||
- { from: api, to: db, label: SQL }
|
||||
- { from: api, to: queue, label: enqueue, labelAt: [560, 290] }
|
||||
- { from: api, to: queue, label: enqueue, labelAt: [560, 290], decisions: [queue-between-api-and-worker] }
|
||||
- { from: queue, to: worker, label: poll }
|
||||
- { from: worker, to: db, label: SQL, labelAt: [735, 420] }
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: postgres-as-source-of-truth
|
||||
title: PostgreSQL as the single source of truth
|
||||
status: accepted
|
||||
date: 2026-09-02
|
||||
elements: [db, api, worker]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Orders, customers and the catalogue were spread over the
|
||||
shop database and two spreadsheets kept by support.
|
||||
Refunds were reconciled by hand every Monday.
|
||||
decision: >
|
||||
One PostgreSQL database owns orders, customers and the
|
||||
catalogue. The API is the only writer. Everything else
|
||||
(cache, search, reports) is derived and may be rebuilt.
|
||||
consequences: >
|
||||
Support loses the spreadsheets and gets the admin console.
|
||||
Any new store must be declared derived or get its own
|
||||
decision. Backups and migrations are the API team's job.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: queue-between-api-and-worker
|
||||
title: A queue between the API and the worker
|
||||
status: accepted
|
||||
date: 2026-09-16
|
||||
elements: [api, queue, worker]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Order confirmation emails and invoices were sent inside
|
||||
the checkout request. A slow mail provider made checkout
|
||||
slow; a failing one made checkout fail.
|
||||
decision: >
|
||||
The API enqueues a job per side effect on SQS. One worker
|
||||
service consumes the queue and does the slow work.
|
||||
Checkout returns as soon as the order is stored.
|
||||
consequences: >
|
||||
Emails arrive seconds later, not instantly. Jobs must be
|
||||
idempotent because SQS delivers at least once. The worker
|
||||
is the second place that writes to the database.
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
schema_version: 1
|
||||
decision:
|
||||
id: opensearch-for-product-search
|
||||
title: OpenSearch for product search
|
||||
status: proposed
|
||||
date: 2026-10-19
|
||||
elements: [api, worker, db]
|
||||
diagrams: [0200-containers/online-shop.archify]
|
||||
context: >
|
||||
Catalogue search runs as SQL LIKE queries on the
|
||||
database. Typos find nothing and the catalogue page is
|
||||
the slowest page in the shop.
|
||||
decision: >
|
||||
Add a dedicated search service on OpenSearch. The API
|
||||
queries it; the worker rebuilds the index from the
|
||||
database after every catalogue change.
|
||||
consequences: >
|
||||
The index is derived data under decision 0001 and may be
|
||||
rebuilt at any time. One more service to run. Search
|
||||
results can lag the database by the rebuild interval.
|
||||
@@ -329,6 +329,50 @@ 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 Registry… creates a registry file and opens the registry view; creators are ordered', async () => {
|
||||
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes as { commands: { command: string }[]; menus: Record<string, { command: string; group: string }[]> };
|
||||
const creators = commands.menus['explorer/context'].filter((m) => /^archify\.new/.test(m.command));
|
||||
assert.deepEqual(creators.map((m) => m.command), ['archify.newModel', 'archify.newRegistry', 'archify.newDiagram', 'archify.newDecision']);
|
||||
assert.deepEqual(creators.map((m) => m.group), ['zzz_archify@1', 'zzz_archify@2', 'zzz_archify@3', 'zzz_archify@4']);
|
||||
const folder = workspaceFile('model/registry');
|
||||
const window = vscode.window as unknown as Record<string, unknown>;
|
||||
const originalInput = window.showInputBox;
|
||||
window.showInputBox = async () => 'partners';
|
||||
try {
|
||||
await vscode.commands.executeCommand('archify.newRegistry', folder);
|
||||
} finally {
|
||||
window.showInputBox = originalInput;
|
||||
}
|
||||
const file = vscode.Uri.joinPath(folder, 'partners.archify-registry');
|
||||
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(file));
|
||||
assert.match(text, /^elements:/m);
|
||||
const tab = await waitFor('registry view tab', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === file.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
await vscode.workspace.fs.delete(file);
|
||||
});
|
||||
|
||||
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 +429,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', () =>
|
||||
|
||||
+5
-17
@@ -3,8 +3,6 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
cardsFromElements,
|
||||
dotForType,
|
||||
groupOf,
|
||||
hasDiagramBody,
|
||||
isMainFile,
|
||||
@@ -83,22 +81,12 @@ test('resolve: refs become full components, links and usage are collected', () =
|
||||
assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']);
|
||||
assert.equal(r.refs.get(2)?.id, 'api');
|
||||
assert.ok(!('model' in r.diagram));
|
||||
// Cards from the registry descriptions, in drawing order, one per element.
|
||||
const cards = r.diagram.cards as { dot: string; title: string; items: string[] }[];
|
||||
assert.equal(cards.length, 7);
|
||||
assert.deepEqual(cards[0], { dot: 'slate', title: 'Customer', items: ['Buys products; signs in with email or a social account.'] });
|
||||
assert.equal(cards.find((c) => c.title === 'API')?.dot, 'emerald');
|
||||
});
|
||||
|
||||
test('resolve: own cards win, no cards without refs or descriptions', () => {
|
||||
const registry = registryOfModel();
|
||||
// Descriptions travel with the node ids, for the Elements panel; nothing is added to the diagram itself.
|
||||
assert.ok(!('cards' in r.diagram));
|
||||
assert.equal(r.descriptions.get('customer'), 'Buys products; signs in with email or a social account.');
|
||||
assert.equal(r.descriptions.size, 7);
|
||||
const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' });
|
||||
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }]);
|
||||
const inline = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0] }] }, { registry, fileName: 'x.archify' });
|
||||
assert.ok(!('cards' in inline.diagram));
|
||||
assert.equal(cardsFromElements([{ id: 'x', type: 'cloud', label: 'X', file: 'r', line: 0 }]).length, 0);
|
||||
assert.equal(dotForType('messagebus'), 'orange');
|
||||
assert.equal(dotForType(undefined), 'slate');
|
||||
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }], 'hand-written cards pass through');
|
||||
});
|
||||
|
||||
test('resolve: overrides, second id for the same element, inline elements and links', () => {
|
||||
|
||||
Reference in new issue
Block a user