From 887ca973a0a9b7e30b16503950135b12d2e76635 Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Wed, 21 Oct 2026 23:08:17 +0300 Subject: [PATCH] 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 --- README.md | 1 + SKILL.md | 4 +- scripts/.scripts/model/Taskfile.yml | 7 + scripts/.scripts/model/api/model--graph.sh | 8 + scripts/.scripts/model/lib/--index-api.sh | 1 + scripts/.scripts/model/lib/-model--graph.sh | 9 + scripts/src/actions/model-graph/action.mjs | 38 ++++ scripts/src/cli/commands.mjs | 2 + scripts/src/services/graph.mjs | 226 ++++++++++++++++++++ scripts/src/test/run.mjs | 22 ++ 10 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 scripts/.scripts/model/api/model--graph.sh create mode 100644 scripts/.scripts/model/lib/-model--graph.sh create mode 100644 scripts/src/actions/model-graph/action.mjs create mode 100644 scripts/src/services/graph.mjs diff --git a/README.md b/README.md index 33a42b3..39c841e 100644 --- a/README.md +++ b/README.md @@ -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" ``` diff --git a/SKILL.md b/SKILL.md index 0b34c39..6dab05f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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=""` | 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=""` | 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="" [--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:`, `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="" --id="" --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="" --example="" [--id=""] [--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="" [--out=""]` | 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: []` 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:` for neighbours, `from: to:` for the route, `decision:` 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_-]*$`. diff --git a/scripts/.scripts/model/Taskfile.yml b/scripts/.scripts/model/Taskfile.yml index 7bfae29..00284de 100644 --- a/scripts/.scripts/model/Taskfile.yml +++ b/scripts/.scripts/model/Taskfile.yml @@ -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="" [--query="uses:api"] [--links]' + cmds: + - | + bash ./.scripts/model/api/model--graph.sh {{ .CLI_ARGS }} + silent: true diff --git a/scripts/.scripts/model/api/model--graph.sh b/scripts/.scripts/model/api/model--graph.sh new file mode 100644 index 0000000..d8e3bce --- /dev/null +++ b/scripts/.scripts/model/api/model--graph.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# ------------------------------------------------------------------------------------- +# archify-vscode-ext Agent Skill (MIT) +# ------------------------------------------------------------------------------------- + +. ./.scripts/model/lib/--index.sh + +_model__model__graph "$@" diff --git a/scripts/.scripts/model/lib/--index-api.sh b/scripts/.scripts/model/lib/--index-api.sh index 7ab21f9..d0f8544 100755 --- a/scripts/.scripts/model/lib/--index-api.sh +++ b/scripts/.scripts/model/lib/--index-api.sh @@ -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 diff --git a/scripts/.scripts/model/lib/-model--graph.sh b/scripts/.scripts/model/lib/-model--graph.sh new file mode 100644 index 0000000..4780056 --- /dev/null +++ b/scripts/.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 "$@" +} diff --git a/scripts/src/actions/model-graph/action.mjs b/scripts/src/actions/model-graph/action.mjs new file mode 100644 index 0000000..af87ec3 --- /dev/null +++ b/scripts/src/actions/model-graph/action.mjs @@ -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 model folder, or any file/folder inside it (required) + --query database · type:backend · diagram:0200-* · decision: · 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 } : {}) })), + }; +} diff --git a/scripts/src/cli/commands.mjs b/scripts/src/cli/commands.mjs index 79bd442..60854f6 100644 --- a/scripts/src/cli/commands.mjs +++ b/scripts/src/cli/commands.mjs @@ -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, }; diff --git a/scripts/src/services/graph.mjs b/scripts/src/services/graph.mjs new file mode 100644 index 0000000..05524c0 --- /dev/null +++ b/scripts/src/services/graph.mjs @@ -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 }; +} diff --git a/scripts/src/test/run.mjs b/scripts/src/test/run.mjs index f73020e..67c03ed 100644 --- a/scripts/src/test/run.mjs +++ b/scripts/src/test/run.mjs @@ -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');