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:
1 parent
6daf70fef3
commit
887ca973a0
10 files changed
+317
-1
No files matched your search
@@ -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 } : {}) })),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user