Architecture decisions: files, diagram references, views and panels
- *.archify-decision files: id (unique, words), title, status, date, elements, diagrams, supersedes, and prose; parsed, merged and link-checked in the model index; diagnostics on the file - diagrams point at decisions with decisions: [...] on the diagram, nodes and edges; stripped before Archify, unknown ids reported, completion, hover and go-to-definition for the ids and for elements/diagrams/ supersedes in decision files - Decisions panel on the main.archify page; Decisions rail panel under a diagram (hover lights up the node or connection); decisions on element cards and in the element dialog; decision view for the file, with a lost-model note outside a model - Archify | New Decision… command and template; editor schemas; the three New Model examples carry decisions; docs, changelog, 0.8.0
This commit is contained in:
+6
-2
@@ -1,8 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.2 — 2026-10-19
|
||||
## 0.8.0 — 2026-10-19
|
||||
|
||||
- Model page: the panel that lists the registry files is titled **Registries**.
|
||||
- **Architecture decisions.** A model can hold decisions: one `*.archify-decision` file each, anywhere under the model root, with an id (unique in the model), title, status, date, the registry `elements` and `diagrams` it is about, `supersedes`, and prose for context, decision and consequences.
|
||||
- **Diagrams point at decisions** with `decisions: [<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
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
|
||||
- **Schema help**: completion and hover from Archify's own schemas, through the YAML extension for `.archify` files.
|
||||
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
|
||||
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||
- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
||||
- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
|
||||
|
||||
## 3D view
|
||||
|
||||
@@ -111,6 +111,8 @@ online-shop/
|
||||
people.archify-registry shared elements: one name for one thing
|
||||
systems.archify-registry
|
||||
containers.archify-registry
|
||||
decisions/
|
||||
0002-queue-between-api-and-worker.archify-decision
|
||||
0100-context/online-shop.archify
|
||||
0200-containers/online-shop.archify
|
||||
0300-components/api.archify
|
||||
@@ -120,6 +122,7 @@ online-shop/
|
||||
- **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems.
|
||||
- **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it.
|
||||
- **Descriptions.** Under a model diagram, hovering a row of the **Elements** panel shows the registry description of that element.
|
||||
- **Decisions.** One `*.archify-decision` file per architecture decision: id, title, status, date, the registry elements and diagrams it is about, and prose for context, decision and consequences. Diagrams point at decisions with `decisions: [<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`.
|
||||
|
||||
@@ -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.2",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "archify-vscode-ext",
|
||||
"version": "0.7.2",
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"three": "^0.186.1",
|
||||
|
||||
+46
-14
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "archify-vscode-ext",
|
||||
"displayName": "Archify Diagram Viewer",
|
||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, \u2026) and opens rendered Archify HTML.",
|
||||
"version": "0.7.2",
|
||||
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
|
||||
"version": "0.8.0",
|
||||
"publisher": "root-at-skic",
|
||||
"license": "MIT",
|
||||
"sponsor": {
|
||||
@@ -33,7 +33,9 @@
|
||||
"registry",
|
||||
"sequence",
|
||||
"workflow",
|
||||
"yaml"
|
||||
"yaml",
|
||||
"adr",
|
||||
"decisions"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": [
|
||||
@@ -42,20 +44,26 @@
|
||||
"onLanguage:html",
|
||||
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
|
||||
"onWebviewPanel:archify.preview",
|
||||
"workspaceContains:**/*.archify-registry"
|
||||
"workspaceContains:**/*.archify-registry",
|
||||
"workspaceContains:**/*.archify-decision"
|
||||
],
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "archify.newDiagram",
|
||||
"title": "Archify | New Diagram\u2026",
|
||||
"title": "Archify | New Diagram…",
|
||||
"icon": "$(new-file)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"title": "Archify | New Model\u2026",
|
||||
"title": "Archify | New Model…",
|
||||
"icon": "$(new-folder)"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"title": "Archify | New Decision…",
|
||||
"icon": "$(law)"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"title": "Archify | Open Preview",
|
||||
@@ -73,7 +81,7 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.renderToHtml",
|
||||
"title": "Archify | Render to HTML File\u2026",
|
||||
"title": "Archify | Render to HTML File…",
|
||||
"icon": "$(file-code)"
|
||||
},
|
||||
{
|
||||
@@ -125,11 +133,11 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'"
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'"
|
||||
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -150,7 +158,7 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.showSource",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry'",
|
||||
"when": "archifyPreviewFocus || activeCustomEditorId == 'archify.htmlViewer' || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
@@ -165,7 +173,7 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry') && !notebookEditorFocused",
|
||||
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused",
|
||||
"group": "navigation@60"
|
||||
}
|
||||
],
|
||||
@@ -180,9 +188,14 @@
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"when": "explorerResourceIsFolder",
|
||||
"group": "zzz_archify@2"
|
||||
},
|
||||
{
|
||||
"command": "archify.showPreview",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
|
||||
"group": "zzz_archify@3"
|
||||
},
|
||||
{
|
||||
@@ -197,7 +210,7 @@
|
||||
},
|
||||
{
|
||||
"command": "archify.openModel",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry)?$/",
|
||||
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
|
||||
"group": "zzz_archify@5"
|
||||
}
|
||||
],
|
||||
@@ -216,6 +229,10 @@
|
||||
{
|
||||
"command": "archify.newModel",
|
||||
"group": "file"
|
||||
},
|
||||
{
|
||||
"command": "archify.newDecision",
|
||||
"group": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -254,6 +271,16 @@
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.decision",
|
||||
"displayName": "Archify Decision",
|
||||
"selector": [
|
||||
{
|
||||
"filenamePattern": "*.archify-decision"
|
||||
}
|
||||
],
|
||||
"priority": "default"
|
||||
},
|
||||
{
|
||||
"viewType": "archify.htmlViewer",
|
||||
"displayName": "Archify HTML Viewer",
|
||||
@@ -381,7 +408,8 @@
|
||||
"id": "yaml",
|
||||
"extensions": [
|
||||
".archify",
|
||||
".archify-registry"
|
||||
".archify-registry",
|
||||
".archify-decision"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -393,6 +421,10 @@
|
||||
{
|
||||
"fileMatch": "*.archify-registry",
|
||||
"url": "./schemas/archify-registry.schema.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": "*.archify-decision",
|
||||
"url": "./schemas/archify-decision.schema.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,9 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
|
||||
import { diagramName, invalidNameReason } from '../language/templates';
|
||||
import { DECISION_EXT, slugify } from '../model/decisions';
|
||||
import { MAIN_FILE } from '../model/format';
|
||||
import { DECISION_VIEW_TYPE } from '../views/decisionView';
|
||||
import { readText } from '../util/files';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
import { CommandContext, exists, targetUri } from './context';
|
||||
@@ -30,6 +32,38 @@ export function modelCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
|
||||
}),
|
||||
|
||||
// Creates a decision file in the clicked folder of a model and opens it.
|
||||
vscode.commands.registerCommand('archify.newDecision', async (arg?: unknown) => {
|
||||
const folder = await parentFolder(arg);
|
||||
if (!folder) return;
|
||||
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
|
||||
if (!root) {
|
||||
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} is not inside a model (no ${MAIN_FILE} in it or a folder above it). Decisions belong to a model.`);
|
||||
return;
|
||||
}
|
||||
const index = await ctx.models.index(root);
|
||||
const title = await vscode.window.showInputBox({
|
||||
title: 'Archify | New Decision',
|
||||
prompt: `Title of the decision (file goes into ${vscode.workspace.asRelativePath(folder, true)})`,
|
||||
placeHolder: 'OpenSearch for product search',
|
||||
validateInput: async (value) => {
|
||||
if (!value.trim()) return 'Enter a title.';
|
||||
const id = slugify(value);
|
||||
if (index.decisions.decisions.has(id)) return `A decision with id "${id}" already exists in this model.`;
|
||||
if (await exists(vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`))) return `${id}${DECISION_EXT} already exists.`;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (title === undefined) return;
|
||||
const id = slugify(title);
|
||||
const target = vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`);
|
||||
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'decision.archify-decision'));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const text = template.replace('__ID__', id).replace('__TITLE__', JSON.stringify(title.trim())).replace('__DATE__', today);
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
|
||||
await vscode.commands.executeCommand('vscode.openWith', target, DECISION_VIEW_TYPE);
|
||||
}),
|
||||
|
||||
// Creates a new model folder from one of the shipped examples and opens its model page.
|
||||
vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => {
|
||||
const picked = await vscode.window.showQuickPick(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+75
-6
@@ -150,6 +150,19 @@ export function mergeRegistries(parts: { elements: RegistryElement[]; problems:
|
||||
return { elements, files: parts.map((p) => p.file), problems };
|
||||
}
|
||||
|
||||
/** Where a diagram attaches a decision: the diagram itself, a node or an edge. */
|
||||
export type DecisionAttachment = { kind: 'diagram' } | { kind: 'node'; id: string } | { kind: 'edge'; from: string; to: string; label?: string };
|
||||
|
||||
export interface DecisionRef {
|
||||
id: string;
|
||||
pointer: string;
|
||||
attachedTo: DecisionAttachment;
|
||||
}
|
||||
|
||||
/** Collections whose items may carry `decisions` (and `diagram` links), per diagram kind. */
|
||||
const NODE_COLLECTIONS = ['components', 'nodes', 'participants', 'states'] as const;
|
||||
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'] as const;
|
||||
|
||||
export interface ResolvedDiagram {
|
||||
/** The diagram as Archify expects it: refs expanded, model block and links removed. */
|
||||
diagram: Obj;
|
||||
@@ -162,14 +175,66 @@ export interface ResolvedDiagram {
|
||||
used: Set<string>;
|
||||
/** Node id → registry description, for the Elements panel. */
|
||||
descriptions: Map<string, string>;
|
||||
/** Decisions the diagram points at, with where they are attached. */
|
||||
decisionRefs: DecisionRef[];
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** Undefined when the diagram is not inside a model. */
|
||||
registry?: Registry;
|
||||
/** Known decision ids of the model; undefined outside a model or when unknown. */
|
||||
decisions?: Set<string>;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
/** Strips `decisions` keys from a diagram and collects what they point at. Mutates `diagram`. */
|
||||
function takeDecisionRefs(diagram: Obj, options: ResolveOptions, problems: ModelProblem[]): DecisionRef[] {
|
||||
const refs: DecisionRef[] = [];
|
||||
const take = (owner: Obj, pointer: string, attachedTo: DecisionAttachment) => {
|
||||
if (!('decisions' in owner)) return;
|
||||
const raw = owner.decisions;
|
||||
delete owner.decisions;
|
||||
const ids = Array.isArray(raw) ? raw : [raw];
|
||||
ids.forEach((value, i) => {
|
||||
const id = value === undefined || value === null ? undefined : String(value);
|
||||
if (!id) return;
|
||||
const at = `${pointer}/decisions/${i}`;
|
||||
if (typeof value === 'number') {
|
||||
problems.push({ message: `Decision id ${value} is a number to YAML (0001 becomes 1). Quote it ("${value}") or use words.`, severity: 'error', pointer: at });
|
||||
return;
|
||||
}
|
||||
if (!options.registry) {
|
||||
problems.push({ message: `Decision "${id}" cannot be resolved: ${options.fileName.split(/[\\/]/).pop()} is not inside a model (no ${MAIN_FILE} above it).`, severity: 'error', pointer: at });
|
||||
} else if (options.decisions && !options.decisions.has(id)) {
|
||||
problems.push({ message: `Unknown decision "${id}": no .archify-decision file of this model defines it.`, severity: 'error', pointer: at });
|
||||
}
|
||||
refs.push({ id, pointer: at, attachedTo });
|
||||
});
|
||||
};
|
||||
take(diagram, '', { kind: 'diagram' });
|
||||
for (const key of NODE_COLLECTIONS) {
|
||||
const items = diagram[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
diagram[key] = items.map((item, index) => {
|
||||
if (!isObj(item)) return item;
|
||||
const copy = { ...item };
|
||||
take(copy, `/${key}/${index}`, { kind: 'node', id: str(copy.id) ?? str(copy.ref) ?? String(index) });
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
for (const key of EDGE_COLLECTIONS) {
|
||||
const items = diagram[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
diagram[key] = items.map((item, index) => {
|
||||
if (!isObj(item)) return item;
|
||||
const copy = { ...item };
|
||||
take(copy, `/${key}/${index}`, { kind: 'edge', from: str(copy.from) ?? '', to: str(copy.to) ?? '', label: str(copy.label) });
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands `ref` components from the registry and strips what Archify does
|
||||
* not know. Component order and indexes are kept, so Archify's diagnostics
|
||||
@@ -181,12 +246,13 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
const links = new Map<string, string>();
|
||||
const used = new Set<string>();
|
||||
const descriptions = new Map<string, string>();
|
||||
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions };
|
||||
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions, decisionRefs: [] };
|
||||
const diagram: Obj = { ...doc };
|
||||
delete diagram.model;
|
||||
const decisionRefs = takeDecisionRefs(diagram, options, problems);
|
||||
const { registry } = options;
|
||||
const components = Array.isArray(doc.components) ? doc.components : undefined;
|
||||
if (!components) return { diagram, problems, refs, links, used, descriptions };
|
||||
const components = Array.isArray(diagram.components) ? diagram.components : undefined;
|
||||
if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||
|
||||
diagram.components = components.map((raw, index) => {
|
||||
if (!isObj(raw)) return raw;
|
||||
@@ -233,7 +299,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
|
||||
if (link) links.set(id, link);
|
||||
return merged;
|
||||
});
|
||||
return { diagram, problems, refs, links, used, descriptions };
|
||||
return { diagram, problems, refs, links, used, descriptions, decisionRefs };
|
||||
}
|
||||
|
||||
/** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */
|
||||
@@ -249,6 +315,8 @@ export interface DiagramSummary {
|
||||
kind?: string;
|
||||
title?: string;
|
||||
used: string[];
|
||||
/** Decisions the diagram points at. */
|
||||
decisions: DecisionRef[];
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
@@ -258,13 +326,14 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
|
||||
try {
|
||||
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||
} catch (error) {
|
||||
return { relativePath, group: groupOf(relativePath), used: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||
}
|
||||
const obj = isObj(doc) ? doc : {};
|
||||
const meta = isObj(obj.meta) ? obj.meta : {};
|
||||
const components = Array.isArray(obj.components) ? obj.components : [];
|
||||
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
|
||||
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used };
|
||||
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
|
||||
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions };
|
||||
}
|
||||
|
||||
/** Groups in display order: the root group first, then folders in path order. */
|
||||
|
||||
+89
-5
@@ -1,6 +1,18 @@
|
||||
import { DiagramSummary, RegistryElement } from './format';
|
||||
import { Decision, effectiveStatus, sortDecisions } from './decisions';
|
||||
import { DecisionAttachment, DiagramSummary, RegistryElement } from './format';
|
||||
import { ModelIndex } from './service';
|
||||
|
||||
/** A decision as the lists show it. */
|
||||
export interface DecisionRow {
|
||||
id: string;
|
||||
title?: string;
|
||||
/** Status shown: superseded when another decision supersedes it. */
|
||||
status: string;
|
||||
supersededBy?: string;
|
||||
date?: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
/** A diagram an element is used in, as the pages show it. */
|
||||
export interface UsedIn {
|
||||
path: string;
|
||||
@@ -22,6 +34,8 @@ export interface ElementCard {
|
||||
file: string;
|
||||
line: number;
|
||||
usedIn: UsedIn[];
|
||||
/** Decisions about this element, from the decision files and from diagram attachments. */
|
||||
decisions: DecisionRow[];
|
||||
}
|
||||
|
||||
/** What the model page shows; serialised into the webview. */
|
||||
@@ -31,10 +45,27 @@ export interface ModelPanel {
|
||||
hasDiagram: boolean;
|
||||
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
|
||||
registry: ElementCard[];
|
||||
decisions: DecisionRow[];
|
||||
problems: string[];
|
||||
nested: string[];
|
||||
}
|
||||
|
||||
/** What the decision page (one .archify-decision file) shows. */
|
||||
export interface DecisionPanel {
|
||||
file: string;
|
||||
inModel: boolean;
|
||||
model?: { id?: string; name?: string };
|
||||
decision?: DecisionRow & { context?: string; decision?: string; consequences?: string; supersedes: DecisionRow[] };
|
||||
/** Decisions that supersede this one. */
|
||||
supersededBy: DecisionRow[];
|
||||
elements: ElementCard[];
|
||||
/** Diagrams the decision names. */
|
||||
diagrams: UsedIn[];
|
||||
/** Diagrams that point at the decision, with where. */
|
||||
referencedBy: { path: string; title?: string; kind?: string; attachedTo: DecisionAttachment }[];
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
/** What the registry page (one .archify-registry file) shows. */
|
||||
export interface RegistryPanel {
|
||||
/** The registry file, relative to the model root (or its base name outside a model). */
|
||||
@@ -52,7 +83,22 @@ function usedIn(id: string, usage: Map<string, string[]>, diagrams: DiagramSumma
|
||||
});
|
||||
}
|
||||
|
||||
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[]): ElementCard {
|
||||
export function decisionRow(d: Decision, all: Map<string, Decision>): DecisionRow {
|
||||
const { status, supersededBy } = effectiveStatus(d, all);
|
||||
return { id: d.id, title: d.title, status, supersededBy, date: d.date, file: d.file };
|
||||
}
|
||||
|
||||
/** Decisions about an element: named by the decision, or attached to its node in some diagram. */
|
||||
function decisionsOfElement(id: string, index: Pick<ModelIndex, 'decisions' | 'decisionUsage'> | undefined): DecisionRow[] {
|
||||
if (!index) return [];
|
||||
const all = index.decisions.decisions;
|
||||
const ids = new Set<string>();
|
||||
for (const d of all.values()) if (d.elements.includes(id)) ids.add(d.id);
|
||||
for (const [decisionId, uses] of index.decisionUsage) if (uses.some((u) => u.attachedTo.kind === 'node' && u.attachedTo.id === id) && all.has(decisionId)) ids.add(decisionId);
|
||||
return sortDecisions([...ids].map((x) => all.get(x)!), all).map((d) => decisionRow(d, all));
|
||||
}
|
||||
|
||||
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[], index?: Pick<ModelIndex, 'decisions' | 'decisionUsage'>): ElementCard {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
@@ -64,22 +110,25 @@ export function elementCard(e: RegistryElement, usage: Map<string, string[]>, di
|
||||
file: e.file,
|
||||
line: e.line,
|
||||
usedIn: usedIn(e.id, usage, diagrams),
|
||||
decisions: decisionsOfElement(e.id, index),
|
||||
};
|
||||
}
|
||||
|
||||
const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id);
|
||||
|
||||
export function panelFromIndex(index: ModelIndex): ModelPanel {
|
||||
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams));
|
||||
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams, index));
|
||||
const all = index.decisions.decisions;
|
||||
return {
|
||||
meta: index.meta,
|
||||
decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)),
|
||||
hasDiagram: index.mainHasDiagram,
|
||||
groups: index.groups.map((g) => ({
|
||||
group: g.group,
|
||||
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
|
||||
})),
|
||||
registry,
|
||||
problems: index.registry.problems.map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
|
||||
problems: [...index.registry.problems, ...index.decisions.problems].map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
|
||||
nested: index.nested,
|
||||
};
|
||||
}
|
||||
@@ -89,7 +138,7 @@ export function registryPanelFromIndex(index: ModelIndex, file: string): Registr
|
||||
const elements = [...index.registry.elements.values()]
|
||||
.filter((e) => e.file === file)
|
||||
.sort((a, b) => a.line - b.line)
|
||||
.map((e) => elementCard(e, index.usage, index.diagrams));
|
||||
.map((e) => elementCard(e, index.usage, index.diagrams, index));
|
||||
return {
|
||||
file,
|
||||
inModel: true,
|
||||
@@ -108,3 +157,38 @@ export function registryPanelStandalone(file: string, parsed: { elements: Regist
|
||||
problems: parsed.problems.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** The decision page of one file inside a model. */
|
||||
export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel {
|
||||
const all = index.decisions.decisions;
|
||||
const d = [...all.values()].find((x) => x.file === file);
|
||||
const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message);
|
||||
const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems };
|
||||
if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] };
|
||||
const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all));
|
||||
const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index));
|
||||
const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path);
|
||||
return {
|
||||
...base,
|
||||
decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) },
|
||||
supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)),
|
||||
elements,
|
||||
diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })),
|
||||
referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */
|
||||
export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel {
|
||||
const d = parsed.decision;
|
||||
return {
|
||||
file,
|
||||
inModel: false,
|
||||
decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined,
|
||||
supersededBy: [],
|
||||
elements: [],
|
||||
diagrams: d ? d.diagrams.map((path) => ({ path })) : [],
|
||||
referencedBy: [],
|
||||
problems: parsed.problems.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
+37
-5
@@ -1,6 +1,8 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { DiagramSource, readDiagramSource } from '../sources/detect';
|
||||
import { ModelProblem, Registry, resolveRefs } from './format';
|
||||
import { DecisionAttachment, ModelProblem, Registry, resolveRefs } from './format';
|
||||
import { effectiveStatus } from './decisions';
|
||||
import { ModelService } from './service';
|
||||
|
||||
/**
|
||||
@@ -18,6 +20,16 @@ export interface PreparedDiagram {
|
||||
links: Map<string, string>;
|
||||
/** Node id → registry description. */
|
||||
descriptions: Map<string, string>;
|
||||
/** Decisions of this diagram for the Decisions rail panel: pointed at by the diagram, or naming it. */
|
||||
decisions: DiagramDecision[];
|
||||
}
|
||||
|
||||
export interface DiagramDecision {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
file: string;
|
||||
attachedTo: DecisionAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,12 +40,32 @@ export interface PreparedDiagram {
|
||||
export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> {
|
||||
const source = readDiagramSource(text, uri.path);
|
||||
if (!source) return undefined;
|
||||
const found = await models.registryFor(uri);
|
||||
const resolved = resolveRefs(source.diagram, { registry: found?.registry, fileName: uri.path });
|
||||
const index = await models.indexFor(uri);
|
||||
const found = index ? { root: index.root, registry: index.registry } : undefined;
|
||||
const resolved = resolveRefs(source.diagram, { registry: found?.registry, decisions: index ? new Set(index.decisions.decisions.keys()) : undefined, fileName: uri.path });
|
||||
// Only diagrams that use refs (or carry a model block) need the resolved form.
|
||||
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0;
|
||||
const changed = resolved.refs.size > 0 || resolved.problems.length > 0 || 'model' in source.diagram || resolved.links.size > 0 || resolved.decisionRefs.length > 0;
|
||||
const prepared: DiagramSource = changed
|
||||
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
|
||||
: source;
|
||||
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions };
|
||||
// Decisions for the rail: the ones this diagram points at, plus the ones that name this diagram.
|
||||
const decisions: DiagramDecision[] = [];
|
||||
if (index) {
|
||||
const rel = path.relative(index.root.fsPath, uri.fsPath).split(path.sep).join('/');
|
||||
const byId = new Map<string, DiagramDecision>();
|
||||
const add = (id: string, attachedTo?: DecisionAttachment) => {
|
||||
const d = index.decisions.decisions.get(id);
|
||||
if (!d) return;
|
||||
let row = byId.get(id);
|
||||
if (!row) {
|
||||
row = { id, title: d.title, status: effectiveStatus(d, index.decisions.decisions).status, file: d.file, attachedTo: [] };
|
||||
byId.set(id, row);
|
||||
decisions.push(row);
|
||||
}
|
||||
if (attachedTo && !row.attachedTo.some((a) => JSON.stringify(a) === JSON.stringify(attachedTo))) row.attachedTo.push(attachedTo);
|
||||
};
|
||||
for (const ref of resolved.decisionRefs) add(ref.id, ref.attachedTo);
|
||||
for (const d of index.decisions.decisions.values()) if (d.diagrams.includes(rel)) add(d.id, { kind: 'diagram' });
|
||||
}
|
||||
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions, decisions };
|
||||
}
|
||||
|
||||
+27
-3
@@ -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,7 +14,7 @@ import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
|
||||
import { viewer3dOption, viewerRoots } from '../webview/viewer3d';
|
||||
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
|
||||
|
||||
@@ -66,6 +66,10 @@ class DiagramPreview implements vscode.Disposable {
|
||||
void this.openLinked(message.file, REGISTRY_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'openDecision') {
|
||||
void this.openLinked(message.file, DECISION_VIEW_TYPE);
|
||||
return;
|
||||
}
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
@@ -200,10 +204,11 @@ class DiagramPreview implements vscode.Disposable {
|
||||
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
|
||||
links: Object.fromEntries(prepared.links),
|
||||
descriptions: Object.fromEntries(prepared.descriptions),
|
||||
decisions: prepared.decisions,
|
||||
model,
|
||||
});
|
||||
// Nonces differ per render, so compare the renderer output instead.
|
||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions]])}\u0000${result.html}`;
|
||||
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`;
|
||||
if (!force && this.rendered && fingerprint === this.lastHtml) {
|
||||
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { readText } from '../util/files';
|
||||
import { log } from '../util/log';
|
||||
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
|
||||
import { makeNonce, registryPage } from '../webview/page';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
|
||||
|
||||
export { REGISTRY_VIEW_TYPE };
|
||||
|
||||
@@ -32,6 +32,7 @@ class RegistryView implements vscode.Disposable {
|
||||
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
|
||||
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
|
||||
void handleWebviewMessage(message, this.uri, this.panel);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,3 +4,5 @@ export const PREVIEW_VIEW_TYPE = 'archify.preview';
|
||||
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
|
||||
/** Default editor for .archify-registry files: the elements as cards. */
|
||||
export const REGISTRY_VIEW_TYPE = 'archify.registry';
|
||||
/** Default editor for .archify-decision files: the decision as a page. */
|
||||
export const DECISION_VIEW_TYPE = 'archify.decision';
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+229
-1
@@ -22,9 +22,14 @@ export interface PageOptions {
|
||||
descriptions?: Record<string, string>;
|
||||
/** Registry page: the elements of one .archify-registry file. */
|
||||
registry?: RegistryPanel;
|
||||
/** Decision page: one .archify-decision file. */
|
||||
decision?: DecisionPanel;
|
||||
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
|
||||
decisions?: DiagramDecision[];
|
||||
}
|
||||
|
||||
import type { ModelPanel, RegistryPanel } from '../model/panel';
|
||||
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
|
||||
import type { DiagramDecision } from '../model/prepare';
|
||||
|
||||
export function makeNonce(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -79,6 +84,8 @@ function bridgeScript(options: PageOptions): string {
|
||||
links: options.links ?? {},
|
||||
model: options.model,
|
||||
registry: options.registry,
|
||||
decision: options.decision,
|
||||
decisions: options.decisions || [],
|
||||
descriptions: options.descriptions || {},
|
||||
};
|
||||
return `(function () {
|
||||
@@ -234,12 +241,48 @@ function bridgeScript(options: PageOptions): string {
|
||||
});
|
||||
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
|
||||
list.appendChild(used);
|
||||
if (element.decisions && element.decisions.length) {
|
||||
var decided = el('li', 'archify-model-card-used');
|
||||
element.decisions.forEach(function (row) { decided.appendChild(decisionChip(row)); });
|
||||
list.appendChild(decided);
|
||||
}
|
||||
card.appendChild(list);
|
||||
card.addEventListener('click', function () { openElementModal(element); });
|
||||
card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } });
|
||||
return card;
|
||||
}
|
||||
|
||||
// A decision as a chip (id, title, status) that opens its page.
|
||||
function decisionChip(row) {
|
||||
var chip = el('button', 'archify-model-used-link archify-decision-chip is-' + row.status, '');
|
||||
chip.type = 'button';
|
||||
chip.appendChild(el('span', 'archify-decision-dot'));
|
||||
chip.appendChild(document.createTextNode(row.id + (row.title ? ' · ' + row.title : '')));
|
||||
chip.title = 'Decision ' + row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')';
|
||||
chip.addEventListener('click', function (event) { event.stopPropagation(); if (row.file) post({ type: 'openDecision', file: row.file }); });
|
||||
return chip;
|
||||
}
|
||||
function statusBadge(row) {
|
||||
var badge = el('span', 'archify-decision-status is-' + row.status, row.status + (row.supersededBy ? ' by ' + row.supersededBy : ''));
|
||||
return badge;
|
||||
}
|
||||
// A decision as a row in a list: id, title, status, date; click opens it.
|
||||
function decisionRowItem(row) {
|
||||
var item = el('li', 'archify-model-item archify-decision-item is-' + row.status);
|
||||
var button = el('button', 'archify-model-open archify-decision-open');
|
||||
button.type = 'button';
|
||||
button.title = row.file || row.id;
|
||||
var line = el('span', 'archify-decision-line');
|
||||
line.appendChild(el('span', 'archify-decision-id', row.id));
|
||||
line.appendChild(el('span', 'archify-model-item-title', row.title || ''));
|
||||
line.appendChild(statusBadge(row));
|
||||
if (row.date) line.appendChild(el('span', 'archify-model-item-meta', row.date));
|
||||
button.appendChild(line);
|
||||
button.addEventListener('click', function () { if (row.file) post({ type: 'openDecision', file: row.file }); });
|
||||
item.appendChild(button);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Modal: one element, where it is defined and the diagrams that use it.
|
||||
function openElementModal(element) {
|
||||
closeElementModal();
|
||||
@@ -292,6 +335,12 @@ function bridgeScript(options: PageOptions): string {
|
||||
});
|
||||
modal.appendChild(list);
|
||||
}
|
||||
if (element.decisions && element.decisions.length) {
|
||||
modal.appendChild(el('h3', 'archify-model-title', 'Decisions · ' + element.decisions.length));
|
||||
var decisions = el('ul', 'archify-model-list archify-decision-list');
|
||||
element.decisions.forEach(function (row) { decisions.appendChild(decisionRowItem(row)); });
|
||||
modal.appendChild(decisions);
|
||||
}
|
||||
backdrop.appendChild(modal);
|
||||
backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); });
|
||||
document.body.appendChild(backdrop);
|
||||
@@ -325,6 +374,130 @@ function bridgeScript(options: PageOptions): string {
|
||||
document.body.appendChild(body);
|
||||
}
|
||||
|
||||
// The decision page: one .archify-decision file.
|
||||
function renderDecision(page) {
|
||||
if (!page) return;
|
||||
var d = page.decision;
|
||||
var head = el('section', 'archify-model archify-model-head');
|
||||
head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : '')));
|
||||
head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file));
|
||||
if (d) {
|
||||
var meta = el('p', 'archify-decision-meta');
|
||||
meta.appendChild(statusBadge(d));
|
||||
if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date));
|
||||
meta.appendChild(el('span', 'archify-model-item-meta', page.file));
|
||||
head.appendChild(meta);
|
||||
}
|
||||
if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.'));
|
||||
page.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
||||
document.body.appendChild(head);
|
||||
var body = el('section', 'archify-model archify-model-body');
|
||||
if (d) {
|
||||
[['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) {
|
||||
if (!pair[1]) return;
|
||||
var section = el('section', 'archify-decision-section');
|
||||
section.appendChild(el('h2', 'archify-model-title', pair[0]));
|
||||
section.appendChild(el('p', 'archify-decision-text', pair[1].trim()));
|
||||
body.appendChild(section);
|
||||
});
|
||||
if (d.supersedes.length || page.supersededBy.length) {
|
||||
var rel = el('section', 'archify-decision-section');
|
||||
rel.appendChild(el('h2', 'archify-model-title', 'Related decisions'));
|
||||
var relList = el('ul', 'archify-model-list archify-decision-list');
|
||||
d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); });
|
||||
page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); });
|
||||
rel.appendChild(relList);
|
||||
body.appendChild(rel);
|
||||
}
|
||||
}
|
||||
if (page.elements.length) {
|
||||
var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true);
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
elementsPanel.appendChild(cards);
|
||||
body.appendChild(elementsPanel);
|
||||
}
|
||||
var diagramRows = [];
|
||||
page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); });
|
||||
page.referencedBy.forEach(function (use) {
|
||||
var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram';
|
||||
var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0];
|
||||
if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how });
|
||||
});
|
||||
if (diagramRows.length) {
|
||||
var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true);
|
||||
var list = el('ul', 'archify-model-list');
|
||||
diagramRows.forEach(function (row) {
|
||||
var item = el('li', 'archify-model-item');
|
||||
var button = el('button', 'archify-model-open');
|
||||
button.type = 'button';
|
||||
button.title = row.path;
|
||||
button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop()));
|
||||
button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how));
|
||||
button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); });
|
||||
item.appendChild(button);
|
||||
list.appendChild(item);
|
||||
});
|
||||
diagramsPanel.appendChild(list);
|
||||
body.appendChild(diagramsPanel);
|
||||
}
|
||||
document.body.appendChild(body);
|
||||
}
|
||||
|
||||
// Decisions rail panel on a diagram page: the decisions this diagram points
|
||||
// at or that name it. Hover lights up what the decision is attached to.
|
||||
function renderDecisionsRail() {
|
||||
var rows = config.decisions || [];
|
||||
var outline = document.getElementById('node-outline');
|
||||
var svg = document.querySelector('.diagram-container > svg');
|
||||
if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return;
|
||||
var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions');
|
||||
nav.id = 'archify-decisions';
|
||||
var head = el('div', 'node-outline-head');
|
||||
head.appendChild(el('strong', '', 'Decisions'));
|
||||
head.appendChild(el('span', 'node-outline-count', String(rows.length)));
|
||||
nav.appendChild(head);
|
||||
var list = el('ol', 'node-outline-list');
|
||||
var group = el('li', 'node-outline-group');
|
||||
var items = el('ul');
|
||||
function targets(row) {
|
||||
var found = [];
|
||||
row.attachedTo.forEach(function (a) {
|
||||
if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]'));
|
||||
if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]'));
|
||||
});
|
||||
return found;
|
||||
}
|
||||
function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); }
|
||||
rows.forEach(function (row) {
|
||||
var item = el('li');
|
||||
var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status);
|
||||
button.type = 'button';
|
||||
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
||||
var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', ');
|
||||
button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : '');
|
||||
var dot = el('span', 'node-outline-dot');
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
button.appendChild(dot);
|
||||
button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : '')));
|
||||
button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : '')));
|
||||
button.addEventListener('pointerenter', function () { mark(row, true); });
|
||||
button.addEventListener('pointerleave', function () { mark(row, false); });
|
||||
button.addEventListener('focus', function () { mark(row, true); });
|
||||
button.addEventListener('blur', function () { mark(row, false); });
|
||||
button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); });
|
||||
item.appendChild(button);
|
||||
items.appendChild(item);
|
||||
});
|
||||
group.appendChild(items);
|
||||
list.appendChild(group);
|
||||
nav.appendChild(list);
|
||||
var connectors = document.getElementById('archify-connectors');
|
||||
(connectors || outline).insertAdjacentElement('afterend', nav);
|
||||
var archify = window.Archify;
|
||||
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
||||
}
|
||||
|
||||
// Elements: registry descriptions as tooltips on Archify's node index rows.
|
||||
function describeElements() {
|
||||
var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]');
|
||||
@@ -484,6 +657,14 @@ function bridgeScript(options: PageOptions): string {
|
||||
registryPanel.appendChild(filePanel);
|
||||
});
|
||||
}
|
||||
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
|
||||
if (model.decisions && model.decisions.length) {
|
||||
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
|
||||
body.appendChild(decisionsPanel);
|
||||
var decisionList = el('ul', 'archify-model-list archify-decision-list');
|
||||
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
|
||||
decisionsPanel.appendChild(decisionList);
|
||||
}
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
}
|
||||
|
||||
@@ -497,7 +678,9 @@ function bridgeScript(options: PageOptions): string {
|
||||
}
|
||||
if (config.model) onReady(function () { renderModel(config.model); });
|
||||
if (config.registry) onReady(function () { renderRegistry(config.registry); });
|
||||
if (config.decision) onReady(function () { renderDecision(config.decision); });
|
||||
onReady(renderConnectors);
|
||||
onReady(renderDecisionsRail);
|
||||
onReady(describeElements);
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -559,6 +742,24 @@ const MODEL_STYLE = `
|
||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
/* Decisions: rows, chips and status badges. */
|
||||
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
||||
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
||||
.archify-decision-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; }
|
||||
.archify-decision-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.8125rem; color: var(--m-muted); }
|
||||
.archify-decision-status { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.05rem 0.45rem; border-radius: 999px; border: 1px solid var(--m-border); color: var(--m-muted); }
|
||||
.archify-decision-status.is-accepted { color: var(--m-backend); border-color: var(--m-backend); }
|
||||
.archify-decision-status.is-proposed { color: var(--m-cloud); border-color: var(--m-cloud); }
|
||||
.archify-decision-status.is-rejected { color: var(--m-security); border-color: var(--m-security); }
|
||||
.archify-decision-item.is-superseded .archify-model-open, .archify-decision-item.is-rejected .archify-model-open { opacity: 0.6; }
|
||||
.archify-decision-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-family: inherit; }
|
||||
.archify-decision-dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--m-muted); }
|
||||
.archify-decision-chip.is-accepted .archify-decision-dot { background: var(--m-backend); }
|
||||
.archify-decision-chip.is-proposed .archify-decision-dot { background: var(--m-cloud); }
|
||||
.archify-decision-chip.is-rejected .archify-decision-dot { background: var(--m-security); }
|
||||
.archify-decision-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin: 0.4rem 0 0; }
|
||||
.archify-decision-section { margin-top: 0.5rem; }
|
||||
.archify-decision-text { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.6; color: var(--m-text); white-space: pre-line; }
|
||||
/* Registry cards: Archify's own .card look, with fallbacks for the page without a diagram. */
|
||||
.archify-model { --m-frontend: var(--frontend-stroke, #22d3ee); --m-backend: var(--backend-stroke, #34d399); --m-database: var(--database-stroke, #a78bfa); --m-cloud: var(--cloud-stroke, #fbbf24); --m-security: var(--security-stroke, #fb7185); --m-messagebus: var(--messagebus-stroke, #fb923c); --m-external: var(--external-stroke, #94a3b8); }
|
||||
.archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; }
|
||||
@@ -582,6 +783,14 @@ const CONNECTOR_STYLE = `
|
||||
.diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); }
|
||||
.diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; }
|
||||
.archify-connectors .node-outline-item strong { font-weight: 600; }
|
||||
.diagram-container svg [data-node-id][data-archify-hit] { filter: drop-shadow(0 0 6px var(--frontend-stroke)); }
|
||||
/* Bottom rail with a Decisions panel: three columns. */
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions) { grid-template-areas: "cards cards cards" "index connectors decisions"; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions):has(> #node-outline[hidden]) { grid-template-areas: "cards cards" "connectors decisions"; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions { display: block; grid-area: decisions; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
||||
.archify-decision-rail-item.is-superseded, .archify-decision-rail-item.is-rejected { opacity: 0.6; }
|
||||
/* Bottom rail: Elements and Connectors side by side; Connectors alone when Archify hides its index. */
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors) { grid-template-areas: "cards cards" "index connectors"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 1rem; }
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors):has(> #node-outline[hidden]) { grid-template-areas: "cards" "connectors"; grid-template-columns: minmax(0, 1fr); }
|
||||
@@ -675,6 +884,25 @@ export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' |
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** The decision page for one .archify-decision file. */
|
||||
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, decision: DecisionPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} });
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
|
||||
<script nonce="${options.nonce}">${bridge}</script>
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${MODEL_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** A small VS Code-styled page for loading and error states. */
|
||||
export function messagePage(
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||
|
||||
@@ -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,26 @@ export async function run(): Promise<void> {
|
||||
assert.equal(editor.document.languageId, 'yaml');
|
||||
});
|
||||
|
||||
await check('a .archify-decision file opens as the decision view; an unknown decision id is a problem in a diagram', async () => {
|
||||
const decision = workspaceFile('model/decisions/0002-queue-between-api-and-worker.archify-decision');
|
||||
await vscode.commands.executeCommand('vscode.open', decision);
|
||||
const tab = await waitFor('decision view tab', () =>
|
||||
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.decision' && t.input.uri.toString() === decision.toString()),
|
||||
);
|
||||
assert.ok(tab);
|
||||
// A diagram that points at a decision nobody defines.
|
||||
const bad = workspaceFile('model/0300-components/bad-decision.archify');
|
||||
await vscode.workspace.fs.writeFile(bad, Buffer.from('schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ndecisions: [nobody-decided]\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n decisions: [queue-between-api-and-worker]\n', 'utf8'));
|
||||
const doc = await vscode.workspace.openTextDocument(bad);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
const problem = await waitFor('unknown decision problem', () =>
|
||||
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown decision "nobody-decided"/.test(d.message)),
|
||||
);
|
||||
assert.equal(problem.range.start.line, 5);
|
||||
assert.ok(!vscode.languages.getDiagnostics(doc.uri).some((d) => /queue-between-api-and-worker/.test(d.message)), 'the known decision is not reported');
|
||||
await vscode.workspace.fs.delete(bad);
|
||||
});
|
||||
|
||||
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
|
||||
const folder = workspaceFile('new-shop');
|
||||
await vscode.workspace.fs.createDirectory(folder);
|
||||
@@ -385,7 +405,7 @@ export async function run(): Promise<void> {
|
||||
assert.match(content, /name: Shop two/);
|
||||
assert.match(content, /title: Shop two — overview/);
|
||||
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort();
|
||||
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'main.archify', 'registry']);
|
||||
assert.deepEqual(names, ['0100-context', '0200-containers', '0300-components', 'decisions', 'main.archify', 'registry']);
|
||||
const registry = (await vscode.workspace.fs.readDirectory(vscode.Uri.joinPath(folder, 'registry'))).map(([name]) => name).sort();
|
||||
assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
|
||||
const tab = await waitFor('model page tab', () =>
|
||||
|
||||
Reference in New Issue
Block a user