0.9.0: model graph with queries #27
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## 0.9.0 — 2026-10-21
|
## 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.
|
- **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`; terms AND together, a leading `-` excludes; the count reads "9 of 14 elements"; **?** lists the forms and runs an example on click.
|
- **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.
|
- **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.
|
- **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.
|
- Diagram summaries in the model index now carry every node and edge of a diagram (shared and inline), which the graph is built from.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- **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`.
|
||||||
- **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.
|
- **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). The **?** button lists them; clicking an example runs it.
|
- **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.
|
- **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.
|
- **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).
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
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
@@ -22,9 +22,11 @@ interface SavedView {
|
|||||||
|
|
||||||
cytoscape.use(fcose);
|
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_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'];
|
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 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. */
|
/** Edge labels are drawn by default up to this many connections; above it they clutter. */
|
||||||
const LABELS_UP_TO = 40;
|
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 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() {
|
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 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, label: e.labels.join(' / ') } }));
|
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];
|
return [...nodes, ...edges];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,8 +289,12 @@ class GraphPage {
|
|||||||
return [
|
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', 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[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', 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: '.dim', style: { opacity: 0.14, events: 'no' } },
|
||||||
{ selector: 'node.hit', style: { 'border-width': 3 } },
|
{ 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 } },
|
{ 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 {
|
private layout(randomize: boolean): void {
|
||||||
const visible = this.cy.elements().not('.hidden');
|
const visible = this.cy.elements().not('.hidden');
|
||||||
visible
|
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();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,14 +353,14 @@ class GraphPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateCount(): void {
|
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;
|
const r = this.result;
|
||||||
if (!r || !this.queryInput.value.trim()) {
|
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');
|
this.countEl.classList.remove('is-error');
|
||||||
return;
|
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.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));
|
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.'));
|
this.side.appendChild(el('p', 'ag-empty', 'Click an element or a connection.'));
|
||||||
const h3 = el('h3', '', 'This model');
|
const h3 = el('h3', '', 'This model');
|
||||||
this.side.appendChild(h3);
|
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);
|
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) {
|
if (unused) {
|
||||||
const hint = el('button', 'ag-link', `${unused} unused registry element${unused === 1 ? '' : 's'}`);
|
const hint = el('button', 'ag-link', `${unused} unused registry element${unused === 1 ? '' : 's'}`);
|
||||||
hint.type = 'button';
|
hint.type = 'button';
|
||||||
@@ -384,8 +392,98 @@ class GraphPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private showSide(target: NodeSingular | EdgeSingular): void {
|
private showSide(target: NodeSingular | EdgeSingular): void {
|
||||||
if (target.isNode()) this.showNode(this.nodesById.get(target.id()));
|
if (!target.isNode()) {
|
||||||
else this.showEdge(this.edgesById.get(target.id()));
|
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 {
|
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(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.'));
|
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'}`));
|
this.side.appendChild(el('h3', '', `${connected.length} connection${connected.length === 1 ? '' : 's'}`));
|
||||||
const list = el('div');
|
const list = el('div');
|
||||||
for (const e of connected) {
|
for (const e of connected) {
|
||||||
|
|||||||
+1
-1
@@ -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-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-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-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; }
|
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 { 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); }
|
.ag-top { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--ag-border); }
|
||||||
|
|||||||
+51
-2
@@ -8,8 +8,13 @@ import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './forma
|
|||||||
* the webview bundle and the unit tests share it.
|
* 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 {
|
export interface GraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** An element of the model, a decision (`decision:<id>`) or a registry file (`registry:<file>`). */
|
||||||
|
kind: GraphNodeKind;
|
||||||
label: string;
|
label: string;
|
||||||
sublabel?: string;
|
sublabel?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -26,11 +31,17 @@ export interface GraphNode {
|
|||||||
diagrams: string[];
|
diagrams: string[];
|
||||||
/** Decisions about it: named by the decision, or attached to its node in a diagram. */
|
/** Decisions about it: named by the decision, or attached to its node in a diagram. */
|
||||||
decisions: string[];
|
decisions: string[];
|
||||||
|
/** Decision nodes: the effective status. */
|
||||||
|
status?: string;
|
||||||
|
/** Registry nodes: ids of the elements the file defines. */
|
||||||
|
elements?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
export interface GraphEdge {
|
||||||
/** `from>to`, unique in the graph. */
|
/** `from>to`, unique in the graph. */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** A connection drawn by a diagram, or a link to a decision or registry node. */
|
||||||
|
kind: GraphEdgeKind;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
/** Distinct labels the diagrams give this connection. */
|
/** 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 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 {
|
function pushUnique(list: string[], value: string): void {
|
||||||
if (!list.includes(value)) list.push(value);
|
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()) {
|
for (const e of input.registry.elements.values()) {
|
||||||
nodes.set(e.id, {
|
nodes.set(e.id, {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
|
kind: 'element',
|
||||||
label: e.label ?? e.id,
|
label: e.label ?? e.id,
|
||||||
sublabel: e.sublabel,
|
sublabel: e.sublabel,
|
||||||
type: e.type,
|
type: e.type,
|
||||||
@@ -104,7 +118,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
|
|||||||
const element = n.ref ? input.registry.elements.get(n.ref) : undefined;
|
const element = n.ref ? input.registry.elements.get(n.ref) : undefined;
|
||||||
node = element
|
node = element
|
||||||
? { ...nodes.get(element.id)!, id: n.id, label: n.label ?? element.label ?? n.id, sublabel: n.sublabel ?? element.sublabel, diagrams: [], decisions: [] }
|
? { ...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);
|
nodes.set(n.id, node);
|
||||||
}
|
}
|
||||||
pushUnique(node.diagrams, d.relativePath);
|
pushUnique(node.diagrams, d.relativePath);
|
||||||
@@ -113,7 +127,7 @@ export function buildGraph(input: GraphInput): ModelGraph {
|
|||||||
const id = edgeId(e.from, e.to);
|
const id = edgeId(e.from, e.to);
|
||||||
let edge = edges.get(id);
|
let edge = edges.get(id);
|
||||||
if (!edge) {
|
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);
|
edges.set(id, edge);
|
||||||
}
|
}
|
||||||
if (e.label) pushUnique(edge.labels, e.label);
|
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 });
|
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 {
|
return {
|
||||||
meta: input.meta,
|
meta: input.meta,
|
||||||
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
|||||||
+26
-9
@@ -67,12 +67,16 @@ export function parseQuery(text: string): QueryTerm[] {
|
|||||||
|
|
||||||
const lower = (s: string | undefined) => (s ?? '').toLowerCase();
|
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 {
|
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;
|
if (exact) return exact;
|
||||||
const l = ref.toLowerCase();
|
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 {
|
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}`);
|
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>> {
|
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
|
||||||
const map = new 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 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.from)?.add(e.to);
|
||||||
map.get(e.to)?.add(e.from);
|
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 attempt = (directed: boolean) => {
|
||||||
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
|
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
|
||||||
for (const n of graph.nodes) out.set(n.id, []);
|
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 });
|
out.get(e.from)?.push({ to: e.to, edge: e });
|
||||||
if (!directed) out.get(e.to)?.push({ to: e.from, 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));
|
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); }
|
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.`);
|
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`);
|
||||||
|
else ids.add(`decision:${v}`);
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
case 'shared':
|
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':
|
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 'uses':
|
||||||
case 'near': {
|
case 'near': {
|
||||||
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
|
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
|
||||||
@@ -198,11 +204,22 @@ export function runQuery(graph: ModelGraph, text: string): QueryResult {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const negated: Set<string>[] = [];
|
||||||
for (const term of terms) {
|
for (const term of terms) {
|
||||||
const ids = matching(term);
|
const ids = matching(term);
|
||||||
if (!ids) continue;
|
if (!ids) continue;
|
||||||
if (term.negate) for (const id of ids) result.delete(id);
|
if (term.negate) {
|
||||||
else result = new Set([...result].filter((id) => ids.has(id)));
|
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) {
|
||||||
|
|||||||
+37
-10
@@ -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', () => {
|
test('the model graph unites every diagram: elements once, connections once, labels merged', () => {
|
||||||
const graph = graphOfModel();
|
const graph = graphOfModel();
|
||||||
// 11 registry elements + 3 inline components of the API diagram.
|
// 11 registry elements + 3 inline components of the API diagram, plus 3 decision and 3 registry nodes.
|
||||||
assert.equal(graph.nodes.length, 14);
|
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')!;
|
const db = graph.nodes.find((n) => n.id === 'db')!;
|
||||||
assert.equal(db.shared, true);
|
assert.equal(db.shared, true);
|
||||||
assert.equal(db.type, 'database');
|
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.equal(orders.shared, false);
|
||||||
assert.deepEqual(orders.diagrams, ['0300-components/api.archify']);
|
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.
|
// 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')!;
|
const apiDb = graph.edges.find((e) => e.id === 'api>db')!;
|
||||||
assert.deepEqual(apiDb.labels, ['SQL']);
|
assert.deepEqual(apiDb.labels, ['SQL']);
|
||||||
assert.deepEqual(apiDb.diagrams, ['0200-containers/online-shop.archify']);
|
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');
|
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', () => {
|
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 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 diagrams = [summarizeDiagram('diagram_type: architecture\ncomponents:\n - ref: api\n', 'a.archify')];
|
||||||
const graph = buildGraph({ meta: {}, registry, diagrams, decisions: mergeDecisions([]), decisionUsage: new Map() });
|
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(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']);
|
assert.deepEqual([...runQuery(graph, 'unused').nodes], ['ghost', 'registry:r']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('queries parse into terms; unknown keys are plain text', () => {
|
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(' '), []);
|
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', () => {
|
test('queries narrow the graph: text, type, diagram, decision, shared', () => {
|
||||||
const graph = graphOfModel();
|
const graph = graphOfModel();
|
||||||
const ids = (q: string) => [...runQuery(graph, q).nodes].sort();
|
const ids = (q: string) => elementIds(graph, q);
|
||||||
assert.equal(runQuery(graph, '').nodes.size, 14);
|
assert.equal(runQuery(graph, '').nodes.size, 20);
|
||||||
assert.deepEqual(ids('type:database'), ['cache', 'db']);
|
assert.deepEqual(ids('type:database'), ['cache', 'db']);
|
||||||
assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']);
|
assert.deepEqual(ids('type:external'), ['customer', 'email', 'psp', 'support']);
|
||||||
assert.deepEqual(ids('worker'), ['worker']);
|
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', () => {
|
test('queries walk the graph: uses, near, from/to', () => {
|
||||||
const graph = graphOfModel();
|
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('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'), ['api', 'catalogue', 'db', 'orders', 'queue', 'worker']);
|
||||||
assert.deepEqual(ids('near:worker 2 type:database'), ['db']);
|
assert.deepEqual(ids('near:worker 2 type:database'), ['db']);
|
||||||
const result = runQuery(graph, 'from:customer to:db');
|
const result = runQuery(graph, 'from:customer to:db');
|
||||||
assert.deepEqual(result.path, ['customer', 'web', 'api', '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.
|
// 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.deepEqual(shortestPath(graph, 'db', 'customer')?.nodes, ['db', 'api', 'web', 'customer']);
|
||||||
assert.equal(shortestPath(graph, 'psp', 'auth')?.nodes.length, 5);
|
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.nodes.has('api'));
|
||||||
assert.ok(!result.edges.has('api>db'));
|
assert.ok(!result.edges.has('api>db'));
|
||||||
assert.ok(result.edges.has('orders>queue') === false);
|
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)); }));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user