diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a65647..d777a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`, `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:`, `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:`. 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. diff --git a/README.md b/README.md index 7f9bf68..7899ae9 100644 --- a/README.md +++ b/README.md @@ -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 0) 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 (/ 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:` (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 0) 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 (/ 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:` (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). diff --git a/docs/model.md b/docs/model.md index 7fc2dd3..b239878 100644 --- a/docs/model.md +++ b/docs/model.md @@ -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:`, 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:`, 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. diff --git a/src/graph/index.ts b/src/graph/index.ts index 8b4346a..d428200 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -22,9 +22,11 @@ interface SavedView { cytoscape.use(fcose); -const TYPE_COLORS: Record = { 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 = { 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 = { 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: · 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) { diff --git a/src/graph/style.ts b/src/graph/style.ts index 63359e1..3ed39f0 100644 --- a/src/graph/style.ts +++ b/src/graph/style.ts @@ -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); } diff --git a/src/model/graph.ts b/src/model/graph.ts index 03a4c4d..fd007fb 100644 --- a/src/model/graph.ts +++ b/src/model/graph.ts @@ -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:`) or a registry file (`registry:`). */ + 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(); + 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)), diff --git a/src/model/graphQuery.ts b/src/model/graphQuery.ts index 3c4bbfc..9f9c5b6 100644 --- a/src/model/graphQuery.ts +++ b/src/model/graphQuery.ts @@ -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> { const map = new Map>(); 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(); 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[] = []; 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) { diff --git a/test/graph.test.ts b/test/graph.test.ts index 20f0c71..07b7ccb 100644 --- a/test/graph.test.ts +++ b/test/graph.test.ts @@ -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)); })); });