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
This commit is contained in:
2026-10-21 20:46:05 +03:00
parent c8645b228b
commit 99ea076571
8 changed files with 237 additions and 42 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
## 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. Drag, zoom, **Fit**, **Layout**, **Labels**, legend chips that hide a type, **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`; terms AND together, a leading `-` excludes; 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.
- **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. Drag, zoom, **Fit**, **Layout**, **Labels**, 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.
+3 -3
View File
@@ -127,9 +127,9 @@ online-shop/
- **Registry view.** A `.archify-registry` file opens as its elements, as cards. Click one and a dialog shows its description, where it is defined and the diagrams that use it; click a diagram to open it. **Show Source** opens the YAML beside it.
- **Elements and Connectors.** Under every diagram, Archify's node index is titled **Elements**, and a **Connectors** panel next to it lists every connection as "from → to" with its label. Hovering highlights the connector in the diagram; clicking keeps it highlighted and scrolls to it.
- **Starting one.** *Archify | New Model…* (right-click a folder) copies one of the shipped examples into a new folder, named as you choose: **C4 - Standard Web App** (single-page app, API, database, cache, storage, identity provider), **C4 - E-Shop** (web shop, API, worker, queue, payment and email providers) or **C4 - Event-Based Integration** (event bus, router, dead-letter queue, event store, consumers). Each has a registry and context, container and component diagrams. For an empty model, *Archify | New Diagram… → Model* creates just a `main.archify`.
- **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. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, **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). 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.
- **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. Drag nodes, scroll to zoom, **Fit** (or <kbd>0</kbd>) frames what matches, **Layout** lays it out again, **Labels** toggles connection labels, **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).
+4
View File
@@ -135,6 +135,10 @@ The **Model | Graph** switch on the `main.archify` page replaces it with the who
- **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.
+112 -14
View File
@@ -22,9 +22,11 @@ interface SavedView {
cytoscape.use(fcose);
const TYPE_COLORS: Record<string, string> = { frontend: '#22d3ee', backend: '#34d399', database: '#a78bfa', cloud: '#fbbf24', security: '#fb7185', messagebus: '#fb923c', external: '#94a3b8' };
const TYPE_ORDER = ['external', 'frontend', 'cloud', 'security', 'backend', 'messagebus', 'database'];
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';
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';
@@ -117,8 +119,8 @@ class GraphPage {
}
private elements() {
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', shared: n.shared ? 1 : 0, color: colorOf(n.type) } }));
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, label: e.labels.join(' / ') } }));
const nodes = this.graph.nodes.map((n) => ({ group: 'nodes' as const, data: { id: n.id, label: n.label, type: n.type ?? '', kind: n.kind, shared: n.shared ? 1 : 0, color: nodeColor(n) } }));
const edges = this.graph.edges.map((e) => ({ group: 'edges' as const, data: { id: e.id, source: e.from, target: e.to, kind: e.kind, label: e.labels.join(' / ') } }));
return [...nodes, ...edges];
}
@@ -287,8 +289,12 @@ class GraphPage {
return [
{ selector: 'node', style: { shape: 'round-rectangle', width: (n: NodeSingular) => Math.min(260, Math.max(84, String(n.data('label')).length * 7.5 + 34)), height: 42, 'background-color': p.panel, 'border-width': 2, 'border-color': 'data(color)', 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': '230px' } },
{ selector: 'node[shared = 0]', style: { 'border-style': 'dashed' } },
{ selector: 'node[kind = "decision"]', style: { shape: 'round-tag', height: 36, 'font-size': 11, 'font-weight': 500, 'background-color': p.bg, 'text-max-width': '270px', width: (n: NodeSingular) => Math.min(300, Math.max(90, String(n.data('label')).length * 6.6 + 44)) } },
{ selector: 'node[kind = "registry"]', style: { shape: 'cut-rectangle', height: 34, 'font-size': 11, 'font-weight': 500, 'font-family': 'ui-monospace, Menlo, monospace', 'border-width': 1.5, 'border-color': p.muted, 'background-color': p.bg, width: (n: NodeSingular) => Math.min(230, Math.max(90, String(n.data('label')).length * 6.8 + 36)) } },
{ selector: 'edge', style: { width: 1.5, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.9, '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': '2px', 'text-rotation': 'autorotate' } },
{ selector: 'edge.labelled', style: { label: 'data(label)' } },
{ selector: 'edge[kind = "decides"], edge[kind = "defines"]', style: { width: 1, 'line-style': 'dotted', 'target-arrow-shape': 'none', 'line-color': p.muted, 'line-opacity': 0.6 } },
{ selector: 'edge[kind = "supersedes"]', style: { width: 1, 'line-style': 'dashed', 'line-color': TYPE_COLORS.decision, 'target-arrow-color': TYPE_COLORS.decision } },
{ selector: 'edge.labelled[kind = "connection"], edge.labelled[kind = "supersedes"]', style: { label: 'data(label)' } },
{ selector: '.dim', style: { opacity: 0.14, events: 'no' } },
{ selector: 'node.hit', style: { 'border-width': 3 } },
{ selector: 'edge.path', style: { width: 3, 'line-color': p.accent, 'target-arrow-color': p.accent, color: p.fg } },
@@ -301,7 +307,7 @@ class GraphPage {
private layout(randomize: boolean): void {
const visible = this.cy.elements().not('.hidden');
visible
.layout({ name: 'fcose', animate: false, randomize, quality: 'default', nodeDimensionsIncludeLabels: true, nodeRepulsion: () => 9000, idealEdgeLength: () => 150, edgeElasticity: () => 0.2, gravity: 0.3, padding: 40, fit: true } as cytoscape.LayoutOptions)
.layout({ name: 'fcose', animate: false, randomize, 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();
}
@@ -347,14 +353,14 @@ class GraphPage {
}
private updateCount(): void {
const total = this.cy.nodes().not('.hidden').length;
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().not('.hidden').length} connections`;
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').not('.hidden').length;
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));
}
@@ -372,9 +378,11 @@ class GraphPage {
this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.'));
const h3 = el('h3', '', 'This model');
this.side.appendChild(h3);
const summary = el('p', '', `${this.graph.nodes.length} elements, ${this.graph.edges.length} connections, ${this.graph.diagrams.length} diagrams, ${this.graph.decisions.length} decisions.`);
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 = this.graph.nodes.filter((n) => n.shared && !n.diagrams.length).length;
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';
@@ -384,8 +392,98 @@ class GraphPage {
}
private showSide(target: NodeSingular | EdgeSingular): void {
if (target.isNode()) this.showNode(this.nodesById.get(target.id()));
else this.showEdge(this.edgesById.get(target.id()));
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 {
@@ -438,7 +536,7 @@ class GraphPage {
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.from === node.id || e.to === node.id);
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) {
+1 -1
View File
@@ -4,7 +4,7 @@ export const GRAPH_PAGE_STYLE = `
--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-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; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--ag-bg); color: var(--ag-fg); font-family: var(--ag-font); font-size: 13px; }
.ag { display: grid; grid-template-rows: auto auto minmax(0, 1fr); height: 100%; }
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--ag-border); }
+51 -2
View File
@@ -8,8 +8,13 @@ import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './forma
* 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;
@@ -26,11 +31,17 @@ export interface GraphNode {
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. */
@@ -69,6 +80,8 @@ export interface GraphInput {
}
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);
@@ -82,6 +95,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
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,
@@ -104,7 +118,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
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, label: n.label ?? n.ref ?? n.id, sublabel: n.sublabel, type: n.type, brand: n.brand, shared: false, 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);
@@ -113,7 +127,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
const id = edgeId(e.from, e.to);
let edge = edges.get(id);
if (!edge) {
edge = { id, from: e.from, to: e.to, labels: [], diagrams: [], decisions: [] };
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);
@@ -137,6 +151,41 @@ export function buildGraph(input: GraphInput): ModelGraph {
}
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)),
+26 -9
View File
@@ -67,12 +67,16 @@ export function parseQuery(text: string): QueryTerm[] {
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
/** An element by id, then by id or label ignoring case. */
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 exact = graph.nodes.find((n) => n.id === ref);
const elements = graph.nodes.filter(isElement);
const exact = elements.find((n) => n.id === ref);
if (exact) return exact;
const l = ref.toLowerCase();
return graph.nodes.find((n) => n.id.toLowerCase() === l) ?? graph.nodes.find((n) => n.label.toLowerCase() === l);
return elements.find((n) => n.id.toLowerCase() === l) ?? elements.find((n) => n.label.toLowerCase() === l);
}
function globToRegExp(pattern: string): RegExp {
@@ -88,10 +92,11 @@ function diagramMatches(path: string, pattern: string): boolean {
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) {
for (const e of graph.edges.filter(isConnection)) {
map.get(e.from)?.add(e.to);
map.get(e.to)?.add(e.from);
}
@@ -116,7 +121,7 @@ export function shortestPath(graph: ModelGraph, from: string, to: string): { nod
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) {
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 });
}
@@ -174,12 +179,13 @@ export function runQuery(graph: ModelGraph, text: string): QueryResult {
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) => n.shared).map((n) => n.id));
return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id));
case 'unused':
return new Set(graph.nodes.filter((n) => n.shared && n.diagrams.length === 0).map((n) => n.id));
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'];
@@ -198,11 +204,22 @@ export function runQuery(graph: ModelGraph, text: string): QueryResult {
return undefined;
};
const negated: Set<string>[] = [];
for (const term of terms) {
const ids = matching(term);
if (!ids) continue;
if (term.negate) for (const id of ids) result.delete(id);
else result = new Set([...result].filter((id) => ids.has(id)));
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) {
+37 -10
View File
@@ -37,8 +37,9 @@ test('diagram summaries carry nodes and edges, shared and inline', () => {
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.
assert.equal(graph.nodes.length, 14);
// 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');
@@ -49,7 +50,7 @@ test('the model graph unites every diagram: elements once, connections once, lab
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.length, 4 + 7 + 6);
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']);
@@ -67,12 +68,36 @@ test('decisions reach the elements they name and the nodes and edges they are at
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.map((n) => [n.id, n.diagrams.length]), [['api', 1], ['ghost', 0]]);
assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost']);
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', () => {
@@ -87,10 +112,12 @@ test('queries parse into terms; unknown keys are plain text', () => {
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) => [...runQuery(graph, q).nodes].sort();
assert.equal(runQuery(graph, '').nodes.size, 14);
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']);
@@ -107,14 +134,14 @@ test('queries narrow the graph: text, type, diagram, decision, shared', () => {
test('queries walk the graph: uses, near, from/to', () => {
const graph = graphOfModel();
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
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].sort(), ['api>db', 'customer>web', 'web>api']);
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);
@@ -131,5 +158,5 @@ test('connections are shown only when both ends match', () => {
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) => id.split('>').every((end) => result.nodes.has(end))));
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)); }));
});