model:graph: the model as one graph with the Graph page's queries; 1.1

- services/graph.mjs builds the union graph (elements, connections, decision and registry nodes) from the loaded model
- queries: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared; walks follow connections only
- task model:graph with --query and --links; three suite checks; SKILL.md task row and gotcha
This commit is contained in:
root-at-skic committed 2026-10-21 23:08:17 +03:00
1 parent 6daf70fef3
commit 887ca973a0
10 files changed
+317 -1

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ Quick start:
cd scripts
task model:scaffold -- --dir="/path/to/online-shop" --id="online-shop" --name="Online Shop" --c4
task model:validate -- --dir="/path/to/online-shop"
task model:graph -- --dir="/path/to/online-shop" --query="from:web to:db"
task diagram:render -- --file="/path/to/online-shop/0200-containers/online-shop.archify" --out="/tmp/containers.html"
```
+3 -1
View File
@@ -15,7 +15,7 @@ license: MIT
compatibility: Requires Node.js 18+, bash and go-task (task). No network access; Archify is bundled under scripts/vendor.
metadata:
author: root-at-skic
version: "1.0"
version: "1.1"
origin-repository: https://gitea.lego-cloud.eu/vscode-extensions-skills/archify-vscode-ext.git
targets: Archify Diagram Viewer (root-at-skic.archify-vscode-ext)
---
@@ -39,6 +39,7 @@ task --list-all
|---|---|
| `task model:validate -- --dir="<model or file inside>"` | Registry, every diagram (refs resolved, Archify validator), every `diagram:` link. Exit 1 on any error. Works on a single diagram outside a model too. |
| `task model:list -- --dir="<model>"` | What the model page shows: metadata, diagrams by folder, registry elements with "used in N diagrams", decisions with status and the diagrams that reference them. |
| `task model:graph -- --dir="<model>" [--query="uses:api"] [--links]` | The model as one graph, as the extension's **Graph** page shows it: every element, every connection once, plus a node per decision and per registry file. `--query` narrows it with the page's language: `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. Use it to answer "what talks to X", "how does A reach B", "what does this decision touch", "which registry elements are unused". |
| `task model:scaffold -- --dir="<folder>" --id="<id>" --name="<name>" [--description="…"] [--c4]` | Creates `main.archify`; with `--c4` also `registry/` and the `0100-context`, `0200-containers`, `0300-components` folders with starter diagrams. Never overwrites. |
| `task model:scaffold -- --dir="<folder>" --example="<name>" [--id="<id>"] [--name="<name>"] [--description="…"]` | Copies a complete example model: `c4-standard-web-app`, `c4-e-shop` or `c4-event-based-integration` (the same three the extension offers under *Archify \| New Model…*). Id, name and description default to the example's own. Never overwrites. |
| `task model:resolve -- --file="<diagram>" [--out="<file.json>"]` | The Archify JSON the extension hands to Archify, for the plain Archify CLI or CI. |
@@ -176,6 +177,7 @@ Diagrams next to `main.archify` are listed first, ungrouped; deeper ones by fold
- **A second `main.archify` deeper down** is reported, not nested. One model per tree.
- **Diagram kinds other than architecture** take `ref` too (workflow and dataflow `nodes`, sequence `participants`, lifecycle `states`), but the registry `type` must fit that kind: lifecycle states use their own type enum, and sequence participants have no `tag`.
- **Decisions are files, ids are words.** One `*.archify-decision` per decision (`decision:` block with id, title, status, date, elements, diagrams, supersedes; prose in context, decision, consequences). Diagrams point at them with `decisions: [<id>]` on the diagram, a node or an edge; the resolver strips the key. An unquoted numeric id (`0001`) is refused because YAML reads it as 1. When an agent changes the shape of a model (a new store, a new queue, a new boundary), it writes a decision and links the elements it touched.
- **The graph answers structure questions.** Before guessing from file contents, run `task model:graph` with a query: `uses:<id>` for neighbours, `from:<a> to:<b>` for the route, `decision:<id>` for what a decision touches, `unused` for registry elements no diagram draws. Walks follow the diagrams' connections only; decision and registry nodes follow the elements they link to. The extension's Graph page (the **Model | Graph** switch on `main.archify`) shows the same graph with the same queries.
- **Descriptions stay in the registry.** The extension shows an element's description as a tooltip in its Elements panel; nothing is generated into the diagram. Write `cards:` in a diagram only when you want text blocks below it.
- **No em dashes or smart quotes in ids**; ids match `^[a-zA-Z][a-zA-Z0-9_-]*$`.
+7
View File
@@ -30,3 +30,10 @@ tasks:
- |
bash ./.scripts/model/api/model--resolve.sh {{ .CLI_ARGS }}
silent: true
graph:
desc: 'The model as one graph, with queries -- --dir="<model folder or any file inside>" [--query="uses:api"] [--links]'
cmds:
- |
bash ./.scripts/model/api/model--graph.sh {{ .CLI_ARGS }}
silent: true
@@ -0,0 +1,8 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
. ./.scripts/model/lib/--index.sh
_model__model__graph "$@"
@@ -8,3 +8,4 @@
. ./.scripts/model/lib/-model--list.sh
. ./.scripts/model/lib/-model--scaffold.sh
. ./.scripts/model/lib/-model--resolve.sh
. ./.scripts/model/lib/-model--graph.sh
@@ -0,0 +1,9 @@
#!/bin/bash
# -------------------------------------------------------------------------------------
# archify-vscode-ext Agent Skill (MIT)
# -------------------------------------------------------------------------------------
# Runs the "model-graph" action of the Node CLI. Flags are forwarded unchanged.
_model__model__graph() {
_model__node__run --action model-graph "$@"
}
@@ -0,0 +1,38 @@
// The model as one graph, as the extension's Graph page shows it, with the
// same queries: answer "what connects to X", "the path from A to B", "what
// a decision touches" or "which registry elements no diagram draws".
import fs from 'node:fs';
import path from 'node:path';
import { MAIN, findRoot, loadModel } from '../../services/model.mjs';
import { buildGraph, runQuery } from '../../services/graph.mjs';
export const help = `model-graph — the whole model as one graph, narrowed by a query
--dir <path> model folder, or any file/folder inside it (required)
--query <text> database · type:backend · diagram:0200-* · decision:<id> · uses:api · near:api,2 · from:web to:db · unused · shared · -shared
terms AND together, a leading - excludes; walks follow the diagrams' connections only
--links also list the links from decisions and registries to their elements`;
export async function run(input) {
if (!input.dir) return { success: false, error: '--dir is required: the model folder or any file inside it.', help };
const target = path.resolve(String(input.dir));
if (!fs.existsSync(target)) return { success: false, error: `${target} does not exist.` };
const root = findRoot(target);
if (!root) return { success: false, error: `No ${MAIN} in ${target} or any folder above it.` };
const graph = buildGraph(loadModel(root));
const query = typeof input.query === 'string' ? input.query : '';
const result = runQuery(graph, query);
const elements = graph.nodes.filter((n) => n.kind === 'element');
const shown = graph.nodes.filter((n) => result.nodes.has(n.id));
const edges = graph.edges.filter((e) => result.edges.has(e.id) && (input.links || e.kind === 'connection'));
return {
success: !result.message,
root,
query: query || undefined,
message: result.message,
summary: { elements: elements.length, connections: graph.edges.filter((e) => e.kind === 'connection').length, registries: graph.nodes.filter((n) => n.kind === 'registry').length, decisions: graph.decisions.length, diagrams: graph.diagrams.length, matching: shown.filter((n) => n.kind === 'element').length },
path: result.path,
nodes: shown.map((n) => ({ id: n.id, kind: n.kind, label: n.label, type: n.type, ...(n.sublabel ? { sublabel: n.sublabel } : {}), ...(n.kind === 'element' ? { shared: n.shared, diagrams: n.diagrams, decisions: n.decisions } : {}), ...(n.kind === 'decision' ? { status: n.status, file: n.file } : {}), ...(n.kind === 'registry' ? { file: n.file, elements: n.elements } : {}) })),
connections: edges.map((e) => ({ from: e.from, to: e.to, kind: e.kind, ...(e.labels.length ? { label: e.labels.join(' / ') } : {}), ...(e.diagrams.length ? { diagrams: e.diagrams } : {}), ...(e.decisions.length ? { decisions: e.decisions } : {}) })),
};
}
+2
View File
@@ -6,6 +6,7 @@ import * as modelValidate from '../actions/model-validate/action.mjs';
import * as modelList from '../actions/model-list/action.mjs';
import * as modelScaffold from '../actions/model-scaffold/action.mjs';
import * as modelResolve from '../actions/model-resolve/action.mjs';
import * as modelGraph from '../actions/model-graph/action.mjs';
import * as diagramValidate from '../actions/diagram-validate/action.mjs';
import * as diagramRender from '../actions/diagram-render/action.mjs';
import { toYaml } from '../services/yaml.mjs';
@@ -15,6 +16,7 @@ const ACTIONS = {
'model-list': modelList,
'model-scaffold': modelScaffold,
'model-resolve': modelResolve,
'model-graph': modelGraph,
'diagram-validate': diagramValidate,
'diagram-render': diagramRender,
};
+226
View File
@@ -0,0 +1,226 @@
// The model as one graph, as the extension's Graph page shows it: every element any
// diagram draws plus every registry element, every connection once, plus one node
// per decision and per registry file. The query language is the page's: terms AND
// together, a leading "-" excludes, key:value terms filter or walk the graph.
import { NODE_COLLECTION } from './model.mjs';
import { effectiveStatus } from './decisions.mjs';
const EDGE_COLLECTIONS = ['connections', 'edges', 'flows', 'transitions', 'messages'];
const NODE_COLLECTIONS = [...new Set(Object.values(NODE_COLLECTION))];
const str = (v) => (typeof v === 'string' && v.length ? v : undefined);
const push = (list, value) => { if (!list.includes(value)) list.push(value); };
export const edgeId = (from, to) => `${from}>${to}`;
export const decisionNodeId = (id) => `decision:${id}`;
export const registryNodeId = (file) => `registry:${file}`;
export function buildGraph(model) {
const nodes = new Map();
const edges = new Map();
for (const e of Object.values(model.registry.elements)) {
nodes.set(e.id, { id: e.id, kind: 'element', label: e.label ?? e.id, sublabel: e.sublabel, type: e.type, brand: e.brand, shared: true, description: e.description?.trim() || undefined, file: e.file, line: e.line, link: e.diagram, diagrams: [], decisions: [] });
}
const diagrams = model.diagrams.filter((d) => d.resolved && !d.isMain).sort((a, b) => a.file.localeCompare(b.file));
for (const d of diagrams) {
const ids = new Set();
for (const key of NODE_COLLECTIONS) {
for (const item of Array.isArray(d.resolved[key]) ? d.resolved[key] : []) {
if (!item || typeof item !== 'object') continue;
const id = str(item.id);
if (!id || ids.has(id)) continue;
ids.add(id);
let node = nodes.get(id);
if (!node) {
node = { id, kind: 'element', label: str(item.label) ?? id, sublabel: str(item.sublabel), type: str(item.type), brand: str(item.brand), shared: false, diagrams: [], decisions: [] };
nodes.set(id, node);
}
push(node.diagrams, d.file);
}
}
for (const key of EDGE_COLLECTIONS) {
for (const item of Array.isArray(d.resolved[key]) ? d.resolved[key] : []) {
const from = str(item?.from);
const to = str(item?.to);
if (!from || !to || !ids.has(from) || !ids.has(to)) continue;
const id = edgeId(from, to);
let edge = edges.get(id);
if (!edge) { edge = { id, kind: 'connection', from, to, labels: [], diagrams: [], decisions: [] }; edges.set(id, edge); }
if (str(item.label)) push(edge.labels, item.label);
push(edge.diagrams, d.file);
}
}
}
const all = model.decisions.decisions;
for (const d of Object.values(all)) for (const id of d.elements ?? []) if (nodes.has(id)) push(nodes.get(id).decisions, d.id);
for (const [decisionId, uses] of Object.entries(model.decisionUsage ?? {})) {
if (!all[decisionId]) continue;
for (const use of uses) {
if (use.attachedTo?.kind === 'node' && nodes.has(use.attachedTo.id)) push(nodes.get(use.attachedTo.id).decisions, decisionId);
if (use.attachedTo?.kind === 'edge') { const edge = edges.get(edgeId(use.attachedTo.from, use.attachedTo.to)); if (edge) push(edge.decisions, decisionId); }
}
}
const byFile = new Map();
for (const e of Object.values(model.registry.elements)) byFile.set(e.file, [...(byFile.get(e.file) ?? []), e.id]);
for (const { file } of model.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 e = edgeId(id, element); edges.set(e, { id: e, kind: 'defines', from: id, to: element, labels: [], diagrams: [], decisions: [] }); }
}
for (const d of Object.values(all)) {
const id = decisionNodeId(d.id);
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, diagrams: [...(d.diagrams ?? [])], decisions: [], status: effectiveStatus(d, all).status });
}
for (const node of [...nodes.values()]) {
if (node.kind !== 'element') continue;
for (const decision of node.decisions) { if (!all[decision]) continue; const e = edgeId(decisionNodeId(decision), node.id); edges.set(e, { id: e, kind: 'decides', from: decisionNodeId(decision), to: node.id, labels: [], diagrams: [], decisions: [] }); }
}
for (const d of Object.values(all)) for (const other of d.supersedes ?? []) { if (!all[other]) continue; const e = edgeId(decisionNodeId(d.id), decisionNodeId(other)); edges.set(e, { id: e, kind: 'supersedes', from: decisionNodeId(d.id), to: decisionNodeId(other), labels: ['supersedes'], diagrams: [], decisions: [] }); }
return {
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
decisions: Object.values(all).map((d) => ({ id: d.id, title: d.title, status: effectiveStatus(d, all).status, file: d.file })).sort((a, b) => a.id.localeCompare(b.id)),
diagrams: diagrams.map((d) => ({ path: d.file, title: d.resolved?.meta?.title, kind: d.diagramType })),
};
}
// ----------------------------------------------------------------- queries
const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']);
const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']);
export function parseQuery(text) {
const tokens = String(text ?? '').trim().split(/\s+/).filter(Boolean);
const terms = [];
for (let i = 0; i < tokens.length; i += 1) {
let token = tokens[i];
const negate = token.startsWith('-') && token.length > 1;
if (negate) token = token.slice(1);
const colon = token.indexOf(':');
let key = colon > 0 ? token.slice(0, colon).toLowerCase() : token.toLowerCase();
let value = colon > 0 ? token.slice(colon + 1) : '';
if (colon > 0 && !WALK_KEYS.has(key) && !FIELD_KEYS.has(key)) { key = 'text'; value = token; }
else if (colon < 0 && key !== 'shared' && key !== 'unused') { key = 'text'; value = token; }
if (key === 'near' && !value.includes(',') && /^\d+$/.test(tokens[i + 1] ?? '')) { value = `${value},${tokens[i + 1]}`; i += 1; }
if (!value && key === 'text') continue;
terms.push({ key, value, negate });
}
return terms;
}
const lower = (s) => (s ?? '').toLowerCase();
const isElement = (n) => n.kind === 'element';
const isConnection = (e) => e.kind === 'connection';
export function findNode(graph, ref) {
const elements = graph.nodes.filter(isElement);
return elements.find((n) => n.id === ref) ?? elements.find((n) => n.id.toLowerCase() === ref.toLowerCase()) ?? elements.find((n) => n.label.toLowerCase() === ref.toLowerCase());
}
function diagramMatches(file, pattern) {
const bare = file.replace(/\.archify$/, '');
const wanted = pattern.replace(/\.archify$/, '');
if (/[*?]/.test(wanted)) { const re = new RegExp(`^${wanted.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i'); return re.test(bare) || re.test(file); }
const l = wanted.toLowerCase();
const b = bare.toLowerCase();
return b === l || b.startsWith(l) || b.includes(`/${l}`);
}
function neighbours(graph) {
const map = new Map(graph.nodes.map((n) => [n.id, new Set()]));
for (const e of graph.edges.filter(isConnection)) { map.get(e.from)?.add(e.to); map.get(e.to)?.add(e.from); }
return map;
}
function within(graph, start, hops) {
const adjacency = neighbours(graph);
let frontier = new Set([start]);
const seen = new Set([start]);
for (let i = 0; i < hops && frontier.size; i += 1) {
const next = new Set();
for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); }
frontier = next;
}
return seen;
}
export function shortestPath(graph, from, to) {
const attempt = (directed) => {
const out = new Map(graph.nodes.map((n) => [n.id, []]));
for (const e of graph.edges.filter(isConnection)) { out.get(e.from)?.push({ to: e.to, edge: e }); if (!directed) out.get(e.to)?.push({ to: e.from, edge: e }); }
const previous = new Map();
const queue = [from];
const seen = new Set([from]);
while (queue.length) {
const id = queue.shift();
if (id === to) break;
for (const step of out.get(id) ?? []) { if (seen.has(step.to)) continue; seen.add(step.to); previous.set(step.to, { from: id, edge: step.edge }); queue.push(step.to); }
}
if (!seen.has(to)) return undefined;
const nodes = [to]; const edges = [];
for (let at = to; at !== from;) { const step = previous.get(at); edges.unshift(step.edge.id); nodes.unshift(step.from); at = step.from; }
return { nodes, edges };
};
if (from === to) return { nodes: [from], edges: [] };
return attempt(true) ?? attempt(false);
}
export function runQuery(graph, text) {
const terms = parseQuery(text);
let result = new Set(graph.nodes.map((n) => n.id));
const notes = [];
let from; let to; let path; let pathEdges;
const matching = (term) => {
const v = term.value;
switch (term.key) {
case 'text': { const l = v.toLowerCase(); return new Set(graph.nodes.filter((n) => [n.id, n.label, n.sublabel, n.brand, n.type].some((f) => lower(f).includes(l))).map((n) => n.id)); }
case 'type': return new Set(graph.nodes.filter((n) => lower(n.type) === v.toLowerCase()).map((n) => n.id));
case 'diagram': return new Set(graph.nodes.filter((n) => n.diagrams.some((d) => diagramMatches(d, v))).map((n) => n.id));
case 'decision': {
const ids = new Set(graph.nodes.filter((n) => n.decisions.includes(v)).map((n) => n.id));
for (const e of graph.edges) if (e.decisions.includes(v)) { ids.add(e.from); ids.add(e.to); }
if (!graph.decisions.some((d) => d.id === v)) notes.push(`No decision "${v}" in this model.`); else ids.add(decisionNodeId(v));
return ids;
}
case 'shared': return new Set(graph.nodes.filter((n) => isElement(n) && n.shared).map((n) => n.id));
case 'unused': return new Set(graph.nodes.filter((n) => isElement(n) && n.shared && !n.diagrams.length).map((n) => n.id));
case 'uses': case 'near': {
const [ref, hopsText] = term.key === 'near' ? v.split(',') : [v, '1'];
const node = findNode(graph, ref);
if (!node) { notes.push(`No element "${ref}".`); return new Set(); }
return within(graph, node.id, Math.max(1, Math.min(Number(hopsText) || 1, 50)));
}
case 'from': from = v; return undefined;
case 'to': to = v; return undefined;
default: return undefined;
}
};
const negated = [];
for (const term of terms) {
const ids = matching(term);
if (!ids) continue;
if (term.negate) { negated.push(ids); for (const id of ids) result.delete(id); }
else result = new Set([...result].filter((id) => ids.has(id)));
}
if (terms.length) {
for (const e of graph.edges) if (!isConnection(e) && result.has(e.to) && graph.nodes.some((n) => n.id === e.from && !isElement(n))) result.add(e.from);
for (const ids of negated) for (const id of ids) result.delete(id);
}
if (from || to) {
if (!from || !to) notes.push('A path needs both from: and to:.');
else {
const a = findNode(graph, from); const b = findNode(graph, to);
if (!a || !b) notes.push(`No element "${!a ? from : to}".`);
else {
const found = shortestPath(graph, a.id, b.id);
if (!found) notes.push(`No path from ${a.id} to ${b.id}.`);
else { path = found.nodes; pathEdges = new Set(found.edges); result = new Set(found.nodes.filter((id) => result.has(id))); }
}
if (!path) result = new Set();
}
}
const edges = new Set();
for (const e of graph.edges) { if (!result.has(e.from) || !result.has(e.to)) continue; if (pathEdges && !pathEdges.has(e.id)) continue; edges.add(e.id); }
return { nodes: result, edges, path, message: notes[0], terms };
}
+22
View File
@@ -69,6 +69,28 @@ try {
assert(/referencedBy:\n\s+- 0200-containers\/online-shop\.archify/.test(r.stdout), 'referencedBy missing');
});
check('model-graph example: 14 elements, 17 connections, decisions and registries as nodes', ['--action', 'model-graph', '--dir', example], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' elements') === '14' && yamlValue(r.stdout, ' connections') === '17', 'expected 14 elements and 17 connections');
assert(yamlValue(r.stdout, ' registries') === '3' && yamlValue(r.stdout, ' decisions') === '3', 'expected 3 registries and 3 decisions');
assert(/- id: decision:queue-between-api-and-worker\n\s+kind: decision/.test(r.stdout), 'decision node missing');
assert(/- id: registry:registry\/containers\.archify-registry\n\s+kind: registry/.test(r.stdout), 'registry node missing');
notes.push('14 elements, 17 connections, 3 registries, 3 decisions');
});
check('model-graph example: from:customer to:db is the path customer → web → api → db', ['--action', 'model-graph', '--dir', example, '--query', 'from:customer to:db'], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(/path:\n\s+- customer\n\s+- web\n\s+- api\n\s+- db/.test(r.stdout), 'path missing');
assert(yamlValue(r.stdout, ' matching') === '4', 'expected 4 matching elements');
notes.push('path of 4');
});
check('model-graph example: uses:api is api and its neighbours; an unknown element is reported', ['--action', 'model-graph', '--dir', example, '--query', 'uses:api -type:decision -type:registry'], (r, notes) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, ' matching') === '5', 'expected api plus 4 neighbours');
const unknown = cliRun(['--action', 'model-graph', '--dir', example, '--query', 'uses:nothing']);
assert(unknown.status !== 0 && /No element \\?"nothing\\?"/.test(unknown.stdout), 'unknown element not reported');
notes.push('5 elements; unknown element reported');
});
check('model-validate from a file inside the model', ['--action', 'model-validate', '--dir', path.join(example, '0300-components', 'api.archify')], (r) => {
assert(r.status === 0, `exit ${r.status}`);
assert(yamlValue(r.stdout, 'mode') === 'model', 'root was not found from a file inside');