Compare commits

...
20 Commits
Author SHA1 Message Date
root-at-skic 69b87833b7 0.9.1: republish so Open VSX carries the release 2026-10-23 13:29:14 +03:00
root-at-skic 058c0d17a8 Merge pull request '0.9.0: model graph with queries' (#27) from feat/model-graph into main 2026-10-21 13:08:35 -07:00
root-at-skic 8398c44ca8 Model graph: one head block with a consistent gutter and rhythm 2026-10-21 22:47:40 +03:00
root-at-skic 60ad7479fd Model graph: decision titles on two lines, refit after resize 2026-10-21 22:39:30 +03:00
root-at-skic bc9f55e2fa Model graph: decision and registry links on by default, placed near their elements 2026-10-21 22:33:02 +03:00
root-at-skic 27d1b4381c Model graph: theme toggle in the same pill as the switch and Export 2026-10-21 22:03:39 +03:00
root-at-skic 82dd10253b Model graph: toolbar on Archify's rules
- Archify's theme toggle: pill track, sliding knob, masked sun/moon icon
- buttons as quiet 0.5rem pills inside 0.5625rem controls, pressed state like Archify's, Export PNG as the filled 0.6875rem primary
- Model | Graph switch styled as the 2D | 3D switch's twin
2026-10-21 21:55:24 +03:00
root-at-skic 91fcde9fa5 Model graph: connection labels at the less crowded end 2026-10-21 21:47:08 +03:00
root-at-skic 17e07adb33 Model graph: layered layout and a cleaner look
- layered layout by type (clients left, data right), empty columns left out, barycenter row ordering; registries in a row above, decisions in a row below; Force layout as the alternative
- orthogonal connections with labels staggered next to their source; opaque tinted nodes hide lines behind them
- hover glow on elements and their connections; decision and registry links show for the selected element or all with Links
2026-10-21 21:45:42 +03:00
root-at-skic 3ed2d7eed8 Model graph: dark/light theme toggle on the page
- sun/moon button next to Export; the choice is remembered in the page state and written to the archify-theme key the diagram pages use
- explicit dark and light token sets; without a choice the page follows VS Code's theme as before
- Cytoscape restyles from the page tokens whenever the theme changes
2026-10-21 21:33:38 +03:00
root-at-skic 99ea076571 Model graph: decisions and registry files as nodes
- one node per decision (tag coloured by status) with decides links to its elements and supersedes links
- one node per registry file with defines links to its elements
- own legend chips, side panels (Open decision, Open registry), type:decision and type:registry
- walks and paths follow the diagrams' connections only; attached nodes follow their elements into a result
2026-10-21 20:46:05 +03:00
root-at-skic c8645b228b Model graph: the whole model as one graph with queries; 0.9.0
- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram)
- union graph of every element and connection, registry and inline, built from the model index
- query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared
- side panel per element and connection with diagrams, registry line and decisions one click away
- legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js
- command Archify | Open Model Graph; integration check; unit tests for graph and queries
2026-10-21 15:47:00 +03:00
root-at-skic 265f8c57f6 Merge pull request '0.8.1: New Registry command, creator order' (#26) from feat/new-registry into main 2026-10-20 01:23:03 -07:00
root-at-skic c66e819abe Archify | New Registry…; creators ordered Model, Registry, Diagram, Decision; 0.8.1 2026-10-20 10:55:00 +03:00
root-at-skic 2ba66c1ee2 Merge pull request '0.8.0: architecture decisions' (#25) from feat/decisions into main 2026-10-19 13:13:17 -07:00
root-at-skic ebf320a023 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
2026-10-19 23:13:05 +03:00
root-at-skic 33ebdd6328 Model page: title the registry files panel Registries; 0.7.2 2026-10-19 21:25:58 +03:00
root-at-skic 9684464630 Merge pull request '0.7.1: Registry panel lists registry files' (#23) from feat/registry-files into main 2026-10-19 10:59:47 -07:00
root-at-skic 094ca2925f Drop generated cards; registry descriptions become Elements tooltips
- resolveRefs no longer adds cards to a diagram; it returns node id →
  description, which the page sets as the tooltip of the matching row in
  Archify's Elements panel
- hand-written cards pass through unchanged; docs and changelog
2026-10-19 20:44:09 +03:00
root-at-skic de1a57e4c6 Model page: Registry panel lists registry files, elements inside each
- one collapsible header per .archify-registry file with its element
  count and an Open button that opens the registry view; files start
  collapsed, the Registry panel open
- view types moved to views/viewTypes.ts so the diagram page can open a
  registry without a circular import
- 0.7.1
2026-10-19 20:39:24 +03:00
60 changed files with 3831 additions and 170 deletions

No files matched your search

+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## 0.9.1 — 2026-10-23
- No functional change. Republished so that Open VSX carries the release: the 0.9.0 upload there was held under review while the `root-at-skic` namespace was still unverified, and a held upload is not released by the later ownership grant.
## 0.9.0 — 2026-10-21
- **Model graph.** A **Model | Graph** switch on the `main.archify` page (next to **Export** when the file has a diagram, top right otherwise) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once, drawn with Cytoscape.js in Archify's type colours. Registry elements have a solid border, inline ones a dashed border. **Decisions** and **registry files** are nodes as well: a decision is a tag coloured by status with dotted links to the elements and connections it is about and dashed links to what it supersedes; a registry links to the elements it defines. Layered layout by type (clients left, data right, registries above, decisions below, orthogonal connections) or a force layout; hover lights up an element and its connections; the dotted decision and registry links are shown by default; **Links** switches them to the selected element only. Drag, zoom, **Fit**, **Labels**, a dark/light theme toggle of its own (remembered, and shared with the diagram pages' theme key), legend chips that hide a type (decisions and registries included), **Export PNG**. The choice is remembered per file; the graph updates in place as the model changes.
- **Queries.** A query box narrows the graph: `database`, `type:backend`, `diagram:0200-*`, `decision:<id>`, `uses:api`, `near:api,2`, `from:web to:db` (shortest path), `unused`, `shared`, `-shared`, `type:decision`, `type:registry`; terms AND together, a leading `-` excludes; decisions and registries follow their elements into a result, and walks never pass through them; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click.
- **Side panel.** An element shows its description, type, registry file and line (**Registry** opens it), the diagrams that draw it, its connections and its decisions, each one click away; **Neighbours** queries around it. A connection shows its labels, diagrams and decisions. A decision node shows its status, text, the elements and connections it is about and **Open decision**; a registry node the elements it defines and **Open registry**.
- **Entry points.** On a diagram page inside a model the switch reads **Diagram | Graph** and opens the graph narrowed to `diagram:<that diagram>`. New command *Archify | Open Model Graph* in the editor title, the Command Palette and the Explorer menu, wherever *Open Model* is.
- Diagram summaries in the model index now carry every node and edge of a diagram (shared and inline), which the graph is built from.
## 0.8.1 — 2026-10-20
- **Archify | New Registry…** on a folder (also *File → New File…* and the Command Palette) creates an `.archify-registry` file from the template and opens it.
- The Archify entries in the Explorer menu and *File → New File…* come in the order New Model, New Registry, New Diagram, New Decision.
## 0.8.0 — 2026-10-19
- **Architecture decisions.** A model can hold decisions: one `*.archify-decision` file each, anywhere under the model root, with an id (unique in the model), title, status, date, the registry `elements` and `diagrams` it is about, `supersedes`, and prose for context, decision and consequences.
- **Diagrams point at decisions** with `decisions: [<id>]` on the diagram, on a component, node or participant, and on a connection, edge, flow, transition or message. The key is stripped before Archify renders. Unknown ids, and the key outside a model, are reported in Problems. Completion, hover and go-to-definition for decision ids; in decision files the same for `elements`, `diagrams` and `supersedes`.
- **Where they show.** A **Decisions** panel on the `main.archify` page (one list, live decisions first, superseded and rejected dimmed and last). A **Decisions** rail panel under a diagram with the decisions it points at or that name it; hovering a row lights up the node or connection it is attached to, clicking opens the decision. A decision view when a `.archify-decision` file is opened: status, date, context, decision, consequences, related decisions, the elements as cards, the diagrams with where the decision is attached. Decisions also appear on element cards and in the element dialog.
- **Authoring.** *Archify | New Decision…* on a folder inside a model creates a decision file from a template, with the id taken from the title. Editor schema for decision files and the new `decisions` key. A decision outside a model opens, but the page and Problems say it has lost its model.
- The three New Model examples carry decisions. Model page: the registry files panel is titled **Registries**.
## 0.7.1 — 2026-10-19
- **No generated cards.** The cards 0.7.0 generated under model diagrams from registry descriptions are gone; they repeated the Elements panel. The description now shows as the tooltip of the element's row in the Elements panel. Hand-written `cards:` are unchanged.
- **Model page, Registry panel:** lists the registry files of the model, one collapsible header per file with its element count and an **Open** button that opens the registry view; expand a file to see its elements as cards. Before, all elements of all files were shown in one grid.
## 0.7.0 — 2026-10-19 ## 0.7.0 — 2026-10-19
- **Models:** a folder with a `main.archify` at its top is a model, a set of diagrams that belong together. Opening `main.archify` shows the model page: name and description, the overview diagram if the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used. - **Models:** a folder with a `main.archify` at its top is a model, a set of diagrams that belong together. Opening `main.archify` shows the model page: name and description, the overview diagram if the file has one, the diagrams grouped by folder (click to open), and the registry with where each element is used.
+11 -4
View File
@@ -65,6 +65,7 @@ The Archify CLI and the AI agents that use it write diagrams as JSON, named `nam
- **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder. - **New diagram from a template**: *Archify | New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
- **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration). - **New model from an example**: *Archify | New Model…* creates a complete model folder from a C4 example (Standard Web App, E-Shop, Event-Based Integration).
- **Model graph**: the **Graph** switch on any page of a model shows the whole model as one graph, with a query box (`type:database`, `uses:api`, `from:web to:db`, …), a side panel per element and PNG export. See [Models](#models).
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live. - **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
- **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`). - **Live updates**: the diagram re-renders as you edit, and keeps the focus and route you selected (`#focus=…`, `#route=…`).
- **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. - **HTML viewer**: right-click a rendered `.html` file and choose **Archify | Open in HTML Viewer**, or use *Reopen Editor With… → Archify HTML Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
@@ -73,7 +74,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. - **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. - **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. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
- **Commands**: *Archify | New Diagram…*, *Archify | New Model…*, *Archify | Open Model*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*. - **Commands**: *Archify | New Model…*, *Archify | New Registry…*, *Archify | New Diagram…*, *Archify | New Decision…*, *Archify | Open Model*, *Archify | Open Model Graph*, *Archify | Render to HTML File…*, *Archify | Open in Browser*, *Archify | Refresh Preview*, *Archify | Show Source*.
## 3D view ## 3D view
@@ -111,19 +112,25 @@ online-shop/
people.archify-registry shared elements: one name for one thing people.archify-registry shared elements: one name for one thing
systems.archify-registry systems.archify-registry
containers.archify-registry containers.archify-registry
decisions/
0002-queue-between-api-and-worker.archify-decision
0100-context/online-shop.archify 0100-context/online-shop.archify
0200-containers/online-shop.archify 0200-containers/online-shop.archify
0300-components/api.archify 0300-components/api.archify
``` ```
- **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, then two collapsible panels: **Diagrams**, with a collapsible header per folder and a card per diagram (click to open), and **Registry**, the shared elements as cards with the diagrams each is used in. Open or closed is remembered per page. - **The model page.** Click `main.archify` and you get the model's page: its name and description, the overview diagram if the file has one, then two collapsible panels: **Diagrams**, with a collapsible header per folder and a card per diagram (click to open), and **Registries**, one collapsible header per registry file (with an **Open** button for its view) and the file's elements as cards inside, each with the diagrams it is used in. Open or closed is remembered per page.
- **Shared elements.** A registry file defines an element once (`type`, `label`, `sublabel`, `brand`, `description`, …). A diagram draws it with `ref: api` plus `pos` and `size`; it may override `label`, `sublabel` and `tag`, and may still define unshared elements inline. Hover a `ref` for the description, F12 jumps to the registry, completion after `ref:` lists the elements, unknown names appear in Problems. - **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. - **Links.** A registry element (or a component) with `diagram: 0300-components/api.archify` points at the diagram that explains it: double-click the node in the 2D view to open it.
- **Cards.** A model diagram without `cards` of its own shows one card per shared element it draws, from the registry descriptions. The model page shows the registry as the same cards, each with the diagrams it is used in. - **Descriptions.** Under a model diagram, hovering a row of the **Elements** panel shows the registry description of that element.
- **Decisions.** One `*.archify-decision` file per architecture decision: id, title, status, date, the registry elements and diagrams it is about, and prose for context, decision and consequences. Diagrams point at decisions with `decisions: [<id>]` on the diagram, a component or a connection. They show as a **Decisions** panel on the `main.archify` page, a **Decisions** rail panel under each diagram (hover lights up what the decision is attached to), on element cards and in the element dialog, and a decision opens as its own page. *Archify | New Decision…* creates one; links are checked in Problems.
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it. - **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. - **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`. - **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`.
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file. - **The graph.** The **Model | Graph** switch at the top of the `main.archify` page (next to **Export** when the file has a diagram) replaces the page with the whole model as one graph: every element any diagram draws plus every registry element, every connection between them, each once. Registry elements have a solid border, inline ones a dashed border; colours are Archify's type colours and the legend chips at the bottom hide or show a type. **Decisions** are nodes too (tags coloured by status, dotted links to the elements they are about, dashed links to what they supersede), and so are **registries** (one node per file, dotted links to the elements it defines); their chips hide them when the picture gets busy. The layout is **Layered** by default, like Archify's stack: clients on the left, data on the right, registries in a row above, decisions in a row below, connections drawn orthogonally; click the button for a **Force** layout instead. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Labels** toggles connection labels, the dotted **Links** from decisions and registries to their elements are on by default and can be switched off, in which case only the selected element's show (hovering an element lights up its connections), the sun/moon button switches the page between its dark and light theme (remembered, shared with the diagram pages' theme choice), **Export PNG** saves the picture. On a diagram page the switch reads **Diagram | Graph** and opens the graph already narrowed to that diagram. *Archify | Open Model Graph* does the same from the Command Palette or Explorer.
- **Queries.** Type a few words in the box (<kbd>/</kbd> focuses it); matches stay bright, the rest dims, the count shows "9 of 14 elements". Terms combine with AND and a leading `-` excludes: `database` (id, label, sublabel, brand or type contain it), `type:backend`, `diagram:0200-*` (what those diagrams draw), `decision:<id>` (what a decision is about or is attached to), `uses:api` (an element and its neighbours), `near:api,2` (within two steps), `from:web to:db` (the shortest path, following the arrows first), `unused` (registry elements no diagram draws), `shared` and `-shared` (registry or inline elements only), `type:decision` and `type:registry` for the decision and registry nodes. Decisions and registries follow the elements they are attached to into a result; `-type:decision` or `-type:registry` leaves them out. Walks and paths follow the diagrams' connections only, so a registry is never a shortcut between its elements. The **?** button lists them; clicking an example runs it.
- **Side panel.** Click an element for its description, type, where it is defined (**Registry** opens the file at the line), the diagrams that draw it (click to open), its connections and its decisions (click to open). **Neighbours** runs `uses:` for it. Click a connection for its labels, the diagrams that draw it and its decisions. Click a decision node for its status, context and decision text, the elements and connections it is about, and **Open decision**; a registry node for the elements it defines, the ones no diagram draws yet, and **Open registry**.
- **Commands.** *Archify | Open Model* opens the model page of the current file, from the editor title, the Command Palette or the Explorer menu of a `.archify` or `.archify-registry` file; *Archify | Open Model Graph* opens the graph.
Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop). Formats in detail: [docs/model.md](docs/model.md). A full example: [examples/online-shop](examples/online-shop).
+58 -2
View File
@@ -64,9 +64,9 @@ components:
- Before Archify runs, the extension replaces each `ref` component with the registry element plus the diagram's own fields, drops `description` and `diagram`, and hands Archify an ordinary component. - Before Archify runs, the extension replaces each `ref` component with the registry element plus the diagram's own fields, drops `description` and `diagram`, and hands Archify an ordinary component.
- Connections, boundaries and everything else stay as they are and use the ids as before. - Connections, boundaries and everything else stay as they are and use the ids as before.
## Cards under a model diagram ## Descriptions under a model diagram
Archify shows `cards` (a coloured dot, a title, a few lines) below a diagram. A diagram inside a model that draws shared elements and defines no `cards` of its own gets one card per drawn element that has a `description` in the registry, in drawing order, with the dot colour of the element's type. Write your own `cards:` in the diagram to replace them. Archify's **Elements** panel under a diagram lists its nodes. For a `ref` component the row's tooltip is the element's registry `description`. Archify's own `cards:` stay what they are: write them in the diagram when you want text blocks below it; nothing is generated.
## Links between diagrams ## Links between diagrams
@@ -88,3 +88,59 @@ A diagram with `ref` components outside any model (no `main.archify` above it) r
## Starting a model ## 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. *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.
## The model graph
The **Model | Graph** switch on the `main.archify` page replaces it with the whole model as one graph. It is built from the model index, not from Archify's renderer:
- **Nodes** are every element any diagram draws (`components`, `nodes`, `participants`, `states`), joined by id across diagrams, plus every registry element even when no diagram draws it yet. Registry elements carry their description, file and line; inline elements only what the diagram says. The same inline id in two diagrams is one node.
- **Edges** are every `connections`, `edges`, `flows`, `transitions` and `messages` entry between two nodes of the same diagram, keyed by `from>to`; the labels different diagrams give the same pair are joined with " / ". Direction is kept.
- **Decisions** reach nodes through `elements:` in the decision file and through `decisions:` on a node in a diagram; they reach edges through `decisions:` on a connection.
- **Decision nodes** (`decision:<id>`, type `decision`) carry the effective status, the context and decision text, and the diagrams the decision names; `decides` edges point at the elements it is about, `supersedes` edges at the decisions it replaces.
- **Registry nodes** (`registry:<file>`, type `registry`) stand for each `.archify-registry` file; `defines` edges point at its elements.
Walks (`uses:`, `near:`, `from: to:`) follow `connection` edges only. Decision and registry nodes join a result when an element they link to matches, and `-type:decision` or `-type:registry` removes them.
Queries run in the page against that graph; the forms are listed under **?** on the page and in the README. `uses:` and `near:` ignore direction; `from: to:` follows the arrows first and ignores them only when no directed path exists. Connections are shown when both ends match, so `type:backend` shows backend-to-backend connections only.
The switch is remembered per `main.archify` in the workspace. The graph page updates in place when a file of the model changes: elements that are still there keep their position, new ones are laid out around them.
+9
View File
@@ -49,6 +49,15 @@ if (tests) {
entryPoints: ['src/viewer3d/index.ts'], entryPoints: ['src/viewer3d/index.ts'],
outfile: 'dist/viewer3d.js', outfile: 'dist/viewer3d.js',
}), }),
// The Graph page of a model; Cytoscape.js is bundled in.
esbuild.context({
...common,
format: 'iife',
platform: 'browser',
target: 'es2022',
entryPoints: ['src/graph/index.ts'],
outfile: 'dist/graph.js',
}),
]); ]);
if (watch) { if (watch) {
await Promise.all(contexts.map((ctx) => ctx.watch())); await Promise.all(contexts.map((ctx) => ctx.watch()));
@@ -3,6 +3,7 @@ diagram_type: architecture
meta: meta:
title: Containers title: Containers
output: containers.html output: containers.html
decisions: [postgres-as-source-of-truth]
components: components:
- ref: customer - ref: customer
pos: [40, 160] pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue - ref: queue
pos: [460, 360] pos: [460, 360]
size: [130, 60] size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker - ref: worker
pos: [670, 460] pos: [670, 460]
size: [130, 60] size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON } - { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read } - { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL } - { 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: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] } - { 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.
+59 -2
View File
@@ -1,18 +1,22 @@
{ {
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"version": "0.7.0", "version": "0.9.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"version": "0.7.0", "version": "0.9.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cytoscape": "^3.34.4",
"cytoscape-fcose": "^2.2.0",
"three": "^0.186.1", "three": "^0.186.1",
"yaml": "^2.9.1" "yaml": "^2.9.1"
}, },
"devDependencies": { "devDependencies": {
"@types/cytoscape": "^3.21.9",
"@types/cytoscape-fcose": "^2.2.5",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/three": "^0.186.0", "@types/three": "^0.186.0",
"@types/vscode": "~1.100.0", "@types/vscode": "~1.100.0",
@@ -1015,6 +1019,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cytoscape-fcose": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@types/cytoscape-fcose/-/cytoscape-fcose-2.2.5.tgz",
"integrity": "sha512-FaM6MM5zC4Yh72E60vkpbM4tstJS90wj3u1Vq/SMiK/MShhLaMd17QkmR39S14aUWdfNSlUEfx/g/XkDdFmXDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cytoscape": "^3.31"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.4", "version": "22.20.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
@@ -1755,6 +1776,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cose-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz",
"integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
"license": "MIT",
"dependencies": {
"layout-base": "^2.0.0"
}
},
"node_modules/css-select": { "node_modules/css-select": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -1785,6 +1815,27 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/cytoscape": {
"version": "3.34.4",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.4.tgz",
"integrity": "sha512-msY99tMY8iWTg+sIPGfSqDcPNOt4xcj+v/K/7qUFaCUxxlqnMkUYLBjRUzn9W5CU/R2hrmtJa5+UtovrEM2ctg==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/cytoscape-fcose": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
"integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
"license": "MIT",
"dependencies": {
"cose-base": "^2.2.0"
},
"peerDependencies": {
"cytoscape": "^3.2.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2935,6 +2986,12 @@
"prebuild-install": "^7.0.1" "prebuild-install": "^7.0.1"
} }
}, },
"node_modules/layout-base": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz",
"integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
"license": "MIT"
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+93 -24
View File
@@ -2,7 +2,7 @@
"name": "archify-vscode-ext", "name": "archify-vscode-ext",
"displayName": "Archify Diagram Viewer", "displayName": "Archify Diagram Viewer",
"description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.", "description": "Archify diagrams in VS Code. Write diagrams as .archify files in YAML: they open as the interactive diagram, with live updates, validation, templates and completion. Also previews Archify's own JSON files (*.architecture.json, …) and opens rendered Archify HTML.",
"version": "0.7.0", "version": "0.9.1",
"publisher": "root-at-skic", "publisher": "root-at-skic",
"license": "MIT", "license": "MIT",
"sponsor": { "sponsor": {
@@ -33,7 +33,9 @@
"registry", "registry",
"sequence", "sequence",
"workflow", "workflow",
"yaml" "yaml",
"adr",
"decisions"
], ],
"main": "./dist/extension.js", "main": "./dist/extension.js",
"activationEvents": [ "activationEvents": [
@@ -42,19 +44,30 @@
"onLanguage:html", "onLanguage:html",
"workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}", "workspaceContains:**/*.{archify,architecture.json,workflow.json,sequence.json,dataflow.json,lifecycle.json}",
"onWebviewPanel:archify.preview", "onWebviewPanel:archify.preview",
"workspaceContains:**/*.archify-registry" "workspaceContains:**/*.archify-registry",
"workspaceContains:**/*.archify-decision"
], ],
"contributes": { "contributes": {
"commands": [ "commands": [
{
"command": "archify.newModel",
"title": "Archify | New Model…",
"icon": "$(new-folder)"
},
{
"command": "archify.newRegistry",
"title": "Archify | New Registry…",
"icon": "$(symbol-field)"
},
{ {
"command": "archify.newDiagram", "command": "archify.newDiagram",
"title": "Archify | New Diagram…", "title": "Archify | New Diagram…",
"icon": "$(new-file)" "icon": "$(new-file)"
}, },
{ {
"command": "archify.newModel", "command": "archify.newDecision",
"title": "Archify | New Model…", "title": "Archify | New Decision…",
"icon": "$(new-folder)" "icon": "$(law)"
}, },
{ {
"command": "archify.showPreview", "command": "archify.showPreview",
@@ -95,6 +108,11 @@
"command": "archify.openModel", "command": "archify.openModel",
"title": "Archify | Open Model", "title": "Archify | Open Model",
"icon": "$(symbol-structure)" "icon": "$(symbol-structure)"
},
{
"command": "archify.openModelGraph",
"title": "Archify | Open Model Graph",
"icon": "$(type-hierarchy)"
} }
], ],
"menus": { "menus": {
@@ -125,11 +143,15 @@
}, },
{ {
"command": "archify.showSource", "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", "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'"
},
{
"command": "archify.openModelGraph",
"when": "archify.isDiagramSource || archifyPreviewFocus || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision'"
} }
], ],
"editor/title": [ "editor/title": [
@@ -150,7 +172,7 @@
}, },
{ {
"command": "archify.showSource", "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" "group": "navigation"
}, },
{ {
@@ -165,40 +187,60 @@
}, },
{ {
"command": "archify.openModel", "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" "group": "navigation@60"
},
{
"command": "archify.openModelGraph",
"when": "archify.inModel && (archify.isDiagramSource || activeCustomEditorId == 'archify.diagram' || activeCustomEditorId == 'archify.registry' || activeCustomEditorId == 'archify.decision') && !notebookEditorFocused",
"group": "navigation@61"
} }
], ],
"explorer/context": [ "explorer/context": [
{ {
"command": "archify.newDiagram", "command": "archify.newModel",
"when": "explorerResourceIsFolder", "when": "explorerResourceIsFolder",
"group": "zzz_archify@1" "group": "zzz_archify@1"
}, },
{ {
"command": "archify.newModel", "command": "archify.newRegistry",
"when": "explorerResourceIsFolder", "when": "explorerResourceIsFolder",
"group": "zzz_archify@2" "group": "zzz_archify@2"
}, },
{ {
"command": "archify.showPreview", "command": "archify.newDiagram",
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "when": "explorerResourceIsFolder",
"group": "zzz_archify@3" "group": "zzz_archify@3"
}, },
{ {
"command": "archify.renderToHtml", "command": "archify.newDecision",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "when": "explorerResourceIsFolder",
"group": "zzz_archify@4" "group": "zzz_archify@4"
}, },
{
"command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.archify-registry|\\.archify-decision|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "zzz_archify@5"
},
{ {
"command": "archify.openHtmlViewer", "command": "archify.openHtmlViewer",
"when": "resourceExtname == .html", "when": "resourceExtname == .html",
"group": "zzz_archify@3" "group": "zzz_archify@6"
},
{
"command": "archify.renderToHtml",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "zzz_archify@7"
}, },
{ {
"command": "archify.openModel", "command": "archify.openModel",
"when": "resourceFilename =~ /\\.archify(-registry)?$/", "when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
"group": "zzz_archify@5" "group": "zzz_archify@8"
},
{
"command": "archify.openModelGraph",
"when": "resourceFilename =~ /\\.archify(-registry|-decision)?$/",
"group": "zzz_archify@9"
} }
], ],
"editor/title/context": [ "editor/title/context": [
@@ -210,12 +252,20 @@
], ],
"file/newFile": [ "file/newFile": [
{ {
"command": "archify.newDiagram", "command": "archify.newModel",
"group": "file" "group": "file@1"
}, },
{ {
"command": "archify.newModel", "command": "archify.newRegistry",
"group": "file" "group": "file@2"
},
{
"command": "archify.newDiagram",
"group": "file@3"
},
{
"command": "archify.newDecision",
"group": "file@4"
} }
] ]
}, },
@@ -254,6 +304,16 @@
], ],
"priority": "default" "priority": "default"
}, },
{
"viewType": "archify.decision",
"displayName": "Archify Decision",
"selector": [
{
"filenamePattern": "*.archify-decision"
}
],
"priority": "default"
},
{ {
"viewType": "archify.htmlViewer", "viewType": "archify.htmlViewer",
"displayName": "Archify HTML Viewer", "displayName": "Archify HTML Viewer",
@@ -381,7 +441,8 @@
"id": "yaml", "id": "yaml",
"extensions": [ "extensions": [
".archify", ".archify",
".archify-registry" ".archify-registry",
".archify-decision"
] ]
} }
], ],
@@ -393,6 +454,10 @@
{ {
"fileMatch": "*.archify-registry", "fileMatch": "*.archify-registry",
"url": "./schemas/archify-registry.schema.json" "url": "./schemas/archify-registry.schema.json"
},
{
"fileMatch": "*.archify-decision",
"url": "./schemas/archify-decision.schema.json"
} }
] ]
}, },
@@ -407,6 +472,8 @@
"test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs" "test:integration": "node esbuild.mjs && node esbuild.mjs --tests && node test/integration/run.mjs"
}, },
"devDependencies": { "devDependencies": {
"@types/cytoscape": "^3.21.9",
"@types/cytoscape-fcose": "^2.2.5",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/three": "^0.186.0", "@types/three": "^0.186.0",
"@types/vscode": "~1.100.0", "@types/vscode": "~1.100.0",
@@ -422,6 +489,8 @@
}, },
"homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme", "homepage": "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext#readme",
"dependencies": { "dependencies": {
"cytoscape": "^3.34.4",
"cytoscape-fcose": "^2.2.0",
"three": "^0.186.1", "three": "^0.186.1",
"yaml": "^2.9.1" "yaml": "^2.9.1"
} }
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Archify architecture decision",
"description": "One architecture decision of a model. Diagrams point at it with `decisions: [<id>]`.",
"type": "object",
"additionalProperties": false,
"required": [
"decision"
],
"properties": {
"schema_version": {
"const": 1
},
"decision": {
"type": "string",
"description": "What was decided."
},
"context": {
"type": "string",
"description": "The situation and what forced a decision."
},
"consequences": {
"type": "string",
"description": "What becomes easier, harder, or must change."
}
}
}
+24
View File
@@ -219,6 +219,14 @@
"diagram": { "diagram": {
"type": "string", "type": "string",
"description": "Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it." "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": [ "anyOf": [
@@ -332,6 +340,14 @@
}, },
"width": { "width": {
"$ref": "common.schema.json#/$defs/relationshipWidth" "$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." "description": "What the model covers; shown on the model page."
} }
} }
},
"decisions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
} }
} }
} }
+24
View File
@@ -180,6 +180,14 @@
}, },
"yOffset": { "yOffset": {
"type": "number" "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": { "width": {
"$ref": "common.schema.json#/$defs/relationshipWidth" "$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." "description": "What the model covers; shown on the model page."
} }
} }
},
"decisions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
} }
} }
} }
+24
View File
@@ -208,6 +208,14 @@
}, },
"yOffset": { "yOffset": {
"type": "number" "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": { "width": {
"$ref": "common.schema.json#/$defs/relationshipWidth" "$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." "description": "What the model covers; shown on the model page."
} }
} }
},
"decisions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
} }
} }
} }
+24
View File
@@ -144,6 +144,14 @@
}, },
"sources": { "sources": {
"$ref": "common.schema.json#/$defs/sourceReferences" "$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": { "note": {
"type": "string" "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." "description": "What the model covers; shown on the model page."
} }
} }
},
"decisions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Ids of architecture decisions (*.archify-decision files of the model) this is about. Shown in the Decisions panel under the diagram."
} }
} }
} }
+24
View File
@@ -331,6 +331,14 @@
}, },
"yOffset": { "yOffset": {
"type": "number" "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": { "width": {
"type": "number", "type": "number",
"minimum": 0.5 "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." "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": { "$defs": {
+39 -1
View File
@@ -25,11 +25,19 @@ const MODEL = {
}; };
const DIAGRAM_LINK = { type: 'string', description: 'Diagram that explains this element, relative to the model root (main.archify). Double-click the node to open it.' }; const 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)) { for (const file of fs.readdirSync(schemas)) {
if (!/^(architecture|workflow|sequence|dataflow|lifecycle)\.schema\.json$/.test(file)) continue; if (!/^(architecture|workflow|sequence|dataflow|lifecycle)\.schema\.json$/.test(file)) continue;
const schema = read(file); const schema = read(file);
schema.properties.model = MODEL; 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') { if (file === 'architecture.schema.json') {
const item = schema.properties.components.items; 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.' }; 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); write('archify-registry.schema.json', registry);
console.log('Patched editor schemas: model, ref, diagram; wrote archify-registry.schema.json');
const decision = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'Archify architecture decision',
description: 'One architecture decision of a model. Diagrams point at it with `decisions: [<id>]`.',
type: 'object',
additionalProperties: false,
required: ['decision'],
properties: {
schema_version: { const: 1 },
decision: {
type: 'object',
additionalProperties: false,
required: ['id', 'title'],
properties: {
id: { type: 'string', minLength: 1, description: 'Unique in the model. Any short string.' },
title: { type: 'string', minLength: 1 },
status: { enum: ['proposed', 'accepted', 'superseded', 'rejected'], description: 'A decision another one supersedes shows as superseded whatever this says.' },
date: { type: 'string', description: 'YYYY-MM-DD.' },
elements: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Registry element ids the decision is about.' },
diagrams: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Diagram paths (from the model root) the decision names.' },
supersedes: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Ids of older decisions this one replaces.' },
},
},
context: { type: 'string', description: 'The situation and what forced a decision.' },
decision: { type: 'string', description: 'What was decided.' },
consequences: { type: 'string', description: 'What becomes easier, harder, or must change.' },
},
};
write('archify-decision.schema.json', decision);
console.log('Patched editor schemas: model, ref, diagram, decisions; wrote archify-registry.schema.json and archify-decision.schema.json');
+3 -1
View File
@@ -3,6 +3,7 @@ import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
import { ModelService } from '../model/service'; import { ModelService } from '../model/service';
import { DiagramViewManager } from '../views/diagramView'; import { DiagramViewManager } from '../views/diagramView';
import { RegistryViewProvider } from '../views/registryView'; import { RegistryViewProvider } from '../views/registryView';
import { DecisionViewProvider } from '../views/decisionView';
/** What command handlers share: the extension context and the open views. */ /** What command handlers share: the extension context and the open views. */
export interface CommandContext { export interface CommandContext {
@@ -10,6 +11,7 @@ export interface CommandContext {
views: DiagramViewManager; views: DiagramViewManager;
htmlViewer: HtmlViewerProvider; htmlViewer: HtmlViewerProvider;
registry: RegistryViewProvider; registry: RegistryViewProvider;
decision: DecisionViewProvider;
models: ModelService; 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. */ /** 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 { export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
if (arg instanceof vscode.Uri) return arg; 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 { export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
+66 -1
View File
@@ -1,7 +1,10 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { MODEL_EXAMPLES, mainFromExample } from '../language/examples'; import { MODEL_EXAMPLES, mainFromExample } from '../language/examples';
import { diagramName, invalidNameReason } from '../language/templates'; import { diagramName, invalidNameReason } from '../language/templates';
import { MAIN_FILE } from '../model/format'; import { DECISION_EXT, slugify } from '../model/decisions';
import { MAIN_FILE, REGISTRY_EXT } from '../model/format';
import { REGISTRY_VIEW_TYPE } from '../views/registryView';
import { DECISION_VIEW_TYPE } from '../views/decisionView';
import { readText } from '../util/files'; import { readText } from '../util/files';
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView'; import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
import { CommandContext, exists, targetUri } from './context'; import { CommandContext, exists, targetUri } from './context';
@@ -30,6 +33,68 @@ 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); await vscode.commands.executeCommand('vscode.openWith', vscode.Uri.joinPath(root, MAIN_FILE), DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
}), }),
// Opens the model's Graph page: every element and connection of the model in one picture, with a query box.
vscode.commands.registerCommand('archify.openModelGraph', async (arg?: unknown) => {
const uri = targetUri(ctx, arg);
const root = uri ? await ctx.models.rootOf(uri) : undefined;
if (!root) {
vscode.window.showInformationMessage(`Archify: this file is not inside a model (no ${MAIN_FILE} in a folder above it).`);
return;
}
await ctx.views.showGraph(root);
}),
// Creates a registry file in the clicked folder and opens it.
vscode.commands.registerCommand('archify.newRegistry', async (arg?: unknown) => {
const folder = await parentFolder(arg);
if (!folder) return;
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
const name = await vscode.window.showInputBox({
title: 'Archify | New Registry',
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}${root ? '' : ' (not inside a model: add a main.archify above it so diagrams can refer to its elements)'}`,
value: 'elements',
validateInput: async (value) =>
invalidNameReason(value) ?? ((await exists(vscode.Uri.joinPath(folder, `${diagramName(value).replace(/\.archify-registry$/i, '')}${REGISTRY_EXT}`))) ? `${diagramName(value)}${REGISTRY_EXT} already exists.` : undefined),
});
if (name === undefined) return;
const target = vscode.Uri.joinPath(folder, `${diagramName(name).replace(/\.archify-registry$/i, '')}${REGISTRY_EXT}`);
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'registry.archify-registry'));
await vscode.workspace.fs.writeFile(target, Buffer.from(template, 'utf8'));
await vscode.commands.executeCommand('vscode.openWith', target, REGISTRY_VIEW_TYPE);
}),
// Creates a decision file in the clicked folder of a model and opens it.
vscode.commands.registerCommand('archify.newDecision', async (arg?: unknown) => {
const folder = await parentFolder(arg);
if (!folder) return;
const root = await ctx.models.rootOf(vscode.Uri.joinPath(folder, 'x'));
if (!root) {
vscode.window.showWarningMessage(`Archify: ${vscode.workspace.asRelativePath(folder, true)} is not inside a model (no ${MAIN_FILE} in it or a folder above it). Decisions belong to a model.`);
return;
}
const index = await ctx.models.index(root);
const title = await vscode.window.showInputBox({
title: 'Archify | New Decision',
prompt: `Title of the decision (file goes into ${vscode.workspace.asRelativePath(folder, true)})`,
placeHolder: 'OpenSearch for product search',
validateInput: async (value) => {
if (!value.trim()) return 'Enter a title.';
const id = slugify(value);
if (index.decisions.decisions.has(id)) return `A decision with id "${id}" already exists in this model.`;
if (await exists(vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`))) return `${id}${DECISION_EXT} already exists.`;
return undefined;
},
});
if (title === undefined) return;
const id = slugify(title);
const target = vscode.Uri.joinPath(folder, `${id}${DECISION_EXT}`);
const template = await readText(vscode.Uri.joinPath(ctx.extension.extensionUri, 'templates', 'decision.archify-decision'));
const today = new Date().toISOString().slice(0, 10);
const text = template.replace('__ID__', id).replace('__TITLE__', JSON.stringify(title.trim())).replace('__DATE__', today);
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
await vscode.commands.executeCommand('vscode.openWith', target, DECISION_VIEW_TYPE);
}),
// Creates a new model folder from one of the shipped examples and opens its model page. // Creates a new model folder from one of the shipped examples and opens its model page.
vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => { vscode.commands.registerCommand('archify.newModel', async (arg?: unknown) => {
const picked = await vscode.window.showQuickPick( const picked = await vscode.window.showQuickPick(
+6 -3
View File
@@ -1,8 +1,10 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { isDecisionFile } from '../model/decisions';
import { isRegistryFile } from '../model/format'; import { isRegistryFile } from '../model/format';
import { isArchifyFile } from '../sources/detect'; import { isArchifyFile } from '../sources/detect';
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView'; import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
import { REGISTRY_VIEW_TYPE } from '../views/registryView'; import { REGISTRY_VIEW_TYPE } from '../views/registryView';
import { DECISION_VIEW_TYPE } from '../views/decisionView';
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context'; import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
export function viewCommands(ctx: CommandContext): vscode.Disposable[] { export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
@@ -12,6 +14,7 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
if (!uri) return; if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri); if (isHtmlUri(uri)) return openHtmlViewer(uri);
if (isRegistryFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, REGISTRY_VIEW_TYPE); 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); 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); 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 }); await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
return; return;
} }
const registry = ctx.registry.activeUri; const page = ctx.registry.activeUri ?? ctx.decision.activeUri;
if (registry) { if (page) {
await vscode.commands.executeCommand('vscode.openWith', registry, 'default', vscode.ViewColumn.Beside); await vscode.commands.executeCommand('vscode.openWith', page, 'default', vscode.ViewColumn.Beside);
return; return;
} }
const html = ctx.htmlViewer.activeUri; const html = ctx.htmlViewer.activeUri;
+8
View File
@@ -13,6 +13,8 @@ import { log } from './util/log';
import { HtmlViewerProvider } from './views/htmlViewer'; import { HtmlViewerProvider } from './views/htmlViewer';
import { DiagramViewManager } from './views/diagramView'; import { DiagramViewManager } from './views/diagramView';
import { RegistryViewProvider } from './views/registryView'; import { RegistryViewProvider } from './views/registryView';
import { DecisionViewProvider } from './views/decisionView';
import { DECISION_SELECTOR, DecisionLanguage } from './language/decisionLanguage';
export function activate(context: vscode.ExtensionContext): void { export function activate(context: vscode.ExtensionContext): void {
const models = new ModelService(); const models = new ModelService();
@@ -21,20 +23,26 @@ export function activate(context: vscode.ExtensionContext): void {
views: new DiagramViewManager(context, models), views: new DiagramViewManager(context, models),
htmlViewer: new HtmlViewerProvider(context.extensionUri), htmlViewer: new HtmlViewerProvider(context.extensionUri),
registry: new RegistryViewProvider(models), registry: new RegistryViewProvider(models),
decision: new DecisionViewProvider(models),
models, models,
}; };
const refs = new RefLanguage(models); const refs = new RefLanguage(models);
const decisions = new DecisionLanguage(models);
context.subscriptions.push( context.subscriptions.push(
models, models,
ctx.views, ctx.views,
ctx.htmlViewer, ctx.htmlViewer,
ctx.registry, ctx.registry,
ctx.decision,
new DiagnosticsController(context, models), new DiagnosticsController(context, models),
log(), log(),
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'), vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
vscode.languages.registerCompletionItemProvider(REF_SELECTOR, refs, ' ', '"'), vscode.languages.registerCompletionItemProvider(REF_SELECTOR, refs, ' ', '"'),
vscode.languages.registerHoverProvider(REF_SELECTOR, refs), vscode.languages.registerHoverProvider(REF_SELECTOR, refs),
vscode.languages.registerDefinitionProvider(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), ...viewCommands(ctx),
...newDiagramCommands(ctx), ...newDiagramCommands(ctx),
...renderCommands(ctx), ...renderCommands(ctx),
+840
View File
@@ -0,0 +1,840 @@
import cytoscape, { Core, EdgeSingular, NodeSingular } from 'cytoscape';
import fcose from 'cytoscape-fcose';
import type { GraphEdge, GraphNode, ModelGraph } from '../model/graph';
import { QueryResult, runQuery } from '../model/graphQuery';
import { GRAPH_PAGE_STYLE } from './style';
import { layeredLayout } from './layout';
import type { ArchifyHost } from '../webview/host';
/**
* The Graph page of a model: the whole model as one graph, a query box that
* narrows it, a side panel for the selected element or connection. Runs in
* the webview; the extension hands the graph over through the bridge
* (`window.__archifyHost.graph`) and posts a new one when the model changes.
*/
type Host = ArchifyHost;
interface SavedView {
query?: string;
labels?: boolean;
hidden?: string[];
/** A theme chosen on the page; absent means follow VS Code. */
theme?: 'dark' | 'light';
/** Layered by type (default) or force-directed. */
layout?: 'layered' | 'force';
/** Show every decision and registry link, not only the selected element's. */
links?: boolean;
}
type Theme = 'dark' | 'light';
/** The theme in effect: the page's own choice, else VS Code's. */
function currentTheme(): Theme {
const chosen = document.documentElement.dataset.theme;
if (chosen === 'dark' || chosen === 'light') return chosen;
return document.body.classList.contains('vscode-light') ? 'light' : 'dark';
}
cytoscape.use(fcose);
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8', decision: '#e879f9', registry: '#cbd5e1' };
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database', 'decision', 'registry'];
const STATUS_COLORS: Record<string, string> = { accepted: '#34d399', proposed: '#fbbf24', rejected: '#fb7185', superseded: '#94a3b8' };
const colorOf = (type: string | undefined) => TYPE_COLORS[type ?? ''] ?? '#94a3b8';
/** An opaque mix of two `#rrggbb` colours (`amount` of the first), so nodes hide the connections behind them. */
function mix(front: string, back: string, amount: number): string {
const f = parseInt(front.slice(1), 16);
const b = parseInt(back.replace(/^#/, '').padEnd(6, '0').slice(0, 6), 16);
const channel = (shift: number) => Math.round(((f >> shift) & 255) * amount + ((b >> shift) & 255) * (1 - amount));
return `rgb(${channel(16)}, ${channel(8)}, ${channel(0)})`;
}
/** `#rrggbb` form of a CSS colour, read through the canvas so `rgb()` and names work too. */
function toHex(color: string): string {
if (/^#[0-9a-f]{6}$/i.test(color)) return color;
const canvas = document.createElement('canvas').getContext('2d');
if (!canvas) return '#000000';
canvas.fillStyle = color;
return /^#[0-9a-f]{6}$/i.test(canvas.fillStyle) ? canvas.fillStyle : '#000000';
}
const nodeColor = (n: GraphNode) => (n.kind === 'decision' ? STATUS_COLORS[n.status ?? ''] ?? TYPE_COLORS.decision : colorOf(n.type));
/** Edge labels are drawn by default up to this many connections; above it they clutter. */
const LABELS_UP_TO = 40;
const PLACEHOLDER = 'database · type:backend · diagram:0200-* · uses:api · near:api,2 · from:web to:db · decision:<id> · unused · -shared';
const HELP: [string, string][] = [
['database', 'elements whose id, label, sublabel, brand or type contain the word'],
['type:backend', 'elements of that type'],
['diagram:0200-*', 'what the diagrams matching the path draw (glob)'],
['decision:<id>', 'what a decision is about or is attached to'],
['uses:api', 'an element and its neighbours'],
['near:api,2', 'an element and everything within two steps'],
['from:web to:db', 'the shortest path between two elements'],
['unused', 'registry elements no diagram draws'],
['shared', 'registry elements only; -shared: inline elements only'],
['-worker', 'a leading minus excludes; terms combine with AND'],
];
function el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Theme colours Cytoscape needs as literals: the page's tokens, read again whenever the theme changes. */
function palette() {
const dark = currentTheme() === 'dark';
return {
bg: cssVar('--ag-bg', dark ? '#0b1020' : '#ffffff'),
fg: cssVar('--ag-fg', dark ? '#e5e7eb' : '#1f2937'),
muted: cssVar('--ag-muted', dark ? '#9ca3af' : '#6b7280'),
panel: cssVar('--ag-panel', dark ? '#131c31' : '#f3f4f6'),
accent: cssVar('--ag-accent', '#22d3ee'),
edge: dark ? '#64748b' : '#94a3b8',
font: cssVar('--ag-font', 'system-ui, sans-serif'),
};
}
class GraphPage {
cy!: Core;
private graph: ModelGraph;
private readonly nodesById = new Map<string, GraphNode>();
private readonly edgesById = new Map<string, GraphEdge>();
private saved: SavedView;
private queryTimer: number | undefined;
private result: QueryResult | undefined;
private readonly hiddenTypes: Set<string>;
private readonly root = el('div', 'ag');
private readonly nameEl = el('div', 'ag-name');
private readonly kickerEl = el('div', 'ag-kicker');
private readonly queryInput = el('input', 'ag-query');
private readonly countEl = el('span', 'ag-count');
private readonly labelsButton = el('button', 'ag-button', 'Labels');
private readonly linksButton = el('button', 'ag-button', 'Links');
private readonly layoutButton = el('button', 'ag-button', 'Layered');
private readonly themeButton = el('button', 'ag-button ag-theme');
private readonly legend = el('div', 'ag-legend');
private readonly side = el('aside', 'ag-side');
private readonly main = el('div', 'ag-main');
private readonly canvas = el('div', 'ag-canvas');
private readonly help = el('div', 'ag-help');
constructor(private readonly host: Host, initial: { graph: ModelGraph; query?: string }) {
this.graph = initial.graph;
this.saved = (host.getState().graphView as SavedView | undefined) ?? {};
if (initial.query !== undefined) this.saved.query = initial.query;
if (this.saved.labels === undefined) this.saved.labels = initial.graph.edges.length <= LABELS_UP_TO;
if (this.saved.links === undefined) this.saved.links = true;
this.hiddenTypes = new Set(this.saved.hidden ?? []);
this.applyTheme(this.saved.theme ?? archifyThemePreference());
this.index();
this.buildChrome();
this.buildGraph(true);
this.syncLabels();
this.renderLegend();
this.syncLayoutButton();
this.queryInput.value = this.saved.query ?? '';
this.applyQuery();
this.showEmptySide();
this.listen();
}
// ---------------------------------------------------------------- data
private index(): void {
this.nodesById.clear();
this.edgesById.clear();
for (const n of this.graph.nodes) this.nodesById.set(n.id, n);
for (const e of this.graph.edges) this.edgesById.set(e.id, e);
}
private elements() {
const shorten = (text: string, max: number) => {
if (text.length <= max) return text;
const cut = text.slice(0, max - 1);
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), max - 16))}\u2026`;
};
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.kind === 'decision' ? shorten(n.label, 60) : n.label, type: n.type ?? '', kind: n.kind, shared: n.shared ? 1 : 0, color: nodeColor(n) } }));
// In the layered layout a label sits at the end of the connection where fewer connections meet, so a
// fan-out labels at its targets and a fan-in at its sources; what still shares an end is staggered.
const outCount = new Map<string, number>();
const inCount = new Map<string, number>();
for (const e of this.graph.edges) {
if (e.kind !== 'connection') continue;
outCount.set(e.from, (outCount.get(e.from) ?? 0) + 1);
inCount.set(e.to, (inCount.get(e.to) ?? 0) + 1);
}
const used = new Map<string, number>();
const edges = this.graph.edges.map((e) => {
const at = e.kind === 'connection' && (outCount.get(e.from) ?? 0) > (inCount.get(e.to) ?? 0) ? 'target' : 'source';
const key = `${at}:${at === 'source' ? e.from : e.to}`;
const index = used.get(key) ?? 0;
used.set(key, index + 1);
return { group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / '), at, offset: 40 + (index % 3) * 34 } };
});
return [...nodes, ...edges];
}
/** A new graph from the extension: keep the positions of the elements that are still there. */
replace(graph: ModelGraph): void {
const positions = new Map<string, { x: number; y: number }>();
this.cy.nodes().forEach((n) => {
positions.set(n.id(), { ...n.position() });
});
const selected = this.cy.$(':selected').map((e) => e.id());
this.graph = graph;
this.index();
this.cy.elements().remove();
this.cy.add(this.elements());
let fresh = 0;
this.cy.nodes().forEach((n) => {
const at = positions.get(n.id());
if (at) n.position(at);
else fresh += 1;
});
if (fresh || this.saved.layout !== 'force') this.layout(false);
this.syncLinks();
this.renderLegend();
this.applyHidden();
this.applyQuery();
const again = this.cy.$(selected.map((id) => `#${CSS.escape(id)}`).join(','));
if (again.length) {
again.select();
this.showSide(again[0]);
} else this.showEmptySide();
this.kickerEl.textContent = `Model${graph.meta.id ? ` · ${graph.meta.id}` : ''}`;
this.nameEl.textContent = graph.meta.name || graph.meta.id || 'Untitled model';
}
// -------------------------------------------------------------- chrome
private buildChrome(): void {
const style = el('style');
style.textContent = GRAPH_PAGE_STYLE;
document.head.appendChild(style);
const top = el('header', 'ag-top');
const titles = el('div');
this.kickerEl.textContent = `Model${this.graph.meta.id ? ` · ${this.graph.meta.id}` : ''}`;
this.nameEl.textContent = this.graph.meta.name || this.graph.meta.id || 'Untitled model';
titles.append(this.kickerEl, this.nameEl);
const actions = el('div', 'ag-actions');
const sw = el('button', 'ag-switch');
sw.type = 'button';
sw.title = 'Back to the model page';
sw.setAttribute('aria-label', 'Graph view on; switch to the model page');
sw.append(el('span', 'ag-seg', 'Model'), el('span', 'ag-seg on', 'Graph'));
sw.addEventListener('click', () => this.host.post({ type: 'showModel' }));
const exportButton = el('button', 'ag-button ag-primary', 'Export PNG');
exportButton.type = 'button';
exportButton.title = 'Save the graph as it is on screen, as a PNG';
exportButton.addEventListener('click', () => this.exportPng());
this.themeButton.type = 'button';
this.themeButton.setAttribute('role', 'switch');
this.themeButton.append(el('span', 'ag-theme-track'), el('span', 'ag-theme-icon'), el('span', 'ag-sr', 'Dark theme'));
this.themeButton.addEventListener('click', () => this.setTheme(currentTheme() === 'dark' ? 'light' : 'dark'));
this.syncThemeButton();
actions.append(sw, this.themeButton, exportButton);
top.append(titles, actions);
const bar = el('div', 'ag-bar');
const wrap = el('div', 'ag-query-wrap');
this.queryInput.type = 'text';
this.queryInput.placeholder = PLACEHOLDER;
this.queryInput.spellcheck = false;
this.queryInput.setAttribute('aria-label', 'Query');
const clear = el('button', 'ag-clear', '×');
clear.type = 'button';
clear.title = 'Clear (Esc)';
clear.addEventListener('click', () => this.setQuery(''));
wrap.append(this.queryInput, clear);
const fit = el('button', 'ag-button', 'Fit');
fit.type = 'button';
fit.title = 'Fit the matching elements into view (0)';
fit.addEventListener('click', () => this.fit());
this.layoutButton.type = 'button';
this.layoutButton.addEventListener('click', () => {
this.saved.layout = this.saved.layout === 'force' ? 'layered' : 'force';
this.save();
this.layout(true);
this.syncLayoutButton();
});
this.linksButton.type = 'button';
this.linksButton.title = 'Links from decisions and registries to their elements: on for all, off for the selected element only';
this.linksButton.addEventListener('click', () => {
this.saved.links = !this.saved.links;
this.save();
this.syncLinks();
});
this.labelsButton.type = 'button';
this.labelsButton.title = 'Show or hide connection labels';
this.labelsButton.addEventListener('click', () => {
this.saved.labels = !this.saved.labels;
this.save();
this.syncLabels();
});
const helpButton = el('button', 'ag-button', '?');
helpButton.type = 'button';
helpButton.title = 'What can I type?';
helpButton.setAttribute('aria-expanded', 'false');
helpButton.addEventListener('click', () => {
this.help.hidden = !this.help.hidden;
helpButton.setAttribute('aria-expanded', String(!this.help.hidden));
});
bar.append(wrap, this.countEl, fit, this.layoutButton, this.labelsButton, this.linksButton, helpButton);
const cyHolder = el('div', 'ag-cy');
this.canvas.append(cyHolder, this.legend);
this.help.hidden = true;
this.help.appendChild(el('h3', '', 'Queries'));
const table = el('table');
for (const [example, meaning] of HELP) {
const row = el('tr');
const code = el('td', '', example);
code.title = 'Try it';
code.addEventListener('click', () => {
this.setQuery(example.includes('<id>') ? example.replace('<id>', this.graph.decisions[0]?.id ?? '') : example);
});
row.append(code, el('td', '', meaning));
table.appendChild(row);
}
this.help.appendChild(table);
this.help.appendChild(el('p', '', 'Matches stay bright, the rest dims. Connections show when both ends match. Click an element for its details; drag to move it; scroll to zoom. Press / to type a query. Layered puts clients on the left and data on the right, with registries before and decisions after; Links switches the dotted decision and registry links between all of them and the selected element\u2019s only.'));
this.main.append(this.canvas, this.side, this.help);
const head = el('div', 'ag-head');
head.append(top, bar);
this.root.append(head, this.main);
document.body.appendChild(this.root);
this.syncLabels();
}
private renderLegend(): void {
this.legend.textContent = '';
const counts = new Map<string, number>();
for (const n of this.graph.nodes) counts.set(n.type ?? 'other', (counts.get(n.type ?? 'other') ?? 0) + 1);
const types = [...counts.keys()].sort((a, b) => (TYPE_ORDER.indexOf(a) + 1 || 99) - (TYPE_ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b));
for (const type of types) {
const chip = el('button', 'ag-chip');
chip.type = 'button';
chip.style.setProperty('--c', colorOf(type));
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
chip.title = `Show or hide ${type} elements`;
chip.append(document.createTextNode(type), el('small', '', String(counts.get(type))));
chip.addEventListener('click', () => {
if (this.hiddenTypes.has(type)) this.hiddenTypes.delete(type);
else this.hiddenTypes.add(type);
chip.setAttribute('aria-pressed', String(!this.hiddenTypes.has(type)));
this.saved.hidden = [...this.hiddenTypes];
this.save();
this.applyHidden();
this.updateCount();
});
this.legend.appendChild(chip);
}
}
// --------------------------------------------------------------- graph
private buildGraph(first: boolean): void {
const holder = this.canvas.firstElementChild as HTMLElement;
this.cy = cytoscape({
container: holder,
elements: this.elements(),
style: this.stylesheet(),
wheelSensitivity: 0.2,
minZoom: 0.1,
maxZoom: 4,
});
this.applyHidden();
this.syncLinks();
this.layout(first);
this.cy.on('tap', 'node, edge', (event) => this.showSide(event.target as NodeSingular | EdgeSingular));
this.cy.on('tap', (event) => {
if (event.target === this.cy) this.showEmptySide();
});
// Hover lights up an element and its connections; selection also reveals its decision and registry links.
this.cy.on('mouseover', 'node', (event) => {
const node = event.target as NodeSingular;
node.addClass('hover');
node.connectedEdges('[kind = "connection"]').addClass('edge-hover');
});
this.cy.on('mouseout', 'node', (event) => {
const node = event.target as NodeSingular;
node.removeClass('hover');
node.connectedEdges().removeClass('edge-hover');
});
this.cy.on('select unselect', 'node', () => this.syncLinks());
}
/** Decision and registry links: all of them when Links is on, else those of the selected nodes. */
private syncLinks(): void {
const all = Boolean(this.saved.links);
this.linksButton.setAttribute('aria-pressed', String(all));
const meta = this.cy.edges('[kind != "connection"]');
meta.removeClass('shown');
if (all) meta.addClass('shown');
else this.cy.nodes(':selected').connectedEdges('[kind != "connection"]').addClass('shown');
}
private syncLayoutButton(): void {
const force = this.saved.layout === 'force';
this.layoutButton.textContent = force ? 'Force' : 'Layered';
this.layoutButton.title = force ? 'Force-directed layout; click for the layered one (clients left, data right)' : 'Layered by type; click for a force-directed layout';
}
private stylesheet(): cytoscape.StylesheetJson {
const p = palette();
const dark = currentTheme() === 'dark';
const bg = toHex(p.bg);
const fill = (amount: number) => (n: NodeSingular) => mix(String(n.data('color')), bg, amount);
return [
{ selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(240, Math.max(110, String(n.data('label')).length * 7.6 + 40)), height: 46, 'background-color': fill(dark ? 0.16 : 0.1), 'border-width': 1.5, 'border-color': 'data(color)', 'border-opacity': 0.9, label: 'data(label)', color: p.fg, 'font-size': 13, 'font-weight': 600, 'font-family': p.font, 'text-valign': 'center', 'text-halign': 'center', 'text-wrap': 'ellipsis', 'text-max-width': '210px', 'overlay-opacity': 0, 'underlay-color': 'data(color)', 'underlay-padding': 6, 'underlay-opacity': 0, 'underlay-shape': 'round-rectangle', 'transition-property': 'underlay-opacity, border-width', 'transition-duration': 120 } },
{ selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } },
{ selector: 'node[kind = "decision"]', style: { shape: 'round-tag', height: (n: NodeSingular) => (String(n.data('label')).length > 30 ? 44 : 34), 'font-size': 11, 'font-weight': 500, 'background-color': fill(dark ? 0.12 : 0.08), 'text-wrap': 'wrap', 'text-max-width': '196px', 'line-height': 1.2, width: (n: NodeSingular) => Math.min(244, Math.max(120, String(n.data('label')).length * 6.4 + 48)) } },
{ selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 32, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1, 'border-color': p.muted, 'background-color': mix(dark ? '#94a3b8' : '#64748b', bg, 0.08), width: (n: NodeSingular) => Math.min(220, Math.max(110, String(n.data('label')).length * 6.8 + 36)) } },
{ selector: 'node.hover', style: { 'underlay-opacity': dark ? 0.22 : 0.16, 'border-width': 2, 'background-color': fill(dark ? 0.28 : 0.18) } },
{ selector: 'edge', style: { width: 1.4, 'line-color': p.edge, 'line-opacity': 0.85, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.8, 'curve-style': 'bezier', label: '', 'font-size': 10, 'font-family': p.font, color: p.muted, 'text-background-color': p.bg, 'text-background-opacity': 1, 'text-background-padding': '3px', 'text-background-shape': 'round-rectangle', 'text-rotation': 'autorotate', 'transition-property': 'width, line-color', 'transition-duration': 120 } },
{ selector: 'edge.ortho', style: { 'curve-style': 'taxi', 'taxi-direction': 'auto', 'taxi-turn': '45%', 'taxi-turn-min-distance': 24, 'text-rotation': 'none', 'source-text-offset': 'data(offset)', 'source-text-rotation': 'none', 'source-text-background-color': p.bg, 'source-text-background-opacity': 1, 'source-text-background-padding': '3px', 'source-text-background-shape': 'round-rectangle', 'target-text-offset': 'data(offset)', 'target-text-rotation': 'none', 'target-text-background-color': p.bg, 'target-text-background-opacity': 1, 'target-text-background-padding': '3px', 'target-text-background-shape': 'round-rectangle' } },
{ selector: 'edge.edge-hover', style: { width: 2.4, 'line-color': p.fg, 'target-arrow-color': p.fg, 'line-opacity': 1, color: p.fg } },
{ selector: 'edge[kind != "connection"]', style: { display: 'none', width: 1, 'curve-style': 'bezier', 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.55 } },
{ selector: 'edge[kind = "supersedes"]', style: { 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-shape': 'triangle', 'target-arrow-color': TYPE_COLORS.decision } },
{ selector: 'edge[kind != "connection"].shown', style: { display: 'element' } },
{ selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } },
{ selector: 'edge.labelled.ortho[kind = "connection"][at = "source"]', style: { label: '', 'source-label': 'data(label)' } },
{ selector: 'edge.labelled.ortho[kind = "connection"][at = "target"]', style: { label: '', 'target-label': 'data(label)' } },
{ selector: '.dim', style: { opacity: 0.12, events: 'no' } },
{ selector: 'node.hit', style: { 'border-width': 2.5 } },
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg, 'line-opacity': 1 } },
{ selector: 'node:selected', style: { 'border-color': p.accent, 'border-width': 2.5, 'underlay-color': p.accent, 'underlay-opacity': dark ? 0.28 : 0.18, 'background-color': fill(dark ? 0.3 : 0.2) } },
{ selector: 'edge:selected', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, 'line-opacity': 1 } },
{ selector: '.hidden', style: { display: 'none' } },
] as unknown as cytoscape.StylesheetJson;
}
private layout(fresh: boolean): void {
const force = this.saved.layout === 'force';
this.cy.edges().toggleClass('ortho', !force);
if (force) {
this.cy
.elements()
.not('.hidden')
.layout({ name: 'fcose', animate: false, randomize: fresh, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 150 : 110), edgeElasticity: (e: EdgeSingular) => (e.data('kind') === 'connection' ? 0.2 : 0.08), gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
.run();
return;
}
const positions = layeredLayout(this.graph.nodes, this.graph.edges);
this.cy.batch(() => {
this.cy.nodes().forEach((n) => {
const at = positions.get(n.id());
if (at) n.position(at);
});
});
this.cy.fit(this.cy.elements().not('.hidden'), 40);
}
// --------------------------------------------------------------- theme
/** Sets the page's own theme (or follows VS Code when undefined) without touching the saved choice. */
private applyTheme(theme: Theme | undefined): void {
if (theme) document.documentElement.dataset.theme = theme;
else delete document.documentElement.dataset.theme;
}
private setTheme(theme: Theme): void {
this.applyTheme(theme);
this.saved.theme = theme;
this.save();
try {
localStorage.setItem('archify-theme', theme); // the diagram pages read the same key
} catch {
// storage may be unavailable; the page state keeps the choice
}
this.restyle();
}
private syncThemeButton(): void {
const dark = currentTheme() === 'dark';
this.themeButton.dataset.on = dark ? 'dark' : 'light';
this.themeButton.setAttribute('aria-checked', String(dark));
this.themeButton.title = dark ? 'Dark theme on; switch to light' : 'Light theme on; switch to dark';
this.themeButton.setAttribute('aria-label', this.themeButton.title);
}
private restyle(): void {
this.cy?.style(this.stylesheet());
this.syncLabels();
this.syncThemeButton();
}
private applyHidden(): void {
this.cy.nodes().forEach((n) => {
n.toggleClass('hidden', this.hiddenTypes.has(String(n.data('type') || 'other')));
});
}
private syncLabels(): void {
const on = Boolean(this.saved.labels);
this.labelsButton.setAttribute('aria-pressed', String(on));
this.cy?.edges().toggleClass('labelled', on);
}
// --------------------------------------------------------------- query
private setQuery(text: string): void {
this.queryInput.value = text;
this.applyQuery();
this.queryInput.focus();
}
private applyQuery(): void {
const text = this.queryInput.value;
this.saved.query = text;
this.save();
this.result = runQuery(this.graph, text);
const { nodes, edges, path } = this.result;
const empty = !text.trim();
this.cy.batch(() => {
this.cy.elements().removeClass('dim hit path');
if (empty) return;
this.cy.nodes().forEach((n) => {
n.toggleClass('dim', !nodes.has(n.id())).toggleClass('hit', nodes.has(n.id()));
});
this.cy.edges().forEach((e) => {
e.toggleClass('dim', !edges.has(e.id()));
});
if (path) for (const id of edges) this.cy.$id(id).addClass('path');
});
this.updateCount();
}
private updateCount(): void {
const total = this.cy.nodes('[kind = "element"]').not('.hidden').length;
const r = this.result;
if (!r || !this.queryInput.value.trim()) {
this.countEl.textContent = `${total} elements · ${this.cy.edges('[kind = "connection"]').not('.hidden').length} connections`;
this.countEl.classList.remove('is-error');
return;
}
const shown = this.cy.nodes('.hit[kind = "element"]').not('.hidden').length;
this.countEl.textContent = r.message ? r.message : r.path ? `path of ${r.path.length} elements` : `${shown} of ${total} elements`;
this.countEl.classList.toggle('is-error', Boolean(r.message));
}
private fit(): void {
const hits = this.cy.nodes('.hit').not('.hidden');
const target = hits.length ? hits.union(hits.connectedEdges().not('.dim')) : this.cy.elements().not('.hidden');
this.cy.animate({ fit: { eles: target, padding: 40 }, duration: 250 });
}
// ---------------------------------------------------------------- side
private showEmptySide(): void {
this.side.textContent = '';
this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.'));
const h3 = el('h3', '', 'This model');
this.side.appendChild(h3);
const elements = this.graph.nodes.filter((n) => n.kind === 'element');
const registries = this.graph.nodes.filter((n) => n.kind === 'registry');
const summary = el('p', '', `${elements.length} elements, ${this.graph.edges.filter((e) => e.kind === 'connection').length} connections, ${this.graph.diagrams.length} diagrams, ${registries.length} ${registries.length === 1 ? 'registry' : 'registries'}, ${this.graph.decisions.length} decisions.`);
this.side.appendChild(summary);
const unused = elements.filter((n) => n.shared && !n.diagrams.length).length;
if (unused) {
const hint = el('button', 'ag-link', `${unused} unused registry element${unused === 1 ? '' : 's'}`);
hint.type = 'button';
hint.addEventListener('click', () => this.setQuery('unused'));
this.side.appendChild(hint);
}
}
private showSide(target: NodeSingular | EdgeSingular): void {
if (!target.isNode()) {
this.showEdge(this.edgesById.get(target.id()));
return;
}
const node = this.nodesById.get(target.id());
if (node?.kind === 'decision') this.showDecision(node);
else if (node?.kind === 'registry') this.showRegistry(node);
else this.showNode(node);
}
private selectNode(id: string): void {
const n = this.cy.$id(id);
this.cy.elements().unselect();
n.select();
this.showSide(n as NodeSingular);
}
private elementLinks(ids: string[]): HTMLElement {
const holder = el('div');
for (const id of ids) {
const n = this.nodesById.get(id);
if (!n) continue;
const button = this.link(n.label, `Show ${n.id}`, () => this.selectNode(n.id));
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', nodeColor(n));
button.prepend(dot);
holder.appendChild(button);
}
return holder;
}
/** Side panel of a decision node: status, text, what it is about, and the file. */
private showDecision(node: GraphNode): void {
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', nodeColor(node));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', ['decision', node.sublabel, node.status].filter(Boolean).join(' · ')));
if (node.description) this.side.appendChild(el('p', '', node.description));
const actions = el('div', 'ag-side-actions');
if (node.file) actions.appendChild(this.link('Open decision', `Open ${node.file}`, () => this.host.post({ type: 'openDecision', file: node.file })));
if (node.sublabel) actions.appendChild(this.link('What it touches', `Show everything decision ${node.sublabel} is about`, () => this.setQuery(`decision:${node.sublabel}`)));
this.side.appendChild(actions);
const about = this.graph.edges.filter((e) => e.kind === 'decides' && e.from === node.id).map((e) => e.to);
this.side.appendChild(el('h3', '', `About ${about.length} element${about.length === 1 ? '' : 's'}`));
this.side.appendChild(about.length ? this.elementLinks(about) : el('p', 'ag-empty', 'Names no element of this model.'));
const onEdges = this.graph.edges.filter((e) => e.kind === 'connection' && node.sublabel && e.decisions.includes(node.sublabel));
if (onEdges.length) {
this.side.appendChild(el('h3', '', 'On connections'));
const holder = el('div');
for (const e of onEdges) holder.appendChild(this.link(`${this.nodesById.get(e.from)?.label ?? e.from} → ${this.nodesById.get(e.to)?.label ?? e.to}`, e.id, () => { this.cy.elements().unselect(); this.cy.$id(e.id).select(); this.showEdge(e); }));
this.side.appendChild(holder);
}
if (node.diagrams.length) {
this.side.appendChild(el('h3', '', 'Diagrams it names'));
this.side.appendChild(this.diagramLinks(node.diagrams));
}
const related = this.graph.edges.filter((e) => e.kind === 'supersedes' && (e.from === node.id || e.to === node.id));
if (related.length) {
this.side.appendChild(el('h3', '', 'Related decisions'));
const holder = el('div');
for (const e of related) {
const otherId = e.from === node.id ? e.to : e.from;
const other = this.nodesById.get(otherId);
if (other) holder.appendChild(this.link(`${e.from === node.id ? 'supersedes' : 'superseded by'} ${other.label}`, other.sublabel ?? otherId, () => this.selectNode(otherId)));
}
this.side.appendChild(holder);
}
}
/** Side panel of a registry node: the file and the elements it defines. */
private showRegistry(node: GraphNode): void {
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', nodeColor(node));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', `registry · ${node.file ?? ''}`));
const actions = el('div', 'ag-side-actions');
if (node.file) actions.appendChild(this.link('Open registry', `Open ${node.file}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: 0 })));
this.side.appendChild(actions);
const ids = node.elements ?? [];
this.side.appendChild(el('h3', '', `Defines ${ids.length} element${ids.length === 1 ? '' : 's'}`));
this.side.appendChild(ids.length ? this.elementLinks(ids) : el('p', 'ag-empty', 'No elements yet.'));
const unused = ids.filter((id) => !(this.nodesById.get(id)?.diagrams.length));
if (unused.length) {
this.side.appendChild(el('h3', '', `${unused.length} not drawn by any diagram`));
this.side.appendChild(this.elementLinks(unused));
}
}
private link(text: string, title: string, onClick?: () => void, className = ''): HTMLButtonElement {
const button = el('button', `ag-link ${className}${onClick ? '' : ' is-static'}`);
button.type = 'button';
button.title = title;
button.appendChild(document.createTextNode(text));
if (onClick) button.addEventListener('click', onClick);
return button;
}
private decisionLinks(ids: string[]): HTMLElement {
const holder = el('div');
for (const id of ids) {
const d = this.graph.decisions.find((x) => x.id === id);
const button = this.link('', d ? `${d.title ?? d.id} (${d.status})` : id, d ? () => this.host.post({ type: 'openDecision', file: d.file }) : undefined, `is-${d?.status ?? 'unknown'}`);
button.prepend(el('span', 'ag-dot'));
button.appendChild(document.createTextNode([id, d?.title, d?.status].filter(Boolean).join(' · ')));
holder.appendChild(button);
}
return holder;
}
private diagramLinks(paths: string[]): HTMLElement {
const holder = el('div');
for (const path of paths) {
const d = this.graph.diagrams.find((x) => x.path === path);
holder.appendChild(this.link(path.replace(/\.archify$/, ''), d?.title ? `Open ${d.title} (${path})` : `Open ${path}`, () => this.host.post({ type: 'openDiagram', path })));
}
return holder;
}
private showNode(node: GraphNode | undefined): void {
if (!node) return;
this.side.textContent = '';
const title = el('h2');
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', colorOf(node.type));
title.append(dot, document.createTextNode(node.label));
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', [node.id, node.type, node.sublabel, node.brand ? `brand ${node.brand}` : undefined, node.shared ? 'registry' : 'inline'].filter(Boolean).join(' · ')));
this.side.appendChild(node.description ? el('p', '', node.description) : el('p', 'ag-empty', node.shared ? 'No description in the registry.' : 'Inline element: defined in the diagram, not in a registry.'));
const actions = el('div', 'ag-side-actions');
actions.appendChild(this.link('Neighbours', `Show ${node.id} and everything connected to it`, () => this.setQuery(`uses:${node.id}`)));
if (node.file) actions.appendChild(this.link('Registry', `Open ${node.file} at line ${(node.line ?? 0) + 1}`, () => this.host.post({ type: 'openRegistry', file: node.file, line: node.line ?? 0 })));
if (node.link) actions.appendChild(this.link('Explained by', `Open ${node.link}`, () => this.host.post({ type: 'openDiagram', path: node.link })));
this.side.appendChild(actions);
this.side.appendChild(el('h3', '', `Used in ${node.diagrams.length} diagram${node.diagrams.length === 1 ? '' : 's'}`));
this.side.appendChild(node.diagrams.length ? this.diagramLinks(node.diagrams) : el('p', 'ag-empty', 'Not drawn by any diagram yet.'));
const connected = this.graph.edges.filter((e) => e.kind === 'connection' && (e.from === node.id || e.to === node.id));
this.side.appendChild(el('h3', '', `${connected.length} connection${connected.length === 1 ? '' : 's'}`));
const list = el('div');
for (const e of connected) {
const other = e.from === node.id ? e.to : e.from;
const otherLabel = this.nodesById.get(other)?.label ?? other;
const text = e.from === node.id ? `→ ${otherLabel}` : `← ${otherLabel}`;
list.appendChild(this.link(e.labels.length ? `${text} · ${e.labels.join(' / ')}` : text, `${e.from} → ${e.to}`, () => {
const edge = this.cy.$id(e.id);
this.cy.elements().unselect();
edge.select();
this.showEdge(e);
}));
}
this.side.appendChild(list);
if (node.decisions.length) {
this.side.appendChild(el('h3', '', 'Decisions'));
this.side.appendChild(this.decisionLinks(node.decisions));
}
}
private showEdge(edge: GraphEdge | undefined): void {
if (!edge) return;
this.side.textContent = '';
const from = this.nodesById.get(edge.from);
const to = this.nodesById.get(edge.to);
const title = el('h2');
title.textContent = `${from?.label ?? edge.from} → ${to?.label ?? edge.to}`;
this.side.appendChild(title);
this.side.appendChild(el('div', 'ag-id', `${edge.from} → ${edge.to}${edge.labels.length ? ` · ${edge.labels.join(' / ')}` : ''}`));
const ends = el('div', 'ag-side-actions');
for (const end of [from, to]) {
if (!end) continue;
const button = this.link(end.label, `Show ${end.id}`, () => {
const n = this.cy.$id(end.id);
this.cy.elements().unselect();
n.select();
this.showNode(end);
});
const dot = el('span', 'ag-dot');
dot.style.setProperty('--c', colorOf(end.type));
button.prepend(dot);
ends.appendChild(button);
}
this.side.appendChild(ends);
this.side.appendChild(el('h3', '', `Drawn in ${edge.diagrams.length} diagram${edge.diagrams.length === 1 ? '' : 's'}`));
this.side.appendChild(this.diagramLinks(edge.diagrams));
if (edge.decisions.length) {
this.side.appendChild(el('h3', '', 'Decisions'));
this.side.appendChild(this.decisionLinks(edge.decisions));
}
}
// -------------------------------------------------------------- export
private exportPng(): void {
const blob = this.cy.png({ output: 'blob', full: true, scale: 2, bg: palette().bg });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${this.graph.meta.id || 'model'}-graph.png`;
anchor.hidden = true;
document.body.appendChild(anchor);
anchor.click(); // the bridge turns the download into a VS Code save dialog
setTimeout(() => {
anchor.remove();
URL.revokeObjectURL(url);
}, 1000);
}
// -------------------------------------------------------------- events
private listen(): void {
this.queryInput.addEventListener('input', () => {
window.clearTimeout(this.queryTimer);
this.queryTimer = window.setTimeout(() => this.applyQuery(), 120);
});
this.queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
window.clearTimeout(this.queryTimer);
this.applyQuery();
this.fit();
} else if (event.key === 'Escape') {
if (this.queryInput.value) this.setQuery('');
else this.queryInput.blur();
}
});
window.addEventListener('keydown', (event) => {
const target = event.target as HTMLElement | null;
const typing = target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
if (event.key === '/') {
event.preventDefault();
this.queryInput.focus();
this.queryInput.select();
} else if (event.key === '0') this.fit();
else if (event.key === 'Escape') {
this.cy.elements().unselect();
this.showEmptySide();
this.help.hidden = true;
}
});
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
this.cy.resize();
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => this.cy.fit(this.cy.elements().not('.hidden'), 40), 150);
});
new MutationObserver(() => this.restyle()).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('message', (event) => {
const message = event.data || {};
if (message.type === 'graph' && message.graph) this.replace(message.graph as ModelGraph);
if (message.type === 'query' && typeof message.query === 'string') this.setQuery(message.query);
});
}
private save(): void {
this.host.setState({ graphView: { query: this.saved.query, labels: this.saved.labels, hidden: this.saved.hidden ?? [], theme: this.saved.theme, layout: this.saved.layout, links: this.saved.links } });
}
}
/** The theme the Archify diagram pages last used, when they stored one. */
function archifyThemePreference(): Theme | undefined {
try {
const value = localStorage.getItem('archify-theme');
return value === 'dark' || value === 'light' ? value : undefined;
} catch {
return undefined;
}
}
function start(): void {
const host = window.__archifyHost;
if (!host?.graph) return;
// Exposed for the integration harness; nothing in the page depends on it.
(window as unknown as { __archifyGraphPage?: GraphPage }).__archifyGraphPage = new GraphPage(host, host.graph);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
+88
View File
@@ -0,0 +1,88 @@
import type { GraphEdge, GraphNode } from '../model/graph';
/**
* Layered layout: a column per element type in Archify's stack order
* (clients on the left, data on the right), empty columns left out, rows
* ordered to shorten the connections; registries in a row above the
* columns, decisions in a row below. Deterministic, so the picture is stable.
*/
const COLUMN_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'other', 'messagebus', 'database'];
const STATUS_ORDER = ['accepted', 'proposed', 'superseded', 'rejected'];
export interface LayoutOptions {
columnGap: number;
rowGap: number;
}
export type Positions = Map<string, { x: number; y: number }>;
function columnOf(node: GraphNode): number {
if (node.kind === 'registry') return -1;
if (node.kind === 'decision') return COLUMN_ORDER.length;
const i = COLUMN_ORDER.indexOf(node.type ?? 'other');
return i < 0 ? COLUMN_ORDER.indexOf('other') : i;
}
export function layeredLayout(nodes: GraphNode[], edges: GraphEdge[], options: LayoutOptions = { columnGap: 230, rowGap: 84 }): Positions {
const columns = new Map<number, GraphNode[]>();
const registries = nodes.filter((n) => n.kind === 'registry').sort((a, b) => a.label.localeCompare(b.label));
const decisions = nodes.filter((n) => n.kind === 'decision').sort((a, b) => STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label));
for (const n of nodes) if (n.kind === 'element') columns.set(columnOf(n), [...(columns.get(columnOf(n)) ?? []), n]);
const order = new Map<string, number>();
const neighbours = new Map<string, string[]>();
for (const e of edges) {
if (e.kind === 'supersedes') continue;
neighbours.set(e.from, [...(neighbours.get(e.from) ?? []), e.to]);
neighbours.set(e.to, [...(neighbours.get(e.to) ?? []), e.from]);
}
// Start from a stable order: elements by label, decisions by status, registries by file.
for (const [col, list] of columns) {
list.sort((a, b) => {
if (a.kind === 'decision' && b.kind === 'decision') return STATUS_ORDER.indexOf(a.status ?? '') - STATUS_ORDER.indexOf(b.status ?? '') || a.label.localeCompare(b.label);
return a.label.localeCompare(b.label);
});
list.forEach((n, i) => order.set(n.id, i));
columns.set(col, list);
}
// Barycenter sweeps: each node moves towards the average row of what it is connected to.
const keys = [...columns.keys()].sort((a, b) => a - b);
for (let sweep = 0; sweep < 4; sweep += 1) {
const sequence = sweep % 2 === 0 ? keys : [...keys].reverse();
for (const col of sequence) {
const list = columns.get(col)!;
const weight = new Map<string, number>();
for (const n of list) {
const rows = (neighbours.get(n.id) ?? []).map((id) => order.get(id)).filter((r): r is number => r !== undefined);
weight.set(n.id, rows.length ? rows.reduce((a, b) => a + b, 0) / rows.length : order.get(n.id)!);
}
list.sort((a, b) => weight.get(a.id)! - weight.get(b.id)! || order.get(a.id)! - order.get(b.id)!);
list.forEach((n, i) => order.set(n.id, i));
}
}
const positions: Positions = new Map();
const tallest = Math.max(1, ...[...columns.values()].map((list) => list.length));
// Empty columns are left out, so the picture is as wide as the types the model uses.
const used = keys.filter((col) => columns.get(col)!.length);
used.forEach((col, index) => {
const list = columns.get(col)!;
const offset = ((tallest - list.length) * options.rowGap) / 2;
list.forEach((n, i) => positions.set(n.id, { x: index * options.columnGap, y: offset + i * options.rowGap }));
});
const width = Math.max(0, used.length - 1) * options.columnGap;
// Registries and decisions sit above or below the elements they link to: at the mean x of those
// elements, pushed apart only as far as needed so they do not overlap.
const spread = (list: GraphNode[], y: number, gap: number) => {
const wanted = list.map((n) => {
const xs = (neighbours.get(n.id) ?? []).map((id) => positions.get(id)?.x).filter((x): x is number => x !== undefined);
return { n, x: xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : width / 2 };
});
wanted.sort((a, b) => a.x - b.x || a.n.label.localeCompare(b.n.label));
for (let i = 1; i < wanted.length; i += 1) wanted[i].x = Math.max(wanted[i].x, wanted[i - 1].x + gap);
for (let i = wanted.length - 2; i >= 0; i -= 1) wanted[i].x = Math.min(wanted[i].x, wanted[i + 1].x - gap);
for (const { n, x } of wanted) positions.set(n.id, { x, y });
};
spread(registries, -options.rowGap * 1.6, 200);
spread(decisions, tallest * options.rowGap + options.rowGap * 0.6, 250);
return positions;
}
+94
View File
@@ -0,0 +1,94 @@
/** Page styles of the graph view. VS Code's theme variables, Archify's type colours. */
export const GRAPH_PAGE_STYLE = `
:root { --ag-bg: var(--vscode-editor-background, #0b1020); --ag-fg: var(--vscode-foreground, #e5e7eb); --ag-muted: var(--vscode-descriptionForeground, #9ca3af);
--ag-panel: var(--vscode-editorWidget-background, #111a2e); --ag-border: var(--vscode-editorWidget-border, rgba(128,128,128,.35)); --ag-accent: var(--vscode-textLink-foreground, #22d3ee);
--ag-input: var(--vscode-input-background, #1f2937); --ag-input-fg: var(--vscode-input-foreground, #e5e7eb); --ag-input-border: var(--vscode-input-border, var(--ag-border));
--ag-error: var(--vscode-errorForeground, #f87171); --ag-font: var(--vscode-font-family, system-ui, sans-serif); --ag-mono: var(--vscode-editor-font-family, ui-monospace, Menlo, monospace);
--ag-frontend: #22d3ee; --ag-backend: #34d399; --ag-database: #a78bfa; --ag-cloud: #fbbf24; --ag-security: #fb7185; --ag-messagebus: #fb923c; --ag-external: #94a3b8; --ag-decision: #e879f9; --ag-registry: #cbd5e1; }
/* A theme chosen on the page overrides VS Code's; without one the VS Code variables above apply. */
:root[data-theme="dark"] { --ag-bg: #0b1020; --ag-fg: #e5e7eb; --ag-muted: #9ca3af; --ag-panel: #111a2e; --ag-border: rgba(148, 163, 184, 0.28); --ag-accent: #22d3ee; --ag-input: #0f172a; --ag-input-fg: #e5e7eb; --ag-input-border: rgba(148, 163, 184, 0.28); --ag-error: #f87171; color-scheme: dark; }
:root[data-theme="light"] { --ag-bg: #ffffff; --ag-fg: #0f172a; --ag-muted: #64748b; --ag-panel: #f8fafc; --ag-border: rgba(15, 23, 42, 0.14); --ag-accent: #0e7490; --ag-input: #ffffff; --ag-input-fg: #0f172a; --ag-input-border: rgba(15, 23, 42, 0.2); --ag-error: #b91c1c; --ag-external: #64748b; --ag-registry: #94a3b8; color-scheme: light; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; }
:root { --ag-gutter: 1.5rem; }
.ag { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100%; }
/* One head block: title row and query row share a background and a single rule below. */
.ag-head { background: color-mix(in srgb, var(--ag-panel) 55%, var(--ag-bg)); border-bottom: 1px solid var(--ag-border); }
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 1.1rem var(--ag-gutter) 0.6rem; }
.ag-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ag-accent); margin-bottom: 0.2rem; }
.ag-name { font-weight: 700; font-size: 1.25rem; line-height: 1.2; letter-spacing: -0.015em; }
.ag-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; }
.ag-bar .ag-button { min-height: 2rem; }
/* Toolbar controls follow Archify's: quiet pills (0.5rem) inside 0.5625rem buttons, one filled primary action (0.6875rem), the 2D | 3D switch's twin. */
.ag-switch { position: relative; isolation: isolate; display: inline-flex; padding: 0 0.25rem; border: 0; border-radius: 0.5625rem; background: transparent; color: var(--ag-muted); font: inherit; cursor: pointer; }
.ag-switch::before { content: ""; position: absolute; z-index: -1; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
.ag-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
.ag-switch:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: -4px; }
.ag-seg { display: inline-flex; align-items: center; height: 1.75rem; margin: 0.25rem 0; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; }
.ag-seg.on { color: var(--ag-bg); background: var(--ag-fg); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
.ag-switch:hover .ag-seg:not(.on) { color: var(--ag-fg); }
.ag-button { position: relative; isolation: isolate; display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.8125rem; font-weight: 500; min-height: 2.25rem; padding: 0.25rem 0.58rem; border-radius: 0.5625rem; border: 0; background: transparent; color: var(--ag-muted); cursor: pointer; white-space: nowrap; transition: color 0.15s; }
.ag-button::before { content: ""; position: absolute; z-index: -1; inset: 0.25rem 0; border-radius: 0.5rem; background: transparent; transition: background 0.15s, box-shadow 0.15s; }
.ag-button:hover { color: var(--ag-fg); }
.ag-button:hover::before { background: color-mix(in srgb, var(--ag-fg) 6%, transparent); }
.ag-button[aria-pressed="true"] { color: var(--ag-fg); }
.ag-button[aria-pressed="true"]::before { background: color-mix(in srgb, var(--ag-fg) 8%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 14%, transparent); }
.ag-button:focus-visible { outline: 2px solid var(--ag-accent); outline-offset: -4px; }
.ag-primary { color: var(--ag-bg); font-weight: 600; padding: 0.25rem 0.9rem; }
.ag-primary::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: var(--ag-fg); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
.ag-primary:hover { color: var(--ag-bg); }
.ag-primary:hover::before { background: color-mix(in srgb, var(--ag-fg) 86%, var(--ag-bg)); }
/* Theme toggle: Archify's own, a pill track with a knob that slides and carries the sun or moon. */
.ag-theme { width: 4rem; padding: 0; justify-content: center; }
/* The same outer pill as the Model | Graph switch and Export, so the three line up; Archify's track and knob inside it. */
.ag-theme::before { inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
.ag-theme:hover::before { background: color-mix(in srgb, var(--ag-panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ag-fg) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
.ag-theme-track { position: absolute; z-index: 0; inset: 0.6875rem 0.5625rem; border-radius: 999px; background: color-mix(in srgb, var(--ag-fg) 9%, transparent); box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08); transition: background 0.15s; }
.ag-theme:hover .ag-theme-track { background: color-mix(in srgb, var(--ag-fg) 13%, transparent); }
.ag-theme::after { content: ""; position: absolute; z-index: 1; top: 50%; left: 0.6875rem; width: 1.125rem; height: 1.125rem; border-radius: 50%; background: var(--ag-panel); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.12); transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); }
.ag-theme[data-on="dark"] .ag-theme-track { background: rgba(148, 163, 184, 0.16); }
.ag-theme[data-on="dark"]::after { left: calc(100% - 0.6875rem - 1.125rem); background: #3b4a63; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); }
.ag-theme-icon { position: absolute; z-index: 2; top: 50%; left: calc(0.6875rem + 0.1875rem); width: 0.75rem; height: 0.75rem; color: var(--ag-cloud); background: currentColor; transform: translateY(-50%); transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cg fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round'%3E%3Ccircle cx='10' cy='10' r='3.1'/%3E%3Cpath d='M10 2.2v1.4M10 16.4v1.4M2.2 10h1.4M16.4 10h1.4M4.5 4.5l1 1M14.5 14.5l1 1M15.5 4.5l-1 1M5.5 14.5l-1 1'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat; }
.ag-theme[data-on="dark"] .ag-theme-icon { left: calc(100% - 0.6875rem - 1.125rem + 0.1875rem); color: #c7d2fe; -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.2 12.7A6.3 6.3 0 017.3 4.8a6.3 6.3 0 107.9 7.9Z' fill='none' stroke='black' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.ag-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.ag-bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem var(--ag-gutter) 0.9rem; flex-wrap: wrap; }
.ag-query-wrap { flex: 1 1 18rem; display: flex; align-items: center; gap: 0.5rem; min-width: 0; background: var(--ag-input); border: 1px solid var(--ag-input-border); border-radius: 0.5625rem; padding: 0 0.6rem; }
.ag-query-wrap:focus-within { border-color: var(--ag-accent); }
.ag-query { flex: 1; min-width: 0; height: 2.125rem; border: 0; background: transparent; color: var(--ag-input-fg); font-family: var(--ag-mono); font-size: 0.875rem; outline: none; }
.ag-query::placeholder { color: var(--ag-muted); opacity: 0.8; }
.ag-clear { border: 0; background: transparent; color: var(--ag-muted); font-size: 1rem; cursor: pointer; padding: 0 0.1rem; }
.ag-clear:hover { color: var(--ag-fg); }
.ag-count { font-family: var(--ag-mono); font-size: 0.8125rem; color: var(--ag-muted); white-space: nowrap; margin: 0 0.35rem 0 0.25rem; }
.ag-count.is-error { color: var(--ag-error); }
.ag-main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 20rem; min-height: 0; }
.ag-main.is-side-hidden { grid-template-columns: minmax(0, 1fr); }
.ag-canvas { position: relative; min-width: 0; min-height: 0; }
.ag-cy { position: absolute; inset: 0; }
.ag-legend { position: absolute; left: var(--ag-gutter); bottom: 1rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; z-index: 2; max-width: calc(100% - 2 * var(--ag-gutter)); }
.ag-chip { display: inline-flex; align-items: center; gap: 0.4rem; font: inherit; font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px; border: 1px solid var(--ag-border); background: color-mix(in srgb, var(--ag-panel) 85%, transparent); color: var(--ag-fg); cursor: pointer; backdrop-filter: blur(6px); }
.ag-chip::before { content: ""; width: 0.55rem; height: 0.55rem; border-radius: 2px; background: var(--c); }
.ag-chip[aria-pressed="false"] { opacity: 0.45; text-decoration: line-through; }
.ag-chip small { color: var(--ag-muted); font-family: var(--ag-mono); }
.ag-side { border-left: 1px solid var(--ag-border); background: var(--ag-panel); overflow: auto; padding: 1.1rem var(--ag-gutter) 1.5rem; min-width: 0; }
.ag-side h2 { margin: 0; font-size: 1.0625rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; }
.ag-dot { display: inline-block; width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--c, var(--ag-external)); flex: none; }
.ag-id { font-family: var(--ag-mono); color: var(--ag-muted); font-size: 0.75rem; margin: 0.25rem 0 0.8rem; word-break: break-all; }
.ag-side p { margin: 0 0 0.8rem; line-height: 1.5; white-space: pre-line; }
.ag-side h3 { margin: 0.9rem 0 0.4rem; font-size: 0.6875rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ag-muted); font-weight: 700; }
.ag-link { display: inline-flex; align-items: baseline; gap: 0.35rem; line-height: 1.4; font: inherit; font-family: var(--ag-mono); font-size: 0.75rem; padding: 0.15rem 0.55rem; margin: 0 0.3rem 0.35rem 0; border: 1px solid var(--ag-border); border-radius: 999px; background: transparent; color: var(--ag-fg); cursor: pointer; text-align: left; max-width: 100%; }
.ag-link:hover { border-color: var(--ag-accent); }
.ag-link.is-static { cursor: default; }
.ag-link.is-static:hover { border-color: var(--ag-border); }
.ag-link.is-accepted .ag-dot { background: var(--ag-backend); } .ag-link.is-proposed .ag-dot { background: var(--ag-cloud); } .ag-link.is-rejected .ag-dot { background: var(--ag-security); } .ag-link.is-superseded .ag-dot { background: var(--ag-external); }
.ag-empty { color: var(--ag-muted); }
.ag-side-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }
.ag-help { position: absolute; top: 0.5rem; right: 20.75rem; z-index: 5; width: min(26rem, calc(100% - 2rem)); max-height: calc(100% - 1rem); overflow: auto; padding: 0.9rem 1rem; border: 1px solid var(--ag-border); border-radius: 0.6875rem; background: var(--ag-panel); box-shadow: 0 16px 40px rgba(0,0,0,.35); }
.ag-main.is-side-hidden .ag-help { right: 0.75rem; }
.ag-help h3 { margin: 0 0 0.5rem; font-size: 0.8125rem; }
.ag-help table { border-collapse: collapse; width: 100%; font-size: 0.8125rem; }
.ag-help td { padding: 0.25rem 0.4rem 0.25rem 0; vertical-align: top; border-top: 1px solid var(--ag-border); }
.ag-help td:first-child { font-family: var(--ag-mono); white-space: nowrap; color: var(--ag-accent); cursor: pointer; }
.ag-help td:first-child:hover { text-decoration: underline; }
.ag-help p { margin: 0.6rem 0 0; color: var(--ag-muted); font-size: 0.75rem; }
@media (max-width: 760px) { .ag-main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; } .ag-side { border-left: 0; border-top: 1px solid var(--ag-border); max-height: 40%; } .ag-help { right: 0.75rem; } }
`;
+102
View File
@@ -0,0 +1,102 @@
import * as vscode from 'vscode';
import { Decision, DECISION_STATUSES, effectiveStatus, isDecisionFile } from '../model/decisions';
import { ModelIndex, ModelService } from '../model/service';
/** Diagrams (decisions: […]) and decision files (elements: […], diagrams: […], supersedes: […]). */
export const DECISION_SELECTOR: vscode.DocumentSelector = [{ pattern: '**/*.archify' }, { pattern: '**/*.archify-decision' }];
type ListKey = 'decisions' | 'elements' | 'diagrams' | 'supersedes';
/** Which list the cursor is in on a `key: [a, b, …` line, and what has been typed of the current item. */
export function listValueAt(linePrefix: string, fileName: string): { key: ListKey; typed: string } | undefined {
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)$`).exec(linePrefix);
if (match) {
const items = match[2].split(',');
return { key: match[1] as ListKey, typed: items[items.length - 1].replace(/^[\s"']+/, '') };
}
// Block list item under the key: " - 00" (the key is on a line above; the caller checks).
return undefined;
}
/** The list item under a position on a flow-list line, with its range. */
export function listItemAt(line: string, character: number, fileName: string): { key: ListKey; value: string; start: number; end: number } | undefined {
const keys = isDecisionFile(fileName) ? ['elements', 'diagrams', 'supersedes'] : ['decisions'];
const match = new RegExp(`(?:^|[\\s{,])(${keys.join('|')}):\\s*\\[([^\\]]*)\\]?`).exec(line);
if (!match) return undefined;
const listStart = match.index + match[0].indexOf('[') + 1;
const re = /[^,\s"'\]]+/g;
const inner = match[2];
let m: RegExpExecArray | null;
while ((m = re.exec(inner))) {
const start = listStart + m.index;
const end = start + m[0].length;
if (character >= start && character <= end) return { key: match[1] as ListKey, value: m[0], start, end };
}
return undefined;
}
function describeDecision(d: Decision, index: ModelIndex): vscode.MarkdownString {
const md = new vscode.MarkdownString();
const { status, supersededBy } = effectiveStatus(d, index.decisions.decisions);
md.appendMarkdown(`**${d.id}**${d.title ? ` · ${d.title}` : ''} \`${status}\`${supersededBy ? ` by ${supersededBy}` : ''}${d.date ? ` · ${d.date}` : ''}\n\n`);
if (d.decision) md.appendMarkdown(`${d.decision.trim()}\n\n`);
md.appendMarkdown(`*${d.file}*`);
return md;
}
/** Completion, hover and go-to-definition for decision ids in diagrams and for links inside decision files. */
export class DecisionLanguage implements vscode.CompletionItemProvider, vscode.HoverProvider, vscode.DefinitionProvider {
constructor(private readonly models: ModelService) {}
async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.CompletionItem[] | undefined> {
const at = listValueAt(document.lineAt(position.line).text.slice(0, position.character), document.fileName);
if (!at) return undefined;
const index = await this.models.indexFor(document.uri);
if (!index) return undefined;
const range = new vscode.Range(position.translate(0, -at.typed.length), position);
const item = (label: string, description: string | undefined, detail: string, doc?: vscode.MarkdownString) => {
const it = new vscode.CompletionItem({ label, description }, vscode.CompletionItemKind.Reference);
it.detail = detail;
it.documentation = doc;
it.filterText = `${label} ${description ?? ''}`;
it.range = range;
return it;
};
if (at.key === 'decisions' || at.key === 'supersedes') {
return [...index.decisions.decisions.values()].map((d) => item(d.id, d.title, effectiveStatus(d, index.decisions.decisions).status, describeDecision(d, index)));
}
if (at.key === 'elements') {
return [...index.registry.elements.values()].map((e) => item(e.id, e.label, [e.type, e.sublabel].filter(Boolean).join(' · ')));
}
return index.diagrams.map((d) => item(d.relativePath, d.title, d.kind ?? 'diagram'));
}
async provideHover(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Hover | undefined> {
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
if (!hit || (hit.key !== 'decisions' && hit.key !== 'supersedes')) return undefined;
const index = await this.models.indexFor(document.uri);
const range = new vscode.Range(position.line, hit.start, position.line, hit.end);
const d = index?.decisions.decisions.get(hit.value);
if (!index) return new vscode.Hover(`\`${hit.value}\` cannot be resolved: this file is not inside a model.`, range);
if (!d) return new vscode.Hover(`Unknown decision \`${hit.value}\`: no .archify-decision file of this model defines it.`, range);
return new vscode.Hover(describeDecision(d, index), range);
}
async provideDefinition(document: vscode.TextDocument, position: vscode.Position): Promise<vscode.Location | undefined> {
const hit = listItemAt(document.lineAt(position.line).text, position.character, document.fileName);
if (!hit) return undefined;
const index = await this.models.indexFor(document.uri);
if (!index) return undefined;
if (hit.key === 'decisions' || hit.key === 'supersedes') {
const d = index.decisions.decisions.get(hit.value);
return d ? new vscode.Location(vscode.Uri.joinPath(index.root, ...d.file.split('/')), new vscode.Position(d.lines.id, 2)) : undefined;
}
if (hit.key === 'elements') {
const e = index.registry.elements.get(hit.value);
return e ? new vscode.Location(vscode.Uri.joinPath(index.root, ...e.file.split(/[\\/]/)), new vscode.Position(e.line, 2)) : undefined;
}
const diagram = index.diagrams.find((d) => d.relativePath === hit.value);
return diagram ? new vscode.Location(vscode.Uri.joinPath(index.root, ...diagram.relativePath.split('/')), new vscode.Position(0, 0)) : undefined;
}
}
+27 -1
View File
@@ -3,6 +3,7 @@ import * as vscode from 'vscode';
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli'; import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime'; import { evidenceMode, runtimeFor } from '../archify/runtime';
import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format'; import { ModelProblem, isRegistryFile, parseRegistry } from '../model/format';
import { isDecisionFile, parseDecision } from '../model/decisions';
import { prepareDiagram } from '../model/prepare'; import { prepareDiagram } from '../model/prepare';
import { ModelService } from '../model/service'; import { ModelService } from '../model/service';
import { isArchifyFile } from '../sources/detect'; import { isArchifyFile } from '../sources/detect';
@@ -70,7 +71,7 @@ export class DiagnosticsController implements vscode.Disposable {
} }
private schedule(doc: vscode.TextDocument, delay: number): void { 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(); const key = doc.uri.toString();
clearTimeout(this.timers.get(key)); clearTimeout(this.timers.get(key));
this.timers.set(key, setTimeout(() => void this.validate(doc), delay)); 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); await this.validateRegistry(doc, seq);
return; return;
} }
if (isDecisionFile(doc.fileName)) {
await this.validateDecision(doc, seq);
return;
}
if (isArchifyFile(doc.fileName)) { if (isArchifyFile(doc.fileName)) {
// Nothing else reports YAML syntax errors in .archify files, and a file // Nothing else reports YAML syntax errors in .archify files, and a file
// that does not parse has no diagram to validate yet. // 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. */ /** 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> { private async validateRegistry(doc: vscode.TextDocument, seq: number): Promise<void> {
const text = doc.getText(); const text = doc.getText();
const root = await this.models.rootOf(doc.uri); const root = await this.models.rootOf(doc.uri);
+183
View File
@@ -0,0 +1,183 @@
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
import { ModelProblem, Registry } from './format';
/**
* Architecture decisions of a model (see docs/model.md): one
* `*.archify-decision` file per decision, anywhere under the model root.
* Pure functions, no VS Code imports.
*/
export const DECISION_EXT = '.archify-decision';
export const DECISION_STATUSES = ['proposed', 'accepted', 'superseded', 'rejected'] as const;
export type DecisionStatus = (typeof DECISION_STATUSES)[number];
export interface Decision {
id: string;
title?: string;
status?: string;
date?: string;
/** Registry element ids the decision is about. */
elements: string[];
/** Diagram paths (relative to the model root) the decision names. */
diagrams: string[];
/** Ids of older decisions this one replaces. */
supersedes: string[];
context?: string;
decision?: string;
consequences?: string;
/** Decision file relative to the model root, and 0-based lines for diagnostics. */
file: string;
line: number;
lines: { id: number; status: number; elements: number[]; diagrams: number[]; supersedes: number[] };
}
export function isDecisionFile(fileName: string): boolean {
return fileName.endsWith(DECISION_EXT);
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim().length > 0 ? v : v !== undefined && v !== null && typeof v !== 'object' ? String(v) : undefined);
/** "OpenSearch for product search" → "opensearch-for-product-search". */
export function slugify(title: string): string {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug || 'decision';
}
/** Parses one decision file. Problems carry 0-based lines. */
export function parseDecision(text: string, file: string): { decision?: Decision; problems: ModelProblem[] } {
const problems: ModelProblem[] = [];
const doc = parseDocument(text, { prettyErrors: false, uniqueKeys: false });
for (const error of doc.errors) {
problems.push({ message: `Decision is not valid YAML: ${error.message.split('\n')[0]}`, severity: 'error', file, line: error.linePos?.[0].line ? error.linePos[0].line - 1 : 0 });
}
if (doc.errors.length) return { problems };
const lineOf = (offset: number | undefined) => (offset === undefined ? 0 : text.slice(0, offset).split('\n').length - 1);
const root = doc.contents;
if (!isMap(root)) {
problems.push({ message: 'A decision file is a mapping with a "decision" block and context, decision and consequences.', severity: 'error', file, line: 0 });
return { problems };
}
const headNode = root.get('decision', true);
if (!isMap(headNode)) {
problems.push({ message: 'A decision needs a "decision" block with id, title and status.', severity: 'error', file, line: 0, pointer: '/decision' });
return { problems };
}
const headPair = root.items.find((pair) => isScalar(pair.key) && pair.key.value === 'decision');
const headLine = headPair && isScalar(headPair.key) ? lineOf(headPair.key.range?.[0]) : 0;
const keyLine = (key: string) => {
const pair = headNode.items.find((p) => isScalar(p.key) && p.key.value === key);
return pair && isScalar(pair.key) ? lineOf(pair.key.range?.[0]) : headLine;
};
const list = (key: string): { values: string[]; lines: number[] } => {
const node = headNode.get(key, true);
const values: string[] = [];
const lines: number[] = [];
if (isSeq(node)) {
for (const item of node.items) {
if (!isScalar(item)) continue;
if (typeof item.value === 'number') {
problems.push({ message: `"${item.value}" in ${key} is a number to YAML. Quote it or use words.`, severity: 'error', file, line: lineOf(item.range?.[0]), pointer: `/decision/${key}` });
continue;
}
if (str(item.value)) {
values.push(String(item.value));
lines.push(lineOf(item.range?.[0]));
}
}
} else if (node !== undefined) {
problems.push({ message: `"${key}" must be a list.`, severity: 'error', file, line: keyLine(key), pointer: `/decision/${key}` });
}
return { values, lines };
};
const head = headNode.toJSON() as Record<string, unknown>;
if (typeof head.id === 'number') {
problems.push({ message: `decision.id ${head.id} is a number to YAML (0001 becomes 1). Quote it ("${head.id}") or use words.`, severity: 'error', file, line: keyLine('id'), pointer: '/decision/id' });
return { problems };
}
const id = str(head.id);
if (!id) {
problems.push({ message: 'decision.id is missing.', severity: 'error', file, line: headLine, pointer: '/decision/id' });
return { problems };
}
const status = str(head.status);
if (status && !(DECISION_STATUSES as readonly string[]).includes(status)) {
problems.push({ message: `Unknown status "${status}"; one of ${DECISION_STATUSES.join(', ')}.`, severity: 'error', file, line: keyLine('status'), pointer: '/decision/status' });
}
const elements = list('elements');
const diagrams = list('diagrams');
const supersedes = list('supersedes');
const body = root.toJSON() as Record<string, unknown>;
return {
problems,
decision: {
id,
title: str(head.title),
status,
date: str(head.date),
elements: elements.values,
diagrams: diagrams.values,
supersedes: supersedes.values,
context: str(body.context),
decision: str(body.decision),
consequences: str(body.consequences),
file,
line: headLine,
lines: { id: keyLine('id'), status: keyLine('status'), elements: elements.lines, diagrams: diagrams.lines, supersedes: supersedes.lines },
},
};
}
export interface DecisionSet {
decisions: Map<string, Decision>;
files: string[];
problems: ModelProblem[];
}
/** Merges parsed decision files; an id used twice is an error on the second file. */
export function mergeDecisions(parts: { file: string; decision?: Decision; problems: ModelProblem[] }[]): DecisionSet {
const decisions = new Map<string, Decision>();
const problems: ModelProblem[] = [];
for (const part of parts) {
problems.push(...part.problems);
if (!part.decision) continue;
const existing = decisions.get(part.decision.id);
if (existing) {
problems.push({ message: `Decision "${part.decision.id}" is defined twice (also in ${existing.file}).`, severity: 'error', file: part.file, line: part.decision.lines.id });
continue;
}
decisions.set(part.decision.id, part.decision);
}
return { decisions, files: parts.map((p) => p.file), problems };
}
/** The status the pages show: a decision another one supersedes is superseded, whatever its file says. */
export function effectiveStatus(decision: Decision, all: Map<string, Decision>): { status: string; supersededBy?: string } {
for (const other of all.values()) {
if (other !== decision && other.supersedes.includes(decision.id) && other.status !== 'rejected') return { status: 'superseded', supersededBy: other.id };
}
return { status: decision.status ?? 'proposed' };
}
/** Link checks across the model: elements, diagrams and supersedes must exist. */
export function validateDecisions(set: DecisionSet, registry: Registry | undefined, diagramPaths: Set<string>): ModelProblem[] {
const problems: ModelProblem[] = [];
for (const d of set.decisions.values()) {
d.elements.forEach((id, i) => {
if (!registry?.elements.has(id)) problems.push({ message: `Unknown element "${id}": no registry of this model defines it.`, severity: 'error', file: d.file, line: d.lines.elements[i], pointer: `/decision/elements/${i}` });
});
d.diagrams.forEach((path, i) => {
if (!diagramPaths.has(path)) problems.push({ message: `Diagram "${path}" does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.diagrams[i], pointer: `/decision/diagrams/${i}` });
});
d.supersedes.forEach((id, i) => {
if (!set.decisions.has(id)) problems.push({ message: `Decision "${id}" (in supersedes) does not exist in this model.`, severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
else if (id === d.id) problems.push({ message: 'A decision cannot supersede itself.', severity: 'error', file: d.file, line: d.lines.supersedes[i], pointer: `/decision/supersedes/${i}` });
});
}
return problems;
}
/** Sort for lists: live decisions newest first, superseded and rejected last. */
export function sortDecisions(decisions: Decision[], all: Map<string, Decision>): Decision[] {
const rank = (d: Decision) => (['superseded', 'rejected'].includes(effectiveStatus(d, all).status) ? 1 : 0);
return [...decisions].sort((a, b) => rank(a) - rank(b) || (b.date ?? '').localeCompare(a.date ?? '') || a.id.localeCompare(b.id));
}
+126 -51
View File
@@ -55,42 +55,6 @@ export interface Registry {
problems: ModelProblem[]; problems: ModelProblem[];
} }
/** Archify card dot colour for a component type (the colours Archify gives the nodes). */
const DOT_BY_TYPE: Record<string, string> = {
frontend: 'cyan',
backend: 'emerald',
database: 'violet',
cloud: 'amber',
security: 'rose',
messagebus: 'orange',
external: 'slate',
};
export function dotForType(type?: string): string {
return DOT_BY_TYPE[type ?? ''] ?? 'slate';
}
export interface Card {
dot: string;
title: string;
items: string[];
}
/**
* Archify info cards for registry elements: one per element that has a
* description, titled by its label. What a diagram gets below itself when
* it draws shared elements and defines no cards of its own.
*/
export function cardsFromElements(elements: Iterable<RegistryElement>): Card[] {
const cards: Card[] = [];
for (const element of elements) {
if (!element.description) continue;
const items = [element.description.trim()];
cards.push({ dot: dotForType(element.type), title: element.label ?? element.id, items });
}
return cards;
}
export function isMainFile(fileName: string): boolean { export function isMainFile(fileName: string): boolean {
return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE; return (fileName.split(/[\\/]/).pop() ?? '') === MAIN_FILE;
} }
@@ -186,6 +150,19 @@ export function mergeRegistries(parts: { elements: RegistryElement[]; problems:
return { elements, files: parts.map((p) => p.file), problems }; 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 { export interface ResolvedDiagram {
/** The diagram as Archify expects it: refs expanded, model block and links removed. */ /** The diagram as Archify expects it: refs expanded, model block and links removed. */
diagram: Obj; diagram: Obj;
@@ -196,32 +173,86 @@ export interface ResolvedDiagram {
links: Map<string, string>; links: Map<string, string>;
/** Registry element ids used by this diagram. */ /** Registry element ids used by this diagram. */
used: Set<string>; 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 { export interface ResolveOptions {
/** Undefined when the diagram is not inside a model. */ /** Undefined when the diagram is not inside a model. */
registry?: Registry; registry?: Registry;
/** Known decision ids of the model; undefined outside a model or when unknown. */
decisions?: Set<string>;
fileName: 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 * Expands `ref` components from the registry and strips what Archify does
* not know. Component order and indexes are kept, so Archify's diagnostics * not know. Component order and indexes are kept, so Archify's diagnostics
* (JSON pointers) still point at the right component in the source file. * (JSON pointers) still point at the right component in the source file.
* A diagram without `cards` of its own gets cards from the descriptions of
* the shared elements it draws, so the registry text shows under the diagram.
*/ */
export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram { export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiagram {
const problems: ModelProblem[] = []; const problems: ModelProblem[] = [];
const refs = new Map<number, RegistryElement>(); const refs = new Map<number, RegistryElement>();
const links = new Map<string, string>(); const links = new Map<string, string>();
const used = new Set<string>(); const used = new Set<string>();
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used }; const descriptions = new Map<string, string>();
if (!isObj(doc)) return { diagram: {}, problems, refs, links, used, descriptions, decisionRefs: [] };
const diagram: Obj = { ...doc }; const diagram: Obj = { ...doc };
delete diagram.model; delete diagram.model;
const decisionRefs = takeDecisionRefs(diagram, options, problems);
const { registry } = options; const { registry } = options;
const components = Array.isArray(doc.components) ? doc.components : undefined; const components = Array.isArray(diagram.components) ? diagram.components : undefined;
if (!components) return { diagram, problems, refs, links, used }; if (!components) return { diagram, problems, refs, links, used, descriptions, decisionRefs };
diagram.components = components.map((raw, index) => { diagram.components = components.map((raw, index) => {
if (!isObj(raw)) return raw; if (!isObj(raw)) return raw;
@@ -248,6 +279,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
} }
used.add(ref); used.add(ref);
refs.set(index, element); refs.set(index, element);
if (element.description) descriptions.set(id, element.description.trim());
for (const key of Object.keys(own)) { for (const key of Object.keys(own)) {
if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) { if ((COPIED_FIELDS as readonly string[]).includes(key) && !(OVERRIDABLE_FIELDS as readonly string[]).includes(key)) {
problems.push({ problems.push({
@@ -267,14 +299,7 @@ export function resolveRefs(doc: unknown, options: ResolveOptions): ResolvedDiag
if (link) links.set(id, link); if (link) links.set(id, link);
return merged; return merged;
}); });
if (diagram.cards === undefined && refs.size) { return { diagram, problems, refs, links, used, descriptions, decisionRefs };
const seen = new Set<string>();
const elements: RegistryElement[] = [];
for (const element of refs.values()) if (!seen.has(element.id)) { seen.add(element.id); elements.push(element); }
const cards = cardsFromElements(elements);
if (cards.length) diagram.cards = cards;
}
return { diagram, problems, refs, links, used };
} }
/** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */ /** Group of a diagram inside a model: '' for the root folder, else the folder path with '/' separators. */
@@ -284,12 +309,35 @@ export function groupOf(relativePath: string): string {
return parts.join('/'); return parts.join('/');
} }
/** A node as a diagram declares it: a `ref` to the registry, or an inline element. */
export interface DiagramNode {
id: string;
ref?: string;
type?: string;
label?: string;
sublabel?: string;
brand?: string;
}
/** A connection, edge, flow, transition or message between two nodes of a diagram. */
export interface DiagramEdge {
from: string;
to: string;
label?: string;
}
export interface DiagramSummary { export interface DiagramSummary {
relativePath: string; relativePath: string;
group: string; group: string;
kind?: string; kind?: string;
title?: string; title?: string;
used: string[]; used: string[];
/** Decisions the diagram points at. */
decisions: DecisionRef[];
/** Every node the diagram draws, shared or inline, in file order. */
nodes: DiagramNode[];
/** Every edge between two of those nodes. */
edges: DiagramEdge[];
parseError?: string; parseError?: string;
} }
@@ -299,13 +347,40 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
try { try {
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false }); doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
} catch (error) { } 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: [], nodes: [], edges: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
} }
const obj = isObj(doc) ? doc : {}; const obj = isObj(doc) ? doc : {};
const meta = isObj(obj.meta) ? obj.meta : {}; const meta = isObj(obj.meta) ? obj.meta : {};
const components = Array.isArray(obj.components) ? obj.components : []; 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))]; 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 }, []);
const nodes: DiagramNode[] = [];
const seen = new Set<string>();
for (const key of NODE_COLLECTIONS) {
const items = obj[key];
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!isObj(item)) continue;
const ref = str(item.ref);
const id = str(item.id) ?? ref;
if (!id || seen.has(id)) continue;
seen.add(id);
nodes.push({ id, ref, type: str(item.type), label: str(item.label), sublabel: str(item.sublabel), brand: str(item.brand) });
}
}
const edges: DiagramEdge[] = [];
for (const key of EDGE_COLLECTIONS) {
const items = obj[key];
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!isObj(item)) continue;
const from = str(item.from);
const to = str(item.to);
if (!from || !to || !seen.has(from) || !seen.has(to)) continue;
edges.push({ from, to, label: str(item.label) });
}
}
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions, nodes, edges };
} }
/** Groups in display order: the root group first, then folders in path order. */ /** Groups in display order: the root group first, then folders in path order. */
+196
View File
@@ -0,0 +1,196 @@
import { Decision, DecisionSet, effectiveStatus } from './decisions';
import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './format';
/**
* The model as one graph: every element any diagram draws (and every registry
* element, drawn or not), every connection any diagram has between two of
* them, each once. Pure: built from the model index, no VS Code imports, so
* the webview bundle and the unit tests share it.
*/
export type GraphNodeKind = 'element' | 'decision' | 'registry';
export type GraphEdgeKind = 'connection' | 'decides' | 'defines' | 'supersedes';
export interface GraphNode {
id: string;
/** An element of the model, a decision (`decision:<id>`) or a registry file (`registry:<file>`). */
kind: GraphNodeKind;
label: string;
sublabel?: string;
type?: string;
brand?: string;
/** Defined in a registry (true) or inline in one diagram (false). */
shared: boolean;
description?: string;
/** Registry file and 0-based line, for shared elements. */
file?: string;
line?: number;
/** Diagram that explains the element, relative to the model root. */
link?: string;
/** Diagrams that draw it, relative paths in path order. */
diagrams: string[];
/** Decisions about it: named by the decision, or attached to its node in a diagram. */
decisions: string[];
/** Decision nodes: the effective status. */
status?: string;
/** Registry nodes: ids of the elements the file defines. */
elements?: string[];
}
export interface GraphEdge {
/** `from>to`, unique in the graph. */
id: string;
/** A connection drawn by a diagram, or a link to a decision or registry node. */
kind: GraphEdgeKind;
from: string;
to: string;
/** Distinct labels the diagrams give this connection. */
labels: string[];
diagrams: string[];
decisions: string[];
}
export interface GraphDecision {
id: string;
title?: string;
status: string;
file: string;
}
export interface GraphDiagram {
path: string;
title?: string;
kind?: string;
}
export interface ModelGraph {
meta: ModelMeta;
nodes: GraphNode[];
edges: GraphEdge[];
decisions: GraphDecision[];
diagrams: GraphDiagram[];
}
export interface GraphInput {
meta: ModelMeta;
registry: Registry;
diagrams: DiagramSummary[];
decisions: DecisionSet;
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
}
export const edgeId = (from: string, to: string): string => `${from}>${to}`;
export const decisionNodeId = (id: string): string => `decision:${id}`;
export const registryNodeId = (file: string): string => `registry:${file}`;
function pushUnique(list: string[], value: string): void {
if (!list.includes(value)) list.push(value);
}
export function buildGraph(input: GraphInput): ModelGraph {
const nodes = new Map<string, GraphNode>();
const edges = new Map<string, GraphEdge>();
// Registry elements first: shared, and present even when no diagram draws them yet.
for (const e of input.registry.elements.values()) {
nodes.set(e.id, {
id: e.id,
kind: 'element',
label: e.label ?? e.id,
sublabel: e.sublabel,
type: e.type,
brand: e.brand,
shared: true,
description: e.description?.trim() || undefined,
file: e.file,
line: e.line,
link: e.diagram,
diagrams: [],
decisions: [],
});
}
const diagrams = [...input.diagrams].filter((d) => !d.parseError).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
for (const d of diagrams) {
for (const n of d.nodes) {
let node = nodes.get(n.id);
if (!node) {
const element = n.ref ? input.registry.elements.get(n.ref) : undefined;
node = element
? { ...nodes.get(element.id)!, id: n.id, label: n.label ?? element.label ?? n.id, sublabel: n.sublabel ?? element.sublabel, diagrams: [], decisions: [] }
: { id: n.id, kind: 'element', label: n.label ?? n.ref ?? n.id, sublabel: n.sublabel, type: n.type, brand: n.brand, shared: false, diagrams: [], decisions: [] };
nodes.set(n.id, node);
}
pushUnique(node.diagrams, d.relativePath);
}
for (const e of d.edges) {
const id = edgeId(e.from, e.to);
let edge = edges.get(id);
if (!edge) {
edge = { id, kind: 'connection', from: e.from, to: e.to, labels: [], diagrams: [], decisions: [] };
edges.set(id, edge);
}
if (e.label) pushUnique(edge.labels, e.label);
pushUnique(edge.diagrams, d.relativePath);
}
}
const all = input.decisions.decisions;
for (const d of all.values()) for (const id of d.elements) nodes.get(id)?.decisions && pushUnique(nodes.get(id)!.decisions, d.id);
for (const [decisionId, uses] of input.decisionUsage) {
if (!all.has(decisionId)) continue;
for (const use of uses) {
if (use.attachedTo.kind === 'node') {
const node = nodes.get(use.attachedTo.id);
if (node) pushUnique(node.decisions, decisionId);
} else if (use.attachedTo.kind === 'edge') {
const edge = edges.get(edgeId(use.attachedTo.from, use.attachedTo.to));
if (edge) pushUnique(edge.decisions, decisionId);
}
}
}
const decisionRow = (d: Decision): GraphDecision => ({ id: d.id, title: d.title, status: effectiveStatus(d, all).status, file: d.file });
// Registry files as nodes, each linked to the elements it defines.
const byFile = new Map<string, string[]>();
for (const e of input.registry.elements.values()) byFile.set(e.file, [...(byFile.get(e.file) ?? []), e.id]);
for (const file of input.registry.files) if (!byFile.has(file)) byFile.set(file, []);
for (const [file, ids] of byFile) {
const id = registryNodeId(file);
nodes.set(id, { id, kind: 'registry', label: file.replace(/\.archify-registry$/, ''), type: 'registry', shared: true, file, diagrams: [], decisions: [], elements: ids });
for (const element of ids) {
const edge = edgeId(id, element);
edges.set(edge, { id: edge, kind: 'defines', from: id, to: element, labels: [], diagrams: [], decisions: [] });
}
}
// Decisions as nodes, linked to the elements and connections they are about, and to what they supersede.
for (const d of all.values()) {
const id = decisionNodeId(d.id);
const row = decisionRow(d);
nodes.set(id, { id, kind: 'decision', label: d.title ?? d.id, sublabel: d.id, type: 'decision', shared: true, description: [d.context, d.decision].filter(Boolean).join('\n\n') || undefined, file: d.file, line: 0, diagrams: [...d.diagrams], decisions: [], status: row.status });
}
for (const node of [...nodes.values()]) {
if (node.kind !== 'element') continue;
for (const decision of node.decisions) {
if (!all.has(decision)) continue;
const edge = edgeId(decisionNodeId(decision), node.id);
edges.set(edge, { id: edge, kind: 'decides', from: decisionNodeId(decision), to: node.id, labels: [], diagrams: [], decisions: [] });
}
}
for (const d of all.values()) {
for (const other of d.supersedes) {
if (!all.has(other)) continue;
const edge = edgeId(decisionNodeId(d.id), decisionNodeId(other));
edges.set(edge, { id: edge, kind: 'supersedes', from: decisionNodeId(d.id), to: decisionNodeId(other), labels: ['supersedes'], diagrams: [], decisions: [] });
}
}
return {
meta: input.meta,
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
decisions: [...all.values()].map(decisionRow).sort((a, b) => a.id.localeCompare(b.id)),
diagrams: diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind })),
};
}
+251
View File
@@ -0,0 +1,251 @@
import { GraphEdge, GraphNode, ModelGraph } from './graph';
/**
* The graph page's query box. A few words, no language to learn: terms are
* ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk
* the graph. Pure, shared by the webview bundle and the unit tests.
*
* database label, id, sublabel, brand or type contain it
* type:backend elements of that type
* diagram:0200-* drawn by a diagram whose path matches (glob)
* decision:<id> what the decision is about or attached to
* uses:api api and its neighbours
* near:api,2 api and everything within two steps
* from:web to:db the shortest path between two elements
* unused registry elements no diagram draws
* shared / -shared registry elements / inline elements
*/
export interface QueryTerm {
key: string;
value: string;
negate: boolean;
}
export interface QueryResult {
/** Ids of the elements that match. Every element when the query is empty. */
nodes: Set<string>;
/** Ids of the connections shown: both ends match, or the path found. */
edges: Set<string>;
/** The path found by `from: to:`, in order. */
path?: string[];
/** Why the result is empty or partial, when it is. */
message?: string;
terms: QueryTerm[];
}
const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']);
const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']);
export function parseQuery(text: string): QueryTerm[] {
const tokens = text.trim().split(/\s+/).filter(Boolean);
const terms: QueryTerm[] = [];
for (let i = 0; i < tokens.length; i += 1) {
let token = tokens[i];
const negate = token.startsWith('-') && token.length > 1;
if (negate) token = token.slice(1);
const colon = token.indexOf(':');
let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase();
let value = colon > 0 ? token.slice(colon + 1) : '';
if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) {
key = 'text';
value = token;
} else if (colon < 0 && key !== 'shared' && key !== 'unused') {
key = 'text';
value = token;
}
// `near:api 2` is also accepted: a bare number after near is the hop count.
if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) {
value = `${value},${tokens[i + 1]}`;
i += 1;
}
if (!value && key === 'text') continue;
terms.push({ key, value, negate });
}
return terms;
}
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
const isElement = (n: GraphNode) => n.kind === 'element';
const isConnection = (e: GraphEdge) => e.kind === 'connection';
/** An element by id, then by id or label ignoring case. Decision and registry nodes are not walked from. */
export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined {
const elements = graph.nodes.filter(isElement);
const exact = elements.find((n) => n.id === ref);
if (exact) return exact;
const l = ref.toLowerCase();
return elements.find((n) => n.id.toLowerCase() === l) ?? elements.find((n) => n.label.toLowerCase() === l);
}
function globToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${escaped}$`, 'i');
}
function diagramMatches(path: string, pattern: string): boolean {
const bare = path.replace(/\.archify$/, '');
const wanted = pattern.replace(/\.archify$/, '');
if (wanted.includes('*') || wanted.includes('?')) return globToRegExp(wanted).test(bare) || globToRegExp(wanted).test(path);
const l = wanted.toLowerCase();
return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`);
}
/** Element-to-element adjacency over the diagrams' connections; decision and registry links do not count as steps. */
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const n of graph.nodes) map.set(n.id, new Set());
for (const e of graph.edges.filter(isConnection)) {
map.get(e.from)?.add(e.to);
map.get(e.to)?.add(e.from);
}
return map;
}
function within(graph: ModelGraph, start: string, hops: number): Set<string> {
const adjacency = neighbours(graph);
let frontier = new Set([start]);
const seen = new Set([start]);
for (let i = 0; i < hops; i += 1) {
const next = new Set<string>();
for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); }
frontier = next;
if (!frontier.size) break;
}
return seen;
}
/** Shortest path by breadth-first search; directed first, then ignoring direction. */
export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined {
const attempt = (directed: boolean) => {
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
for (const n of graph.nodes) out.set(n.id, []);
for (const e of graph.edges.filter(isConnection)) {
out.get(e.from)?.push({ to: e.to, edge: e });
if (!directed) out.get(e.to)?.push({ to: e.from, edge: e });
}
const previous = new Map<string, { from: string; edge: GraphEdge }>();
const queue = [from];
const seen = new Set([from]);
while (queue.length) {
const id = queue.shift()!;
if (id === to) break;
for (const step of out.get(id) ?? []) {
if (seen.has(step.to)) continue;
seen.add(step.to);
previous.set(step.to, { from: id, edge: step.edge });
queue.push(step.to);
}
}
if (!seen.has(to)) return undefined;
const nodes = [to];
const edges: string[] = [];
let at = to;
while (at !== from) {
const step = previous.get(at)!;
edges.unshift(step.edge.id);
nodes.unshift(step.from);
at = step.from;
}
return { nodes, edges };
};
if (from === to) return { nodes: [from], edges: [] };
return attempt(true) ?? attempt(false);
}
export function runQuery(graph: ModelGraph, text: string): QueryResult {
const terms = parseQuery(text);
const everything = new Set(graph.nodes.map((n) => n.id));
let result = new Set(everything);
const notes: string[] = [];
let from: string | undefined;
let to: string | undefined;
let pathEdges: Set<string> | undefined;
let path: string[] | undefined;
const matching = (term: QueryTerm): Set<string> | undefined => {
const v = term.value;
switch (term.key) {
case 'text': {
const l = v.toLowerCase();
return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id));
}
case 'type':
return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id));
case 'diagram':
return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id));
case 'decision': {
const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id));
for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); }
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`);
else ids.add(`decision:${v}`);
return ids;
}
case 'shared':
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id));
case 'unused':
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared && n.diagrams.length === 0).map((n) => n.id));
case 'uses':
case 'near': {
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
const node = findNode(graph, ref);
if (!node) { notes.push(`No element "${ref}".`); return new Set(); }
const hops = Math.max(1, Math.min(Number(hopsText) || 1, 50));
return within(graph, node.id, hops);
}
case 'from':
from = v;
return undefined;
case 'to':
to = v;
return undefined;
}
return undefined;
};
const negated: Set<string>[] = [];
for (const term of terms) {
const ids = matching(term);
if (!ids) continue;
if (term.negate) {
negated.push(ids);
for (const id of ids) result.delete(id);
} else result = new Set([...result].filter((id) => ids.has(id)));
}
// Decisions and registries follow their elements: a matching element brings the nodes attached to it.
if (terms.length) {
for (const e of graph.edges) {
if (isConnection(e)) continue;
if (result.has(e.to) && graph.nodes.some((n) => n.id === e.from && !isElement(n))) result.add(e.from);
}
for (const ids of negated) for (const id of ids) result.delete(id);
}
if (from || to) {
if (!from || !to) notes.push('A path needs both from: and to:.');
else {
const a = findNode(graph, from);
const b = findNode(graph, to);
if (!a || !b) notes.push(`No element "${!a ? from : to}".`);
else {
const found = shortestPath(graph, a.id, b.id);
if (!found) notes.push(`No path from ${a.id} to ${b.id}.`);
else {
path = found.nodes;
pathEdges = new Set(found.edges);
result = new Set(found.nodes.filter((id) => result.has(id)));
}
}
if (!path) result = new Set();
}
}
const edges = new Set<string>();
for (const e of graph.edges) {
if (!result.has(e.from) || !result.has(e.to)) continue;
if (pathEdges && !pathEdges.has(e.id)) continue;
edges.add(e.id);
}
return { nodes: result, edges, path, message: notes[0], terms };
}
+89 -5
View File
@@ -1,6 +1,18 @@
import { DiagramSummary, RegistryElement } from './format'; import { Decision, effectiveStatus, sortDecisions } from './decisions';
import { DecisionAttachment, DiagramSummary, RegistryElement } from './format';
import { ModelIndex } from './service'; 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. */ /** A diagram an element is used in, as the pages show it. */
export interface UsedIn { export interface UsedIn {
path: string; path: string;
@@ -22,6 +34,8 @@ export interface ElementCard {
file: string; file: string;
line: number; line: number;
usedIn: UsedIn[]; 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. */ /** What the model page shows; serialised into the webview. */
@@ -31,10 +45,27 @@ export interface ModelPanel {
hasDiagram: boolean; hasDiagram: boolean;
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[]; groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
registry: ElementCard[]; registry: ElementCard[];
decisions: DecisionRow[];
problems: string[]; problems: string[];
nested: 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. */ /** What the registry page (one .archify-registry file) shows. */
export interface RegistryPanel { export interface RegistryPanel {
/** The registry file, relative to the model root (or its base name outside a model). */ /** 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 { return {
id: e.id, id: e.id,
label: e.label, label: e.label,
@@ -64,22 +110,25 @@ export function elementCard(e: RegistryElement, usage: Map<string, string[]>, di
file: e.file, file: e.file,
line: e.line, line: e.line,
usedIn: usedIn(e.id, usage, diagrams), usedIn: usedIn(e.id, usage, diagrams),
decisions: decisionsOfElement(e.id, index),
}; };
} }
const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id); const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id);
export function panelFromIndex(index: ModelIndex): ModelPanel { 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 { return {
meta: index.meta, meta: index.meta,
decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)),
hasDiagram: index.mainHasDiagram, hasDiagram: index.mainHasDiagram,
groups: index.groups.map((g) => ({ groups: index.groups.map((g) => ({
group: g.group, group: g.group,
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })), diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
})), })),
registry, 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, nested: index.nested,
}; };
} }
@@ -89,7 +138,7 @@ export function registryPanelFromIndex(index: ModelIndex, file: string): Registr
const elements = [...index.registry.elements.values()] const elements = [...index.registry.elements.values()]
.filter((e) => e.file === file) .filter((e) => e.file === file)
.sort((a, b) => a.line - b.line) .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 { return {
file, file,
inModel: true, inModel: true,
@@ -108,3 +157,38 @@ export function registryPanelStandalone(file: string, parsed: { elements: Regist
problems: parsed.problems.map((p) => p.message), problems: parsed.problems.map((p) => p.message),
}; };
} }
/** The decision page of one file inside a model. */
export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel {
const all = index.decisions.decisions;
const d = [...all.values()].find((x) => x.file === file);
const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message);
const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems };
if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] };
const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all));
const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index));
const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path);
return {
...base,
decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) },
supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)),
elements,
diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })),
referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })),
};
}
/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */
export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel {
const d = parsed.decision;
return {
file,
inModel: false,
decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined,
supersededBy: [],
elements: [],
diagrams: d ? d.diagrams.map((path) => ({ path })) : [],
referencedBy: [],
problems: parsed.problems.map((p) => p.message),
};
}
+39 -5
View File
@@ -1,6 +1,8 @@
import path from 'node:path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { DiagramSource, readDiagramSource } from '../sources/detect'; 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'; import { ModelService } from './service';
/** /**
@@ -16,6 +18,18 @@ export interface PreparedDiagram {
problems: ModelProblem[]; problems: ModelProblem[];
/** Node id → diagram path relative to the model root. */ /** Node id → diagram path relative to the model root. */
links: Map<string, string>; links: Map<string, string>;
/** Node id → registry description. */
descriptions: Map<string, string>;
/** Decisions of this diagram for the Decisions rail panel: pointed at by the diagram, or naming it. */
decisions: DiagramDecision[];
}
export interface DiagramDecision {
id: string;
title?: string;
status: string;
file: string;
attachedTo: DecisionAttachment[];
} }
/** /**
@@ -26,12 +40,32 @@ export interface PreparedDiagram {
export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> { export async function prepareDiagram(models: ModelService, uri: vscode.Uri, text: string): Promise<PreparedDiagram | undefined> {
const source = readDiagramSource(text, uri.path); const source = readDiagramSource(text, uri.path);
if (!source) return undefined; if (!source) return undefined;
const found = await models.registryFor(uri); const index = await models.indexFor(uri);
const resolved = resolveRefs(source.diagram, { registry: found?.registry, fileName: uri.path }); 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. // 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 const prepared: DiagramSource = changed
? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) } ? { ...source, diagram: resolved.diagram, json: JSON.stringify(resolved.diagram, null, 2) }
: source; : source;
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links }; // Decisions for the rail: the ones this diagram points at, plus the ones that name this diagram.
const decisions: DiagramDecision[] = [];
if (index) {
const rel = path.relative(index.root.fsPath, uri.fsPath).split(path.sep).join('/');
const byId = new Map<string, DiagramDecision>();
const add = (id: string, attachedTo?: DecisionAttachment) => {
const d = index.decisions.decisions.get(id);
if (!d) return;
let row = byId.get(id);
if (!row) {
row = { id, title: d.title, status: effectiveStatus(d, index.decisions.decisions).status, file: d.file, attachedTo: [] };
byId.set(id, row);
decisions.push(row);
}
if (attachedTo && !row.attachedTo.some((a) => JSON.stringify(a) === JSON.stringify(attachedTo))) row.attachedTo.push(attachedTo);
};
for (const ref of resolved.decisionRefs) add(ref.id, ref.attachedTo);
for (const d of index.decisions.decisions.values()) if (d.diagrams.includes(rel)) add(d.id, { kind: 'diagram' });
}
return { source: prepared, root: found?.root, registry: found?.registry, problems: resolved.problems, links: resolved.links, descriptions: resolved.descriptions, decisions };
} }
+27 -3
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { log } from '../util/log'; import { log } from '../util/log';
import { import {
DecisionAttachment,
DiagramSummary, DiagramSummary,
MAIN_FILE, MAIN_FILE,
ModelMeta, ModelMeta,
@@ -17,6 +18,7 @@ import {
summarizeDiagram, summarizeDiagram,
} from './format'; } from './format';
import { parse as parseYaml } from 'yaml'; 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 * 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[] }[]; groups: { group: string; diagrams: DiagramSummary[] }[];
/** Element id → diagrams (relative paths) that refer to it. */ /** Element id → diagrams (relative paths) that refer to it. */
usage: Map<string, string[]>; 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 main.archify files found below the root; not supported. */
nested: string[]; nested: string[];
} }
@@ -71,7 +77,7 @@ export class ModelService implements vscode.Disposable {
private readonly disposables: vscode.Disposable[]; private readonly disposables: vscode.Disposable[];
constructor() { 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); const changed = (uri: vscode.Uri) => void this.onFileChanged(uri);
this.disposables = [ this.disposables = [
watcher, watcher,
@@ -80,7 +86,7 @@ export class ModelService implements vscode.Disposable {
watcher.onDidChange(changed), watcher.onDidChange(changed),
vscode.workspace.onDidChangeTextDocument((event) => { vscode.workspace.onDidChangeTextDocument((event) => {
// Registry edits in the editor should re-render open diagrams before a save. // 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, this.changeEmitter,
]; ];
@@ -118,6 +124,12 @@ export class ModelService implements vscode.Disposable {
return found ?? undefined; 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. */ /** 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> { async registryFor(uri: vscode.Uri): Promise<{ root: vscode.Uri; registry: Registry } | undefined> {
const root = await this.rootOf(uri); const root = await this.rootOf(uri);
@@ -175,7 +187,19 @@ export class ModelService implements vscode.Disposable {
} }
const usage = new Map<string, string[]>(); 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]); 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 { dispose(): void {
+2 -12
View File
@@ -2,6 +2,7 @@ import { layerMarkup, rasterize } from './raster';
import { CameraState, Layers, Stage } from './stage'; import { CameraState, Layers, Stage } from './stage';
import { DiagramModel, readModel } from './svgModel'; import { DiagramModel, readModel } from './svgModel';
import { STYLE } from './style'; import { STYLE } from './style';
import type { ArchifyHost } from '../webview/host';
/** /**
* 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's * 3D mode for the Archify viewer. It adds a 2D | 3D switch to Archify's
@@ -12,12 +13,7 @@ import { STYLE } from './style';
* follows Archify's animations as they play. * follows Archify's animations as they play.
*/ */
interface Host { type Host = ArchifyHost;
defaultView: '2d' | '3d';
post(message: unknown): void;
getState(): Record<string, unknown>;
setState(patch: Record<string, unknown>): void;
}
interface SavedView { interface SavedView {
active?: boolean; active?: boolean;
@@ -25,12 +21,6 @@ interface SavedView {
camera?: CameraState; camera?: CameraState;
} }
declare global {
interface Window {
__archifyHost?: Host;
}
}
const REDRAW_MS = 220; const REDRAW_MS = 220;
const MARGIN = 80; const MARGIN = 80;
/** Texture pixels per SVG unit; more costs memory and upload time for little gain. */ /** Texture pixels per SVG unit; more costs memory and upload time for little gain. */
+152
View File
@@ -0,0 +1,152 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { isDecisionFile, parseDecision } from '../model/decisions';
import { isMainFile, isRegistryFile } from '../model/format';
import { DecisionPanel, decisionPanelFromIndex, decisionPanelStandalone } from '../model/panel';
import { ModelService } from '../model/service';
import { readText } from '../util/files';
import { log } from '../util/log';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { decisionPage, makeNonce } from '../webview/page';
import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
export { DECISION_VIEW_TYPE };
class DecisionDocument implements vscode.CustomDocument {
constructor(readonly uri: vscode.Uri) {}
dispose(): void {}
}
class DecisionView implements vscode.Disposable {
private seq = 0;
private timer: NodeJS.Timeout | undefined;
private last: string | undefined;
root: vscode.Uri | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(
private readonly models: ModelService,
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
) {
this.disposables.push(
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
if (message.type === 'openDecision') return void this.openInModel(message.file, DECISION_VIEW_TYPE);
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
void this.update();
}
private async openInModel(relativePath: string, viewType: string): Promise<void> {
if (!this.root) return;
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
try {
await vscode.workspace.fs.stat(target);
} catch {
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
return;
}
await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
}
private async openRegistryLine(file: string, line?: number): Promise<void> {
if (!this.root) return;
const target = vscode.Uri.joinPath(this.root, ...file.split('/'));
const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
const at = Math.max(line ?? 0, 0);
await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) });
}
scheduleUpdate(): void {
clearTimeout(this.timer);
this.timer = setTimeout(() => void this.update(), 300);
}
async update(): Promise<void> {
clearTimeout(this.timer);
const seq = ++this.seq;
let decision: DecisionPanel;
try {
this.root = await this.models.rootOf(this.uri);
if (this.root) {
const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/');
decision = decisionPanelFromIndex(await this.models.index(this.root), rel);
} else {
const name = path.basename(this.uri.path);
decision = decisionPanelStandalone(name, parseDecision(await readText(this.uri), name));
}
} catch (error) {
log().warn(`Decision view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (seq !== this.seq) return;
const fingerprint = JSON.stringify(decision);
if (fingerprint === this.last) return;
this.last = fingerprint;
this.panel.webview.html = decisionPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, decision);
}
dispose(): void {
clearTimeout(this.timer);
for (const d of this.disposables.splice(0)) d.dispose();
}
}
/** Read-only custom editor for .archify-decision files: the decision as a page, with its elements and diagrams. */
export class DecisionViewProvider implements vscode.CustomReadonlyEditorProvider<DecisionDocument>, vscode.Disposable {
private readonly views = new Set<DecisionView>();
private active: DecisionView | undefined;
private readonly disposables: vscode.Disposable[];
constructor(private readonly models: ModelService) {
this.disposables = [
vscode.window.registerCustomEditorProvider(DECISION_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
supportsMultipleEditorsPerDocument: true,
}),
models.onDidChangeModel(({ root }) => {
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
}),
vscode.workspace.onDidChangeTextDocument((event) => {
if (!isDecisionFile(event.document.fileName) && !isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
}),
];
}
/** File shown in the focused decision view, if any. */
get activeUri(): vscode.Uri | undefined {
return this.active?.uri;
}
openCustomDocument(uri: vscode.Uri): DecisionDocument {
return new DecisionDocument(uri);
}
async resolveCustomEditor(document: DecisionDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true };
const view = new DecisionView(this.models, panel, document.uri);
this.views.add(view);
const onViewState = () => {
if (panel.active) this.active = view;
else if (this.active === view) this.active = undefined;
if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root));
};
panel.onDidChangeViewState(onViewState);
onViewState();
panel.onDidDispose(() => {
this.views.delete(view);
if (this.active === view) this.active = undefined;
view.dispose();
});
}
dispose(): void {
for (const view of this.views) view.dispose();
this.views.clear();
for (const d of this.disposables.splice(0)) d.dispose();
}
}
+125 -17
View File
@@ -2,7 +2,8 @@ import path from 'node:path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { renderDiagram } from '../archify/cli'; import { renderDiagram } from '../archify/cli';
import { evidenceMode, runtimeFor } from '../archify/runtime'; import { evidenceMode, runtimeFor } from '../archify/runtime';
import { isMainFile, isRegistryFile } from '../model/format'; import { MAIN_FILE, isMainFile, isRegistryFile } from '../model/format';
import { buildGraph } from '../model/graph';
import { ModelPanel, panelFromIndex } from '../model/panel'; import { ModelPanel, panelFromIndex } from '../model/panel';
import { prepareDiagram } from '../model/prepare'; import { prepareDiagram } from '../model/prepare';
import { ModelService } from '../model/service'; import { ModelService } from '../model/service';
@@ -11,18 +12,28 @@ import { readText } from '../util/files';
import { log } from '../util/log'; import { log } from '../util/log';
import { viewerTheme } from '../util/settings'; import { viewerTheme } from '../util/settings';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page'; import { graphPage, makeNonce, messagePage, modelPage, prepareDiagramHtml } from '../webview/page';
import { viewer3dOption, viewerRoots } from '../webview/viewer3d'; import { graphScriptUri, viewer3dOption, viewerRoots } from '../webview/viewer3d';
export const PREVIEW_VIEW_TYPE = 'archify.preview'; import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
/** Default editor for .archify files: the rendered diagram. */
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram'; export { DIAGRAM_EDITOR_VIEW_TYPE, PREVIEW_VIEW_TYPE };
interface PreviewState { interface PreviewState {
uri: string; uri: string;
hash?: string; hash?: string;
/** main.archify only: the Graph page is shown instead of the model page. */
graph?: boolean;
} }
/** What a preview asks of its manager. */
interface PreviewHost {
/** Opens the Graph page of the model at `root`, with a query to start from. */
openGraph(root: vscode.Uri, query?: string): Promise<void>;
}
const GRAPH_MODE_KEY = 'archify.graphMode';
class DiagramPreview implements vscode.Disposable { class DiagramPreview implements vscode.Disposable {
private seq = 0; private seq = 0;
private hash: string | undefined; private hash: string | undefined;
@@ -34,10 +45,16 @@ class DiagramPreview implements vscode.Disposable {
readonly onDidDispose = this.onDisposeEmitter.event; readonly onDidDispose = this.onDisposeEmitter.event;
/** Root of the model this file belongs to, once known. */ /** Root of the model this file belongs to, once known. */
root: vscode.Uri | undefined; root: vscode.Uri | undefined;
/** main.archify: show the Graph page instead of the model page. Remembered per file across reloads. */
private graph = false;
private graphQuery: string | undefined;
/** True while the webview shows the Graph page, which takes model updates as messages. */
private graphShown = false;
constructor( constructor(
private readonly context: vscode.ExtensionContext, private readonly context: vscode.ExtensionContext,
private readonly models: ModelService, private readonly models: ModelService,
private readonly host: PreviewHost,
readonly panel: vscode.WebviewPanel, readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri, readonly uri: vscode.Uri,
state?: PreviewState, state?: PreviewState,
@@ -45,6 +62,7 @@ class DiagramPreview implements vscode.Disposable {
readonly isEditor = false, readonly isEditor = false,
) { ) {
this.hash = state?.hash; this.hash = state?.hash;
this.graph = state?.graph ?? this.graphModes()[uri.toString()] === true;
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) }; panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
if (!isEditor) { if (!isEditor) {
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg'); panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
@@ -59,7 +77,24 @@ class DiagramPreview implements vscode.Disposable {
return; return;
} }
if (message.type === 'openDiagram') { if (message.type === 'openDiagram') {
void this.openLinked(message.path); void this.openLinked(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
return;
}
if (message.type === 'openRegistry') {
void this.openLinked(message.file, REGISTRY_VIEW_TYPE);
return;
}
if (message.type === 'openDecision') {
void this.openLinked(message.file, DECISION_VIEW_TYPE);
return;
}
if (message.type === 'showGraph') {
if (isMainFile(this.uri.path)) this.setGraph(true, message.query);
else if (this.root) void this.host.openGraph(this.root, message.query);
return;
}
if (message.type === 'showModel') {
this.setGraph(false);
return; return;
} }
void handleWebviewMessage(message, this.uri, this.panel); void handleWebviewMessage(message, this.uri, this.panel);
@@ -81,8 +116,51 @@ class DiagramPreview implements vscode.Disposable {
void this.update(); void this.update();
} }
/** Posts to the webview; a panel closed mid-update is not an error. */
private post(message: unknown): void {
Promise.resolve(this.panel.webview.postMessage(message)).catch(() => undefined);
}
private state(): PreviewState { private state(): PreviewState {
return { uri: this.uri.toString(), hash: this.hash }; return { uri: this.uri.toString(), hash: this.hash, graph: this.graph };
}
private graphModes(): Record<string, boolean> {
return this.context.workspaceState.get<Record<string, boolean>>(GRAPH_MODE_KEY, {});
}
/** Switches a main.archify editor between its model page and the Graph page. */
setGraph(on: boolean, query?: string): void {
this.graph = on;
this.graphQuery = query;
const modes = this.graphModes();
if (on) modes[this.uri.toString()] = true;
else delete modes[this.uri.toString()];
void this.context.workspaceState.update(GRAPH_MODE_KEY, modes);
this.lastHtml = undefined;
this.rendered = false;
if (!on) this.graphShown = false;
else if (this.graphShown && query !== undefined) this.post({ type: 'query', query });
void this.update(true);
}
/** The Graph page of the model, or an update of it when it is already showing. */
private showGraphPage(index: Awaited<ReturnType<ModelService['index']>>): void {
const graph = buildGraph(index);
const fingerprint = `graph\u0000${JSON.stringify(graph)}`;
if (fingerprint === this.lastHtml && this.graphShown) return;
this.lastHtml = fingerprint;
this.rendered = false;
if (this.graphShown) {
this.post({ type: 'graph', graph });
return;
}
this.graphShown = true;
this.panel.webview.html = graphPage(
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
{ graph, query: this.graphQuery, scriptUri: graphScriptUri(this.panel.webview, this.context.extensionUri) },
);
this.graphQuery = undefined;
} }
private showMessage(title: string, body: string, detail?: string): void { private showMessage(title: string, body: string, detail?: string): void {
@@ -96,7 +174,7 @@ class DiagramPreview implements vscode.Disposable {
} }
/** Opens a diagram of the model, given its path relative to the model root. */ /** Opens a diagram of the model, given its path relative to the model root. */
private async openLinked(relativePath: string): Promise<void> { private async openLinked(relativePath: string, viewType: string): Promise<void> {
if (!this.root) return; if (!this.root) return;
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/')); const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
try { try {
@@ -105,7 +183,7 @@ class DiagramPreview implements vscode.Disposable {
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`); vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
return; return;
} }
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active); await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
} }
private showModelPage(model: ModelPanel): void { private showModelPage(model: ModelPanel): void {
@@ -113,6 +191,7 @@ class DiagramPreview implements vscode.Disposable {
if (fingerprint === this.lastHtml) return; if (fingerprint === this.lastHtml) return;
this.lastHtml = fingerprint; this.lastHtml = fingerprint;
this.rendered = false; this.rendered = false;
this.graphShown = false;
this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model); this.panel.webview.html = modelPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() }, model);
} }
@@ -138,7 +217,13 @@ class DiagramPreview implements vscode.Disposable {
if (main) { if (main) {
this.root = vscode.Uri.file(path.dirname(this.uri.fsPath)); this.root = vscode.Uri.file(path.dirname(this.uri.fsPath));
try { try {
model = panelFromIndex(await this.models.index(this.root)); const index = await this.models.index(this.root);
if (seq !== this.seq) return;
if (this.graph) {
this.showGraphPage(index);
return;
}
model = panelFromIndex(index);
} catch (error) { } catch (error) {
log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`); log().warn(`Model index failed for ${this.root.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
} }
@@ -151,6 +236,7 @@ class DiagramPreview implements vscode.Disposable {
} }
if (seq !== this.seq) return; if (seq !== this.seq) return;
if (!main) this.root = prepared?.root; if (!main) this.root = prepared?.root;
this.graphShown = false;
if (!prepared) { if (!prepared) {
if (model) { if (model) {
this.showModelPage(model); this.showModelPage(model);
@@ -176,7 +262,7 @@ class DiagramPreview implements vscode.Disposable {
if (!result.ok || !result.html) { if (!result.ok || !result.html) {
log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`); log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`);
if (this.rendered && this.lastHtml) { if (this.rendered && this.lastHtml) {
void this.panel.webview.postMessage({ this.post({
type: 'notice', type: 'notice',
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` }, notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
}); });
@@ -195,12 +281,15 @@ class DiagramPreview implements vscode.Disposable {
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined, notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri), viewer3d: viewer3dOption(this.panel.webview, this.context.extensionUri),
links: Object.fromEntries(prepared.links), links: Object.fromEntries(prepared.links),
descriptions: Object.fromEntries(prepared.descriptions),
decisions: prepared.decisions,
model, model,
graphSwitch: main ? { left: 'Model' } : prepared.root ? { left: 'Diagram', query: `diagram:${path.relative(prepared.root.fsPath, this.uri.fsPath).split(path.sep).join('/')}` } : undefined,
}); });
// Nonces differ per render, so compare the renderer output instead. // Nonces differ per render, so compare the renderer output instead.
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links]])}\u0000${result.html}`; const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${JSON.stringify([model ?? null, [...prepared.links], [...prepared.descriptions], prepared.decisions])}\u0000${result.html}`;
if (!force && this.rendered && fingerprint === this.lastHtml) { if (!force && this.rendered && fingerprint === this.lastHtml) {
void this.panel.webview.postMessage({ type: 'notice', notice: undefined }); this.post({ type: 'notice', notice: undefined });
return; return;
} }
this.lastHtml = fingerprint; this.lastHtml = fingerprint;
@@ -276,10 +365,29 @@ export class DiagramViewManager
return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key); return [...this.previews.values(), ...this.editors].filter((preview) => preview.uri.toString() === key);
} }
private readonly host: PreviewHost = { openGraph: (root, query) => this.showGraph(root, query) };
/** Opening a .archify file lands here: the diagram is the editor. */ /** Opening a .archify file lands here: the diagram is the editor. */
async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> { async resolveCustomTextEditor(document: vscode.TextDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) }; panel.webview.options = { enableScripts: true, localResourceRoots: viewerRoots(this.context.extensionUri) };
this.track(new DiagramPreview(this.context, this.models, panel, document.uri, undefined, true)); this.track(new DiagramPreview(this.context, this.models, this.host, panel, document.uri, undefined, true));
}
/** Opens the model's main.archify as its Graph page, with a query to start from. */
async showGraph(root: vscode.Uri, query?: string): Promise<void> {
const main = vscode.Uri.joinPath(root, MAIN_FILE);
await vscode.commands.executeCommand('vscode.openWith', main, DIAGRAM_EDITOR_VIEW_TYPE, vscode.ViewColumn.Active);
const key = main.toString();
const deadline = Date.now() + 5000;
for (;;) {
const editor = [...this.editors].find((e) => e.uri.toString() === key && e.panel.active) ?? [...this.editors].find((e) => e.uri.toString() === key);
if (editor) {
editor.setGraph(true, query);
return;
}
if (Date.now() > deadline) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
} }
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void { show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
@@ -294,7 +402,7 @@ export class DiagramViewManager
{ viewColumn, preserveFocus }, { viewColumn, preserveFocus },
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) }, { enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: viewerRoots(this.context.extensionUri) },
); );
this.track(new DiagramPreview(this.context, this.models, panel, uri)); this.track(new DiagramPreview(this.context, this.models, this.host, panel, uri));
} }
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> { async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
@@ -302,7 +410,7 @@ export class DiagramViewManager
panel.dispose(); panel.dispose();
return; return;
} }
this.track(new DiagramPreview(this.context, this.models, panel, vscode.Uri.parse(state.uri), state)); this.track(new DiagramPreview(this.context, this.models, this.host, panel, vscode.Uri.parse(state.uri), state));
} }
private track(preview: DiagramPreview): void { private track(preview: DiagramPreview): void {
+3 -3
View File
@@ -7,10 +7,9 @@ import { readText } from '../util/files';
import { log } from '../util/log'; import { log } from '../util/log';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages'; import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, registryPage } from '../webview/page'; import { makeNonce, registryPage } from '../webview/page';
import { DIAGRAM_EDITOR_VIEW_TYPE } from './diagramView'; import { DECISION_VIEW_TYPE, DIAGRAM_EDITOR_VIEW_TYPE, REGISTRY_VIEW_TYPE } from './viewTypes';
/** Default editor for .archify-registry files: the elements as cards, each with where it is used. */ export { REGISTRY_VIEW_TYPE };
export const REGISTRY_VIEW_TYPE = 'archify.registry';
class RegistryDocument implements vscode.CustomDocument { class RegistryDocument implements vscode.CustomDocument {
constructor(readonly uri: vscode.Uri) {} constructor(readonly uri: vscode.Uri) {}
@@ -33,6 +32,7 @@ class RegistryView implements vscode.Disposable {
panel.webview.onDidReceiveMessage((message: WebviewMessage) => { panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE); 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 === '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 handleWebviewMessage(message, this.uri, this.panel);
}), }),
); );
+8
View File
@@ -0,0 +1,8 @@
/** Custom editor view types, in one place so views can refer to each other without importing each other. */
export const PREVIEW_VIEW_TYPE = 'archify.preview';
/** Default editor for .archify files: the rendered diagram. */
export const DIAGRAM_EDITOR_VIEW_TYPE = 'archify.diagram';
/** Default editor for .archify-registry files: the elements as cards. */
export const REGISTRY_VIEW_TYPE = 'archify.registry';
/** Default editor for .archify-decision files: the decision as a page. */
export const DECISION_VIEW_TYPE = 'archify.decision';
+20
View File
@@ -0,0 +1,20 @@
import type { ModelGraph } from '../model/graph';
/**
* What the bridge script (page.ts) exposes to the bundled page scripts as
* `window.__archifyHost`: they cannot call acquireVsCodeApi a second time.
*/
export interface ArchifyHost {
defaultView: '2d' | '3d';
post(message: unknown): void;
getState(): Record<string, unknown>;
setState(patch: Record<string, unknown>): void;
/** The Graph page's data, present on that page only. */
graph?: { graph: ModelGraph; query?: string };
}
declare global {
interface Window {
__archifyHost?: ArchifyHost;
}
}
+5
View File
@@ -11,6 +11,9 @@ export type WebviewMessage =
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string } | { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
| { type: 'openDiagram'; path: string } | { type: 'openDiagram'; path: string }
| { type: 'openRegistry'; file: string; line?: number } | { type: 'openRegistry'; file: string; line?: number }
| { type: 'openDecision'; file: string }
| { type: 'showGraph'; query?: string }
| { type: 'showModel' }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
/** Column for opening files next to a webview without covering it. */ /** Column for opening files next to a webview without covering it. */
@@ -95,6 +98,8 @@ export async function handleWebviewMessage(
break; break;
case 'hash': case 'hash':
case 'openDiagram': case 'openDiagram':
case 'showGraph':
case 'showModel':
// Handled by the view, which knows the model root. // Handled by the view, which knows the model root.
break; break;
} }
+343 -11
View File
@@ -18,11 +18,23 @@ export interface PageOptions {
links?: Record<string, string>; links?: Record<string, string>;
/** The model page: metadata above the diagram, the diagram list and registry below. */ /** The model page: metadata above the diagram, the diagram list and registry below. */
model?: ModelPanel; model?: ModelPanel;
/** Node id → registry description, shown as tooltips in the Elements panel. */
descriptions?: Record<string, string>;
/** Registry page: the elements of one .archify-registry file. */ /** Registry page: the elements of one .archify-registry file. */
registry?: RegistryPanel; registry?: RegistryPanel;
/** Decision page: one .archify-decision file. */
decision?: DecisionPanel;
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
decisions?: DiagramDecision[];
/** Pages inside a model get a switch to the model's Graph page; `left` names the page they are on. */
graphSwitch?: { left: 'Model' | 'Diagram'; query?: string };
/** The Graph page: the model as one graph, the bundled script that draws it, and the query to start with. */
graph?: { graph: ModelGraph; query?: string; scriptUri: string };
} }
import type { ModelPanel, RegistryPanel } from '../model/panel'; import type { ModelGraph } from '../model/graph';
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
import type { DiagramDecision } from '../model/prepare';
export function makeNonce(): string { export function makeNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -77,6 +89,11 @@ function bridgeScript(options: PageOptions): string {
links: options.links ?? {}, links: options.links ?? {},
model: options.model, model: options.model,
registry: options.registry, registry: options.registry,
decision: options.decision,
decisions: options.decisions || [],
descriptions: options.descriptions || {},
graphSwitch: options.graphSwitch,
graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined,
}; };
return `(function () { return `(function () {
var config = ${scriptJson(config)}; var config = ${scriptJson(config)};
@@ -100,7 +117,8 @@ function bridgeScript(options: PageOptions): string {
defaultView: config.defaultView || '2d', defaultView: config.defaultView || '2d',
post: post, post: post,
getState: function () { return vscode.getState() || {}; }, getState: function () { return vscode.getState() || {}; },
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); } setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); },
graph: config.graph
}; };
function saveBlobUrl(url, filename) { function saveBlobUrl(url, filename) {
@@ -189,6 +207,7 @@ function bridgeScript(options: PageOptions): string {
// Collapsible panels of the model page; open/closed is kept in the webview state. // Collapsible panels of the model page; open/closed is kept in the webview state.
function panelStates() { return (vscode.getState() || {}).modelPanels || {}; } function panelStates() { return (vscode.getState() || {}).modelPanels || {}; }
function panel(key, className, title, count, defaultOpen) { function panel(key, className, title, count, defaultOpen) {
// count: a number or a short text such as "3 files".
var details = el('details', 'archify-model-panel ' + className); var details = el('details', 'archify-model-panel ' + className);
var saved = panelStates()[key]; var saved = panelStates()[key];
details.open = saved === undefined ? defaultOpen : saved; details.open = saved === undefined ? defaultOpen : saved;
@@ -230,12 +249,48 @@ function bridgeScript(options: PageOptions): string {
}); });
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet')); } else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
list.appendChild(used); 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.appendChild(list);
card.addEventListener('click', function () { openElementModal(element); }); card.addEventListener('click', function () { openElementModal(element); });
card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } }); card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } });
return card; 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. // Modal: one element, where it is defined and the diagrams that use it.
function openElementModal(element) { function openElementModal(element) {
closeElementModal(); closeElementModal();
@@ -288,6 +343,12 @@ function bridgeScript(options: PageOptions): string {
}); });
modal.appendChild(list); 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.appendChild(modal);
backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); }); backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); });
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
@@ -321,6 +382,139 @@ function bridgeScript(options: PageOptions): string {
document.body.appendChild(body); document.body.appendChild(body);
} }
// The decision page: one .archify-decision file.
function renderDecision(page) {
if (!page) return;
var d = page.decision;
var head = el('section', 'archify-model archify-model-head');
head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : '')));
head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file));
if (d) {
var meta = el('p', 'archify-decision-meta');
meta.appendChild(statusBadge(d));
if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date));
meta.appendChild(el('span', 'archify-model-item-meta', page.file));
head.appendChild(meta);
}
if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.'));
page.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
document.body.appendChild(head);
var body = el('section', 'archify-model archify-model-body');
if (d) {
[['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) {
if (!pair[1]) return;
var section = el('section', 'archify-decision-section');
section.appendChild(el('h2', 'archify-model-title', pair[0]));
section.appendChild(el('p', 'archify-decision-text', pair[1].trim()));
body.appendChild(section);
});
if (d.supersedes.length || page.supersededBy.length) {
var rel = el('section', 'archify-decision-section');
rel.appendChild(el('h2', 'archify-model-title', 'Related decisions'));
var relList = el('ul', 'archify-model-list archify-decision-list');
d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); });
page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); });
rel.appendChild(relList);
body.appendChild(rel);
}
}
if (page.elements.length) {
var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true);
var cards = el('div', 'cards archify-model-cards');
page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
elementsPanel.appendChild(cards);
body.appendChild(elementsPanel);
}
var diagramRows = [];
page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); });
page.referencedBy.forEach(function (use) {
var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram';
var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0];
if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how });
});
if (diagramRows.length) {
var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true);
var list = el('ul', 'archify-model-list');
diagramRows.forEach(function (row) {
var item = el('li', 'archify-model-item');
var button = el('button', 'archify-model-open');
button.type = 'button';
button.title = row.path;
button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop()));
button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how));
button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); });
item.appendChild(button);
list.appendChild(item);
});
diagramsPanel.appendChild(list);
body.appendChild(diagramsPanel);
}
document.body.appendChild(body);
}
// Decisions rail panel on a diagram page: the decisions this diagram points
// at or that name it. Hover lights up what the decision is attached to.
function renderDecisionsRail() {
var rows = config.decisions || [];
var outline = document.getElementById('node-outline');
var svg = document.querySelector('.diagram-container > svg');
if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return;
var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions');
nav.id = 'archify-decisions';
var head = el('div', 'node-outline-head');
head.appendChild(el('strong', '', 'Decisions'));
head.appendChild(el('span', 'node-outline-count', String(rows.length)));
nav.appendChild(head);
var list = el('ol', 'node-outline-list');
var group = el('li', 'node-outline-group');
var items = el('ul');
function targets(row) {
var found = [];
row.attachedTo.forEach(function (a) {
if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]'));
if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]'));
});
return found;
}
function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); }
rows.forEach(function (row) {
var item = el('li');
var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status);
button.type = 'button';
button.style.setProperty('--outline-tone', 'var(--text-muted)');
var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', ');
button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : '');
var dot = el('span', 'node-outline-dot');
dot.setAttribute('aria-hidden', 'true');
button.appendChild(dot);
button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : '')));
button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : '')));
button.addEventListener('pointerenter', function () { mark(row, true); });
button.addEventListener('pointerleave', function () { mark(row, false); });
button.addEventListener('focus', function () { mark(row, true); });
button.addEventListener('blur', function () { mark(row, false); });
button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); });
item.appendChild(button);
items.appendChild(item);
});
group.appendChild(items);
list.appendChild(group);
nav.appendChild(list);
var connectors = document.getElementById('archify-connectors');
(connectors || outline).insertAdjacentElement('afterend', nav);
var archify = window.Archify;
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
}
// Elements: registry descriptions as tooltips on Archify's node index rows.
function describeElements() {
var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]');
Array.prototype.forEach.call(items, function (item) {
var text = config.descriptions[item.getAttribute('data-outline-node')];
if (text) item.title = text;
});
}
// Connectors: a rail panel next to Archify's Elements index, read from the // Connectors: a rail panel next to Archify's Elements index, read from the
// SVG's edges. Hover highlights the connector, click keeps it highlighted. // SVG's edges. Hover highlights the connector, click keeps it highlighted.
function renderConnectors() { function renderConnectors() {
@@ -446,17 +640,68 @@ function bridgeScript(options: PageOptions): string {
}); });
holder.appendChild(list); holder.appendChild(list);
}); });
// Registry elements as Archify info cards: the same cards a diagram shows below itself. // Registry: one collapsible header per registry file, its elements as cards inside.
if (model.registry.length) { if (model.registry.length) {
var registryPanel = panel('registry', 'archify-model-registry', 'Registry', model.registry.length, false); var files = [];
var byFile = {};
model.registry.forEach(function (element) {
if (!byFile[element.file]) { byFile[element.file] = []; files.push(element.file); }
byFile[element.file].push(element);
});
files.sort();
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), true);
body.appendChild(registryPanel); body.appendChild(registryPanel);
files.forEach(function (file) {
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
var filePanel = panel('registry:' + file, 'archify-model-folder archify-model-registry-file', file.replace(/\.archify-registry$/, ''), elements.length + (elements.length === 1 ? ' element' : ' elements'), false);
var open = el('button', 'archify-model-used-link archify-model-open-file', 'Open');
open.type = 'button';
open.title = 'Open ' + file;
open.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'openRegistry', file: file, line: 0 }); });
filePanel.firstChild.appendChild(open);
var cards = el('div', 'cards archify-model-cards'); var cards = el('div', 'cards archify-model-cards');
model.registry.forEach(function (element) { cards.appendChild(elementCard(element)); }); elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
registryPanel.appendChild(cards); filePanel.appendChild(cards);
registryPanel.appendChild(filePanel);
});
}
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
if (model.decisions && model.decisions.length) {
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
body.appendChild(decisionsPanel);
var decisionList = el('ul', 'archify-model-list archify-decision-list');
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
decisionsPanel.appendChild(decisionList);
} }
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body); if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
} }
// Model | Graph (or Diagram | Graph): Export's twin in Archify's toolbar,
// or at the top of the model page. Graph opens the model's Graph page.
function renderModeSwitch() {
var sw = config.graphSwitch;
if (!sw || document.getElementById('archify-mode-switch')) return;
var button = el('button', 'archify-mode-switch');
button.id = 'archify-mode-switch';
button.type = 'button';
button.title = 'Open the model as one graph, with a query box';
button.setAttribute('aria-label', sw.left + ' view; switch to the model graph');
button.appendChild(el('span', 'archify-mode-seg on', sw.left));
button.appendChild(el('span', 'archify-mode-seg', 'Graph'));
button.addEventListener('click', function (event) {
event.stopPropagation();
post({ type: 'showGraph', query: sw.query });
});
var exportWrap = document.querySelector('.toolbar .export-wrap');
var head = document.querySelector('.archify-model-head');
if (exportWrap && exportWrap.parentNode) exportWrap.parentNode.insertBefore(button, exportWrap);
else if (head) {
var row = el('div', 'archify-model-switch-row');
row.appendChild(button);
head.insertBefore(row, head.firstChild);
} else document.body.appendChild(button);
}
window.addEventListener('message', function (event) { window.addEventListener('message', function (event) {
var message = event.data || {}; var message = event.data || {};
if (message.type === 'notice') showNotice(message.notice); if (message.type === 'notice') showNotice(message.notice);
@@ -467,7 +712,11 @@ function bridgeScript(options: PageOptions): string {
} }
if (config.model) onReady(function () { renderModel(config.model); }); if (config.model) onReady(function () { renderModel(config.model); });
if (config.registry) onReady(function () { renderRegistry(config.registry); }); if (config.registry) onReady(function () { renderRegistry(config.registry); });
if (config.decision) onReady(function () { renderDecision(config.decision); });
onReady(renderConnectors); onReady(renderConnectors);
onReady(renderDecisionsRail);
onReady(describeElements);
onReady(renderModeSwitch);
if (config.notice) { if (config.notice) {
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); }); document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
@@ -502,6 +751,10 @@ const MODEL_STYLE = `
.archify-model-folder > .archify-model-summary { padding: 0.55rem 0.75rem; font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; } .archify-model-folder > .archify-model-summary { padding: 0.55rem 0.75rem; font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
.archify-model-folder > .archify-model-summary:hover { color: var(--m-accent); } .archify-model-folder > .archify-model-summary:hover { color: var(--m-accent); }
.archify-model-folder > .archify-model-list { margin: 0 0.75rem; } .archify-model-folder > .archify-model-list { margin: 0 0.75rem; }
.archify-model-registry-file > .archify-model-summary { text-transform: none; letter-spacing: 0; font-size: 0.8125rem; }
.archify-model-registry-file > .archify-model-cards { margin: 0.25rem 0.75rem 0; }
.archify-model-open-file { margin-left: 0.5rem; text-transform: none; letter-spacing: 0; font-family: inherit; }
.archify-model-open-file:hover { color: var(--m-accent); }
.archify-model-diagrams > .archify-model-list { margin-top: 0.25rem; } .archify-model-diagrams > .archify-model-list { margin-top: 0.25rem; }
.archify-model-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; } .archify-model-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
.archify-model-open { display: flex; flex-direction: column; gap: 0.2rem; width: 100%; padding: 0.7rem 0.85rem; text-align: left; cursor: pointer; font: inherit; color: var(--m-text); background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.6rem; transition: border-color 0.15s, transform 0.15s; } .archify-model-open { display: flex; flex-direction: column; gap: 0.2rem; width: 100%; padding: 0.7rem 0.85rem; text-align: left; cursor: pointer; font: inherit; color: var(--m-text); background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.6rem; transition: border-color 0.15s, transform 0.15s; }
@@ -524,6 +777,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-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-title { margin: 1.25rem 0 0.6rem; }
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } .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. */ /* 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 { --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; } .archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; }
@@ -542,11 +813,32 @@ const MODEL_STYLE = `
.archify-model-used-link:hover { border-color: var(--m-accent); } .archify-model-used-link:hover { border-color: var(--m-accent); }
`; `;
/* The Model | Graph switch: in Archify's toolbar it is the 2D | 3D switch's twin; on the model page it sits top right. */
const SWITCH_STYLE = `
.archify-mode-switch { display: inline-flex; align-items: center; padding: 0 0.25rem; gap: 0; border: 0; background: transparent; color: var(--text-muted, var(--vscode-descriptionForeground)); font: inherit; cursor: pointer; position: relative; }
.toolbar .archify-mode-switch::before { content: ""; position: absolute; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); }
.toolbar .archify-mode-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
.archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border-radius: 0.5rem; font-weight: 550; font-size: 0.8125rem; transition: background 0.15s, color 0.15s; }
.archify-mode-seg.on { color: var(--bg, var(--vscode-editor-background)); background: var(--text, var(--vscode-foreground)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
.archify-mode-switch:hover .archify-mode-seg:not(.on) { color: var(--text, var(--vscode-foreground)); }
.archify-mode-switch:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 2px; border-radius: 0.75rem; }
.archify-model-switch-row { float: right; margin: 1.5rem 0 0 1rem; }
.archify-model-switch-row .archify-mode-switch { border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); padding: 0.1rem 0.25rem; }
`;
/* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */ /* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */
const CONNECTOR_STYLE = ` 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-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; } .diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; }
.archify-connectors .node-outline-item strong { font-weight: 600; } .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. */ /* 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) { 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); } 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); }
@@ -576,7 +868,7 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
const head = const head =
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` + `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` + `<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`; `<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${SWITCH_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
let page = html let page = html
// Archify calls its node rail "Node index"; next to the registry it reads better as Elements. // Archify calls its node rail "Node index"; next to the registry it reads better as Elements.
@@ -604,7 +896,47 @@ export function prepareDiagramHtml(html: string, options: PageOptions): string {
/** The model page for a main.archify without a diagram of its own. */ /** The model page for a main.archify without a diagram of its own. */
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string { export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, model: ModelPanel): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {} }); const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {}, graphSwitch: { left: 'Model' } });
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}
${SWITCH_STYLE}
</style>
</head>
<body></body>
</html>`;
}
/** The Graph page of a model: the bundled script builds everything from the host's graph data. */
export function graphPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, graph: NonNullable<PageOptions['graph']>): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph });
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>
<script nonce="${options.nonce}" defer src="${escapeHtml(graph.scriptUri)}"></script>
<style>
html, body { margin: 0; height: 100%; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
${NOTICE_STYLE}
</style>
</head>
<body></body>
</html>`;
}
/** The registry page for one .archify-registry file. */
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, registry: RegistryPanel): string {
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, links: {} });
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -621,9 +953,9 @@ export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'st
</html>`; </html>`;
} }
/** The registry page for one .archify-registry file. */ /** The decision page for one .archify-decision file. */
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, registry: RegistryPanel): string { 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, registry, links: {} }); const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {} });
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
+5
View File
@@ -13,3 +13,8 @@ export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri
defaultView, defaultView,
}; };
} }
/** The Graph page script (a webview URI). */
export function graphScriptUri(webview: vscode.Webview, extensionUri: vscode.Uri): string {
return webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'graph.js')).toString();
}
+17
View File
@@ -0,0 +1,17 @@
# An architecture decision of this model. The id is unique in the model;
# elements are registry ids, diagrams are paths from the model root.
schema_version: 1
decision:
id: __ID__
title: __TITLE__
status: proposed # proposed | accepted | superseded | rejected
date: __DATE__
elements: []
diagrams: []
supersedes: []
context: >
What was the situation, and what forced a decision.
decision: >
What was decided, in one or two sentences.
consequences: >
What becomes easier, what becomes harder, what must change.
@@ -3,6 +3,7 @@ diagram_type: architecture
meta: meta:
title: Containers title: Containers
output: containers.html output: containers.html
decisions: [postgres-as-source-of-truth]
components: components:
- ref: customer - ref: customer
pos: [40, 160] pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue - ref: queue
pos: [460, 360] pos: [460, 360]
size: [130, 60] size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker - ref: worker
pos: [670, 460] pos: [670, 460]
size: [130, 60] size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON } - { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read } - { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL } - { 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: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] } - { from: worker, to: db, label: SQL, labelAt: [735, 420] }
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: postgres-as-source-of-truth
title: PostgreSQL as the single source of truth
status: accepted
date: 2026-09-02
elements: [db, api, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Orders, customers and the catalogue were spread over the
shop database and two spreadsheets kept by support.
Refunds were reconciled by hand every Monday.
decision: >
One PostgreSQL database owns orders, customers and the
catalogue. The API is the only writer. Everything else
(cache, search, reports) is derived and may be rebuilt.
consequences: >
Support loses the spreadsheets and gets the admin console.
Any new store must be declared derived or get its own
decision. Backups and migrations are the API team's job.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: queue-between-api-and-worker
title: A queue between the API and the worker
status: accepted
date: 2026-09-16
elements: [api, queue, worker]
diagrams: [0200-containers/online-shop.archify]
context: >
Order confirmation emails and invoices were sent inside
the checkout request. A slow mail provider made checkout
slow; a failing one made checkout fail.
decision: >
The API enqueues a job per side effect on SQS. One worker
service consumes the queue and does the slow work.
Checkout returns as soon as the order is stored.
consequences: >
Emails arrive seconds later, not instantly. Jobs must be
idempotent because SQS delivers at least once. The worker
is the second place that writes to the database.
@@ -0,0 +1,20 @@
schema_version: 1
decision:
id: opensearch-for-product-search
title: OpenSearch for product search
status: proposed
date: 2026-10-19
elements: [api, worker, db]
diagrams: [0200-containers/online-shop.archify]
context: >
Catalogue search runs as SQL LIKE queries on the
database. Typos find nothing and the catalogue page is
the slowest page in the shop.
decision: >
Add a dedicated search service on OpenSearch. The API
queries it; the worker rebuilds the index from the
database after every catalogue change.
consequences: >
The index is derived data under decision 0001 and may be
rebuilt at any time. One more service to run. Search
results can lag the database by the rebuild interval.
@@ -43,4 +43,4 @@ connections:
- { from: router, to: store, label: append } - { from: router, to: store, label: append }
- { from: router, to: notifier, label: notify } - { from: router, to: notifier, label: notify }
- { from: router, to: analytics, label: flatten } - { from: router, to: analytics, label: flatten }
- { from: router, to: dlq, label: failed, variant: dashed, labelDy: 24 } - { from: router, to: dlq, label: failed, variant: dashed, labelDy: 24, decisions: [dead-letter-queue] }
@@ -0,0 +1,18 @@
schema_version: 1
decision:
id: dead-letter-queue
title: Failed events go to a dead-letter queue, never dropped
status: accepted
date: 2026-09-24
elements: [router, dlq, ops]
diagrams: [0200-containers/platform.archify, 0300-components/router.archify]
context: >
Events that failed schema validation were logged and lost.
Nobody noticed until a customer asked where an order went.
decision: >
The router moves every event it cannot process to a
dead-letter queue with the reason. Operations replays
them after the cause is fixed.
consequences: >
Nothing is lost silently. Someone has to watch the queue;
that is an on-call duty for operations.
@@ -32,7 +32,7 @@ boundaries:
connections: connections:
- { from: user, to: spa, label: HTTPS, variant: emphasis } - { from: user, to: spa, label: HTTPS, variant: emphasis }
- { from: spa, to: api, label: JSON } - { 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: cache, label: read }
- { from: api, to: db, label: SQL } - { from: api, to: db, label: SQL }
- { from: api, to: storage, label: files } - { from: api, to: storage, label: files }
@@ -0,0 +1,17 @@
schema_version: 1
decision:
id: external-identity-provider
title: Sign in through an identity provider, never store passwords
status: accepted
date: 2026-09-10
elements: [idp, api, spa]
diagrams: [0200-containers/web-app.archify]
context: >
The first version kept password hashes in the database and
had to handle resets, lockouts and two-factor codes itself.
decision: >
Users sign in through an OpenID Connect identity provider.
The single-page app gets a token; the API only verifies it.
consequences: >
No password tables, no reset flows. The identity provider
is a hard dependency: when it is down, nobody can sign in.
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { parse } from 'yaml';
import { effectiveStatus, mergeDecisions, parseDecision, slugify, sortDecisions, validateDecisions } from '../src/model/decisions';
import { mergeRegistries, parseRegistry, resolveRefs, summarizeDiagram } from '../src/model/format';
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
const DECISION_FILES = ['decisions/0001-postgres-as-source-of-truth.archify-decision', 'decisions/0002-queue-between-api-and-worker.archify-decision', 'decisions/0003-opensearch-for-product-search.archify-decision'];
function registryOfModel() {
const files = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
return mergeRegistries(files.map((file) => ({ file, ...parseRegistry(read(file), file) })));
}
function decisionsOfModel() {
return mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
}
test('decision files parse with lines for every linked id', () => {
const { decision, problems } = parseDecision(read(DECISION_FILES[1]), 'd2');
assert.deepEqual(problems, []);
assert.ok(decision);
assert.equal(decision.id, 'queue-between-api-and-worker');
assert.equal(decision.title, 'A queue between the API and the worker');
assert.equal(decision.status, 'accepted');
assert.deepEqual(decision.elements, ['api', 'queue', 'worker']);
assert.deepEqual(decision.diagrams, ['0200-containers/online-shop.archify']);
assert.equal(decision.lines.elements.length, 3);
assert.ok(decision.context && decision.decision && decision.consequences);
});
test('decision files: broken YAML, missing block, unknown status', () => {
assert.equal(parseDecision('decision: {\n', 'x').problems[0].severity, 'error');
assert.match(parseDecision('context: only text\n', 'x').problems[0].message, /"decision" block/);
const bad = parseDecision('decision:\n id: a\n title: A\n status: maybe\n elements: api\n', 'x');
assert.deepEqual(bad.problems.map((p) => p.message.split(';')[0]), ['Unknown status "maybe"', '"elements" must be a list.']);
assert.equal(bad.problems[0].line, 3);
});
test('decisions merge, validate links, and the superseded rule applies', () => {
const set = decisionsOfModel();
assert.deepEqual(set.problems, []);
assert.equal(set.decisions.size, 3);
const registry = registryOfModel();
const diagrams = new Set(['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify']);
assert.deepEqual(validateDecisions(set, registry, diagrams), []);
// Duplicate id, unknown element, missing diagram, dangling supersedes.
const extra = parseDecision('decision:\n id: queue-between-api-and-worker\n title: Dup\n elements: [ghost]\n diagrams: [0900/x.archify]\n supersedes: [nope]\n', 'dup');
const merged = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'dup', ...extra }]);
assert.match(merged.problems[0].message, /defined twice/);
const newer = parseDecision('decision:\n id: one-database\n title: Replace the first\n status: accepted\n supersedes: [postgres-as-source-of-truth]\n', 'd4');
const withNewer = mergeDecisions([...DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })), { file: 'd4', ...newer }]);
const problems = validateDecisions(mergeDecisions([{ file: 'dup', ...extra }]), registry, diagrams);
assert.deepEqual(problems.map((p) => p.message.split(':')[0]), ['Unknown element "ghost"', 'Diagram "0900/x.archify" does not exist in this model.', 'Decision "nope" (in supersedes) does not exist in this model.']);
assert.deepEqual(effectiveStatus(withNewer.decisions.get('postgres-as-source-of-truth')!, withNewer.decisions), { status: 'superseded', supersededBy: 'one-database' });
assert.deepEqual(effectiveStatus(withNewer.decisions.get('queue-between-api-and-worker')!, withNewer.decisions), { status: 'accepted' });
const order = sortDecisions([...withNewer.decisions.values()], withNewer.decisions).map((d) => d.id);
assert.equal(order[order.length - 1], 'postgres-as-source-of-truth', 'superseded sorts last');
assert.equal(slugify('OpenSearch for product search!'), 'opensearch-for-product-search');
// Numeric ids are a YAML trap and are refused on both sides.
assert.match(parseDecision('decision:\n id: 0001\n title: N\n', 'n').problems[0].message, /is a number to YAML/);
assert.match(parseDecision('decision:\n id: ok\n title: N\n supersedes: [0001]\n', 'n').problems[0].message, /is a number to YAML/);
});
test('diagrams: decisions keys are stripped, collected and checked', () => {
const registry = registryOfModel();
const known = new Set(decisionsOfModel().decisions.keys());
const doc = parse(read('0200-containers/online-shop.archify'));
const r = resolveRefs(doc, { registry, decisions: known, fileName: 'c.archify' });
assert.deepEqual(r.problems, []);
assert.ok(!('decisions' in r.diagram));
const components = r.diagram.components as Record<string, unknown>[];
const connections = r.diagram.connections as Record<string, unknown>[];
assert.ok(components.every((c) => !('decisions' in c)) && connections.every((c) => !('decisions' in c)));
assert.deepEqual(r.decisionRefs.map((d) => [d.id, d.attachedTo.kind, d.pointer]), [
['postgres-as-source-of-truth', 'diagram', '/decisions/0'],
['queue-between-api-and-worker', 'node', '/components/5/decisions/0'],
['queue-between-api-and-worker', 'edge', '/connections/4/decisions/0'],
]);
const numeric = resolveRefs({ decisions: [1], components: [] }, { registry, decisions: known, fileName: 'x.archify' });
assert.match(numeric.problems[0].message, /is a number to YAML/);
const unknown = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0], decisions: ['nope'] }] }, { registry, decisions: known, fileName: 'x.archify' });
assert.equal(unknown.problems[0].pointer, '/components/0/decisions/0');
assert.match(unknown.problems[0].message, /Unknown decision "nope"/);
const outside = resolveRefs({ decisions: ['postgres-as-source-of-truth'], components: [] }, { fileName: '/w/loose.archify' });
assert.match(outside.problems[0].message, /not inside a model/);
// Other kinds: nodes and edges carry the key too.
const workflow = resolveRefs({ nodes: [{ id: 'n', type: 'process', label: 'N', decisions: ['postgres-as-source-of-truth'] }], edges: [{ from: 'n', to: 'n', decisions: ['queue-between-api-and-worker'] }] }, { registry, decisions: known, fileName: 'w.archify' });
assert.deepEqual(workflow.decisionRefs.map((d) => d.attachedTo.kind), ['node', 'edge']);
assert.ok(!('decisions' in (workflow.diagram.nodes as Record<string, unknown>[])[0]));
const summary = summarizeDiagram(read('0200-containers/online-shop.archify'), '0200-containers/online-shop.archify');
assert.deepEqual(summary.decisions.map((d) => d.id), ['postgres-as-source-of-truth', 'queue-between-api-and-worker', 'queue-between-api-and-worker']);
});
+11 -1
View File
@@ -7,6 +7,7 @@ import { parse } from 'yaml';
import { validate } from '../src/archify/cli'; import { validate } from '../src/archify/cli';
import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples'; import { MODEL_EXAMPLES, mainFromExample } from '../src/language/examples';
import { MAIN_FILE, REGISTRY_EXT, hasDiagramBody, mergeRegistries, parseRegistry, readModelMeta, resolveRefs } from '../src/model/format'; 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'; import { readDiagramSource } from '../src/sources/detect';
const root = path.resolve(__dirname, '..', '..'); 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}`); 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. // Diagrams: the three C4 levels, each resolving without problems and valid for Archify.
const diagrams = files.filter((f) => f.endsWith('.archify')).sort(); const diagrams = files.filter((f) => f.endsWith('.archify')).sort();
for (const level of ['0100-context', '0200-containers', '0300-components']) { 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) { for (const file of diagrams) {
const source = readDiagramSource(read(file), file); const source = readDiagramSource(read(file), file);
assert.ok(source, `${file} is not a diagram`); 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); assert.deepEqual(resolved.problems, [], file);
for (const link of resolved.links.values()) assert.ok(files.includes(link), `${file} links to missing ${link}`); 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); for (const id of resolved.used) used.add(id);
+3 -1
View File
@@ -3,6 +3,7 @@ diagram_type: architecture
meta: meta:
title: Containers title: Containers
output: containers.html output: containers.html
decisions: [postgres-as-source-of-truth]
components: components:
- ref: customer - ref: customer
pos: [40, 160] pos: [40, 160]
@@ -22,6 +23,7 @@ components:
- ref: queue - ref: queue
pos: [460, 360] pos: [460, 360]
size: [130, 60] size: [130, 60]
decisions: [queue-between-api-and-worker]
- ref: worker - ref: worker
pos: [670, 460] pos: [670, 460]
size: [130, 60] size: [130, 60]
@@ -34,6 +36,6 @@ connections:
- { from: web, to: api, label: JSON } - { from: web, to: api, label: JSON }
- { from: api, to: cache, label: read } - { from: api, to: cache, label: read }
- { from: api, to: db, label: SQL } - { 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: queue, to: worker, label: poll }
- { from: worker, to: db, label: SQL, labelAt: [735, 420] } - { 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.
+162
View File
@@ -0,0 +1,162 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { mergeDecisions, parseDecision } from '../src/model/decisions';
import { DecisionAttachment, mergeRegistries, parseRegistry, summarizeDiagram } from '../src/model/format';
import { ModelGraph, buildGraph } from '../src/model/graph';
import { parseQuery, runQuery, shortestPath } from '../src/model/graphQuery';
const model = path.resolve(__dirname, '..', '..', 'test', 'fixtures', 'model');
const read = (rel: string) => fs.readFileSync(path.join(model, rel), 'utf8');
const REGISTRY_FILES = ['registry/people.archify-registry', 'registry/systems.archify-registry', 'registry/containers.archify-registry'];
const DIAGRAM_FILES = ['0100-context/online-shop.archify', '0200-containers/online-shop.archify', '0300-components/api.archify'];
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 graphOfModel(): ModelGraph {
const registry = mergeRegistries(REGISTRY_FILES.map((file) => ({ file, ...parseRegistry(read(file), file) })));
const diagrams = DIAGRAM_FILES.map((file) => summarizeDiagram(read(file), file));
const decisions = mergeDecisions(DECISION_FILES.map((file) => ({ file, ...parseDecision(read(file), file) })));
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 buildGraph({ meta: { id: 'online-shop', name: 'Online Shop' }, registry, diagrams, decisions, decisionUsage });
}
test('diagram summaries carry nodes and edges, shared and inline', () => {
const api = summarizeDiagram(read('0300-components/api.archify'), '0300-components/api.archify');
assert.deepEqual(api.nodes.map((n) => n.id), ['web', 'auth', 'orders', 'catalogue', 'db', 'queue']);
assert.equal(api.nodes[0].ref, 'web');
assert.equal(api.nodes[1].ref, undefined);
assert.equal(api.nodes[1].type, 'security');
assert.equal(api.edges.length, 6);
assert.deepEqual(api.edges[0], { from: 'web', to: 'auth', label: 'sign in' });
const broken = summarizeDiagram('diagram_type: [', 'x.archify');
assert.deepEqual(broken.nodes, []);
assert.deepEqual(broken.edges, []);
});
test('the model graph unites every diagram: elements once, connections once, labels merged', () => {
const graph = graphOfModel();
// 11 registry elements + 3 inline components of the API diagram, plus 3 decision and 3 registry nodes.
assert.equal(graph.nodes.filter((n) => n.kind === 'element').length, 14);
assert.equal(graph.nodes.length, 20);
const db = graph.nodes.find((n) => n.id === 'db')!;
assert.equal(db.shared, true);
assert.equal(db.type, 'database');
assert.deepEqual(db.diagrams, ['0200-containers/online-shop.archify', '0300-components/api.archify']);
assert.equal(db.file, 'registry/containers.archify-registry');
assert.ok(db.description);
const orders = graph.nodes.find((n) => n.id === 'orders')!;
assert.equal(orders.shared, false);
assert.deepEqual(orders.diagrams, ['0300-components/api.archify']);
// api → db is drawn once in the containers diagram; orders → db and catalogue → db are separate connections.
assert.equal(graph.edges.filter((e) => e.kind === 'connection').length, 4 + 7 + 6);
const apiDb = graph.edges.find((e) => e.id === 'api>db')!;
assert.deepEqual(apiDb.labels, ['SQL']);
assert.deepEqual(apiDb.diagrams, ['0200-containers/online-shop.archify']);
assert.deepEqual(graph.diagrams.map((d) => d.path), DIAGRAM_FILES);
});
test('decisions reach the elements they name and the nodes and edges they are attached to', () => {
const graph = graphOfModel();
const queue = graph.nodes.find((n) => n.id === 'queue')!;
assert.deepEqual(queue.decisions, ['queue-between-api-and-worker']);
const api = graph.nodes.find((n) => n.id === 'api')!;
assert.deepEqual([...api.decisions].sort(), ['opensearch-for-product-search', 'postgres-as-source-of-truth', 'queue-between-api-and-worker']);
assert.deepEqual(graph.edges.find((e) => e.id === 'api>queue')!.decisions, ['queue-between-api-and-worker']);
assert.equal(graph.decisions.length, 3);
assert.equal(graph.decisions.find((d) => d.id === 'postgres-as-source-of-truth')!.status, 'accepted');
});
test('decisions and registries are nodes of their own, linked to the elements they are about or define', () => {
const graph = graphOfModel();
const decision = graph.nodes.find((n) => n.id === 'decision:queue-between-api-and-worker')!;
assert.equal(decision.kind, 'decision');
assert.equal(decision.type, 'decision');
assert.equal(decision.status, 'accepted');
assert.equal(decision.sublabel, 'queue-between-api-and-worker');
assert.deepEqual(graph.edges.filter((e) => e.kind === 'decides' && e.from === decision.id).map((e) => e.to).sort(), ['api', 'queue', 'worker']);
const registry = graph.nodes.find((n) => n.id === 'registry:registry/containers.archify-registry')!;
assert.equal(registry.kind, 'registry');
assert.equal(registry.label, 'registry/containers');
assert.deepEqual(registry.elements, ['web', 'api', 'worker', 'queue', 'db', 'cache']);
assert.equal(graph.edges.filter((e) => e.kind === 'defines' && e.from === registry.id).length, 6);
// Walks and paths do not go through them: a registry is not a shortcut between its elements.
assert.deepEqual(shortestPath(graph, 'customer', 'cache')?.nodes, ['customer', 'web', 'api', 'cache']);
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
assert.ok(!ids('uses:api').some((id) => id.startsWith('registry:') && id !== 'registry:registry/containers.archify-registry'));
// They follow their elements into a result, and a negative term removes them again.
assert.deepEqual(ids('type:database').filter((id) => id.includes(':')), ['decision:opensearch-for-product-search', 'decision:postgres-as-source-of-truth', 'registry:registry/containers.archify-registry']);
assert.deepEqual(ids('type:database -type:decision -type:registry'), ['cache', 'db']);
assert.deepEqual(ids('type:registry'), ['registry:registry/containers.archify-registry', 'registry:registry/people.archify-registry', 'registry:registry/systems.archify-registry']);
assert.ok(ids('decision:queue-between-api-and-worker').includes('decision:queue-between-api-and-worker'));
});
test('an unused registry element is in the graph with no diagrams', () => {
const registry = mergeRegistries([{ file: 'r', ...parseRegistry('elements:\n api: { type: backend, label: API }\n ghost: { type: database, label: Ghost }\n', 'r') }]);
const diagrams = [summarizeDiagram('diagram_type: architecture\ncomponents:\n - ref: api\n', 'a.archify')];
const graph = buildGraph({ meta: {}, registry, diagrams, decisions: mergeDecisions([]), decisionUsage: new Map() });
assert.deepEqual(graph.nodes.filter((n) => n.kind === 'element').map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]);
assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost', 'registry:r']);
});
test('queries parse into terms; unknown keys are plain text', () => {
assert.deepEqual(parseQuery('database type:backend -shared near:api 2 foo:bar unused'), [
{ key: 'text', value: 'database', negate: false },
{ key: 'type', value: 'backend', negate: false },
{ key: 'shared', value: '', negate: true },
{ key: 'near', value: 'api,2', negate: false },
{ key: 'text', value: 'foo:bar', negate: false },
{ key: 'unused', value: '', negate: false },
]);
assert.deepEqual(parseQuery(' '), []);
});
const elementIds = (graph: ModelGraph, q: string) => [...runQuery(graph, q).nodes].filter((id) => !id.includes(':')).sort();
test('queries narrow the graph: text, type, diagram, decision, shared', () => {
const graph = graphOfModel();
const ids = (q: string) => elementIds(graph, q);
assert.equal(runQuery(graph, '').nodes.size, 20);
assert.deepEqual(ids('type:database'), ['cache', 'db']);
assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']);
assert.deepEqual(ids('worker'), ['worker']);
assert.deepEqual(ids('diagram:0100-*'), ['customer', 'email', 'psp', 'shop', 'support']);
assert.deepEqual(ids('diagram:0300-components/api'), ['auth', 'catalogue', 'db', 'orders', 'queue', 'web']);
assert.deepEqual(ids('-shared'), ['auth', 'catalogue', 'orders']);
assert.deepEqual(ids('shared type:backend'), ['api', 'worker']);
assert.deepEqual(ids('decision:queue-between-api-and-worker'), ['api', 'queue', 'worker']);
assert.deepEqual(ids('type:backend -worker'), ['api', 'catalogue', 'orders']);
const unknown = runQuery(graph, 'decision:nope');
assert.equal(unknown.nodes.size, 0);
assert.match(unknown.message ?? '', /No decision "nope"/);
});
test('queries walk the graph: uses, near, from/to', () => {
const graph = graphOfModel();
const ids = (q: string) => elementIds(graph, q);
assert.deepEqual(ids('uses:api'), ['api', 'cache', 'db', 'queue', 'web']);
assert.deepEqual(ids('uses:API'), ['api', 'cache', 'db', 'queue', 'web']);
assert.deepEqual(ids('near:worker,2'), ['api', 'catalogue', 'db', 'orders', 'queue', 'worker']);
assert.deepEqual(ids('near:worker 2 type:database'), ['db']);
const result = runQuery(graph, 'from:customer to:db');
assert.deepEqual(result.path, ['customer', 'web', 'api', 'db']);
assert.deepEqual([...result.edges].filter((id) => !id.includes(':')).sort(), ['api>db', 'customer>web', 'web>api']);
// Direction is tried first; against the arrows the search still finds a way.
assert.deepEqual(shortestPath(graph, 'db', 'customer')?.nodes, ['db', 'api', 'web', 'customer']);
assert.equal(shortestPath(graph, 'psp', 'auth')?.nodes.length, 5);
const none = runQuery(graph, 'from:customer to:nowhere');
assert.equal(none.nodes.size, 0);
assert.match(none.message ?? '', /No element "nowhere"/);
const missing = runQuery(graph, 'from:customer');
assert.match(missing.message ?? '', /both from: and to:/);
});
test('connections are shown only when both ends match', () => {
const graph = graphOfModel();
const result = runQuery(graph, 'type:backend');
assert.ok(result.nodes.has('api'));
assert.ok(!result.edges.has('api>db'));
assert.ok(result.edges.has('orders>queue') === false);
assert.ok([...result.edges].every((id) => { const at = id.indexOf('>', id.startsWith('decision:') || id.startsWith('registry:') ? id.indexOf(':') : 0); return result.nodes.has(id.slice(0, at)) && result.nodes.has(id.slice(at + 1)); }));
});
+61 -1
View File
@@ -317,6 +317,22 @@ export async function run(): Promise<void> {
assert.ok(tab); assert.ok(tab);
}); });
await check('Archify | Open Model Graph switches the main.archify editor to the Graph page and back', async () => {
const main = workspaceFile('model/main.archify');
const diagram = workspaceFile('model/0100-context/online-shop.archify');
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes.commands as { command: string; title: string }[];
assert.equal(commands.find((item) => item.command === 'archify.openModelGraph')?.title, 'Archify | Open Model Graph');
await vscode.commands.executeCommand('archify.openModelGraph', diagram);
const tab = await waitFor('model editor active', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.uri.toString() === main.toString()),
);
assert.ok(tab);
// The choice is remembered per file in the workspace state until the page is switched back.
const ext = vscode.extensions.getExtension(EXTENSION_ID);
assert.ok(ext?.isActive);
await new Promise((resolve) => setTimeout(resolve, 500));
});
await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => { await check('a .archify-registry file opens as the registry view and Show Source opens the YAML', async () => {
const registry = workspaceFile('model/registry/people.archify-registry'); const registry = workspaceFile('model/registry/people.archify-registry');
await vscode.commands.executeCommand('vscode.open', registry); await vscode.commands.executeCommand('vscode.open', registry);
@@ -329,6 +345,50 @@ export async function run(): Promise<void> {
assert.equal(editor.document.languageId, 'yaml'); assert.equal(editor.document.languageId, 'yaml');
}); });
await check('a .archify-decision file opens as the decision view; an unknown decision id is a problem in a diagram', async () => {
const decision = workspaceFile('model/decisions/0002-queue-between-api-and-worker.archify-decision');
await vscode.commands.executeCommand('vscode.open', decision);
const tab = await waitFor('decision view tab', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.decision' && t.input.uri.toString() === decision.toString()),
);
assert.ok(tab);
// A diagram that points at a decision nobody defines.
const bad = workspaceFile('model/0300-components/bad-decision.archify');
await vscode.workspace.fs.writeFile(bad, Buffer.from('schema_version: 1\ndiagram_type: architecture\nmeta:\n title: Bad\n output: bad.html\ndecisions: [nobody-decided]\ncomponents:\n - ref: api\n pos: [40, 60]\n size: [130, 60]\n decisions: [queue-between-api-and-worker]\n', 'utf8'));
const doc = await vscode.workspace.openTextDocument(bad);
await vscode.window.showTextDocument(doc);
const problem = await waitFor('unknown decision problem', () =>
vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && /Unknown decision "nobody-decided"/.test(d.message)),
);
assert.equal(problem.range.start.line, 5);
assert.ok(!vscode.languages.getDiagnostics(doc.uri).some((d) => /queue-between-api-and-worker/.test(d.message)), 'the known decision is not reported');
await vscode.workspace.fs.delete(bad);
});
await check('Archify | New Registry… creates a registry file and opens the registry view; creators are ordered', async () => {
const commands = vscode.extensions.getExtension(EXTENSION_ID)?.packageJSON.contributes as { commands: { command: string }[]; menus: Record<string, { command: string; group: string }[]> };
const creators = commands.menus['explorer/context'].filter((m) => /^archify\.new/.test(m.command));
assert.deepEqual(creators.map((m) => m.command), ['archify.newModel', 'archify.newRegistry', 'archify.newDiagram', 'archify.newDecision']);
assert.deepEqual(creators.map((m) => m.group), ['zzz_archify@1', 'zzz_archify@2', 'zzz_archify@3', 'zzz_archify@4']);
const folder = workspaceFile('model/registry');
const window = vscode.window as unknown as Record<string, unknown>;
const originalInput = window.showInputBox;
window.showInputBox = async () => 'partners';
try {
await vscode.commands.executeCommand('archify.newRegistry', folder);
} finally {
window.showInputBox = originalInput;
}
const file = vscode.Uri.joinPath(folder, 'partners.archify-registry');
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(file));
assert.match(text, /^elements:/m);
const tab = await waitFor('registry view tab', () =>
vscode.window.tabGroups.all.flatMap((g) => g.tabs).find((t) => t.isActive && t.input instanceof vscode.TabInputCustom && t.input.viewType === 'archify.registry' && t.input.uri.toString() === file.toString()),
);
assert.ok(tab);
await vscode.workspace.fs.delete(file);
});
await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => { await check('Archify | New Diagram… → Model creates main.archify from the folder name', async () => {
const folder = workspaceFile('new-shop'); const folder = workspaceFile('new-shop');
await vscode.workspace.fs.createDirectory(folder); await vscode.workspace.fs.createDirectory(folder);
@@ -385,7 +445,7 @@ export async function run(): Promise<void> {
assert.match(content, /name: Shop two/); assert.match(content, /name: Shop two/);
assert.match(content, /title: Shop two — overview/); assert.match(content, /title: Shop two — overview/);
const names = (await vscode.workspace.fs.readDirectory(folder)).map(([name]) => name).sort(); 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(); 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']); assert.deepEqual(registry, ['containers.archify-registry', 'people.archify-registry', 'systems.archify-registry']);
const tab = await waitFor('model page tab', () => const tab = await waitFor('model page tab', () =>
+5 -17
View File
@@ -3,8 +3,6 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { test } from 'node:test'; import { test } from 'node:test';
import { import {
cardsFromElements,
dotForType,
groupOf, groupOf,
hasDiagramBody, hasDiagramBody,
isMainFile, isMainFile,
@@ -83,22 +81,12 @@ test('resolve: refs become full components, links and usage are collected', () =
assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']); assert.deepEqual([...r.used].sort(), ['api', 'cache', 'customer', 'db', 'queue', 'web', 'worker']);
assert.equal(r.refs.get(2)?.id, 'api'); assert.equal(r.refs.get(2)?.id, 'api');
assert.ok(!('model' in r.diagram)); assert.ok(!('model' in r.diagram));
// Cards from the registry descriptions, in drawing order, one per element. // Descriptions travel with the node ids, for the Elements panel; nothing is added to the diagram itself.
const cards = r.diagram.cards as { dot: string; title: string; items: string[] }[]; assert.ok(!('cards' in r.diagram));
assert.equal(cards.length, 7); assert.equal(r.descriptions.get('customer'), 'Buys products; signs in with email or a social account.');
assert.deepEqual(cards[0], { dot: 'slate', title: 'Customer', items: ['Buys products; signs in with email or a social account.'] }); assert.equal(r.descriptions.size, 7);
assert.equal(cards.find((c) => c.title === 'API')?.dot, 'emerald');
});
test('resolve: own cards win, no cards without refs or descriptions', () => {
const registry = registryOfModel();
const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' }); const own = resolveRefs({ components: [{ ref: 'api', pos: [0, 0] }], cards: [{ dot: 'rose', title: 'Mine', items: ['kept'] }] }, { registry, fileName: 'x.archify' });
assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }]); assert.deepEqual(own.diagram.cards, [{ dot: 'rose', title: 'Mine', items: ['kept'] }], 'hand-written cards pass through');
const inline = resolveRefs({ components: [{ id: 'a', type: 'backend', label: 'A', pos: [0, 0] }] }, { registry, fileName: 'x.archify' });
assert.ok(!('cards' in inline.diagram));
assert.equal(cardsFromElements([{ id: 'x', type: 'cloud', label: 'X', file: 'r', line: 0 }]).length, 0);
assert.equal(dotForType('messagebus'), 'orange');
assert.equal(dotForType(undefined), 'slate');
}); });
test('resolve: overrides, second id for the same element, inline elements and links', () => { test('resolve: overrides, second id for the same element, inline elements and links', () => {