Model graph: the whole model as one graph with queries; 0.9.0
- Model | Graph switch on main.archify (Diagram | Graph on diagram pages, narrowed to that diagram) - union graph of every element and connection, registry and inline, built from the model index - query box: text, type:, diagram:, decision:, uses:, near:, from: to:, unused, shared, -shared - side panel per element and connection with diagrams, registry line and decisions one click away - legend chips, Fit, Layout, Labels, Export PNG; Cytoscape.js bundled as dist/graph.js - command Archify | Open Model Graph; integration check; unit tests for graph and queries
This commit is contained in:
1 parent
265f8c57f6
commit
c8645b228b
20 files changed
+1551
-30
No files matched your search
+49
-2
@@ -309,6 +309,23 @@ export function groupOf(relativePath: string): string {
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
/** A node as a diagram declares it: a `ref` to the registry, or an inline element. */
|
||||
export interface DiagramNode {
|
||||
id: string;
|
||||
ref?: string;
|
||||
type?: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
/** A connection, edge, flow, transition or message between two nodes of a diagram. */
|
||||
export interface DiagramEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface DiagramSummary {
|
||||
relativePath: string;
|
||||
group: string;
|
||||
@@ -317,6 +334,10 @@ export interface DiagramSummary {
|
||||
used: string[];
|
||||
/** Decisions the diagram points at. */
|
||||
decisions: DecisionRef[];
|
||||
/** Every node the diagram draws, shared or inline, in file order. */
|
||||
nodes: DiagramNode[];
|
||||
/** Every edge between two of those nodes. */
|
||||
edges: DiagramEdge[];
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
@@ -326,14 +347,40 @@ export function summarizeDiagram(text: string, relativePath: string): DiagramSum
|
||||
try {
|
||||
doc = parseYaml(text, { prettyErrors: false, uniqueKeys: false });
|
||||
} catch (error) {
|
||||
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||
return { relativePath, group: groupOf(relativePath), used: [], decisions: [], nodes: [], edges: [], parseError: error instanceof Error ? error.message.split('\n')[0] : String(error) };
|
||||
}
|
||||
const obj = isObj(doc) ? doc : {};
|
||||
const meta = isObj(obj.meta) ? obj.meta : {};
|
||||
const components = Array.isArray(obj.components) ? obj.components : [];
|
||||
const used = [...new Set(components.map((c) => (isObj(c) ? str(c.ref) : undefined)).filter((r): r is string => !!r))];
|
||||
const decisions = takeDecisionRefs({ ...obj }, { registry: { elements: new Map(), files: [], problems: [] }, fileName: relativePath }, []);
|
||||
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions };
|
||||
const nodes: DiagramNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const key of NODE_COLLECTIONS) {
|
||||
const items = obj[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
for (const item of items) {
|
||||
if (!isObj(item)) continue;
|
||||
const ref = str(item.ref);
|
||||
const id = str(item.id) ?? ref;
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
nodes.push({ id, ref, type: str(item.type), label: str(item.label), sublabel: str(item.sublabel), brand: str(item.brand) });
|
||||
}
|
||||
}
|
||||
const edges: DiagramEdge[] = [];
|
||||
for (const key of EDGE_COLLECTIONS) {
|
||||
const items = obj[key];
|
||||
if (!Array.isArray(items)) continue;
|
||||
for (const item of items) {
|
||||
if (!isObj(item)) continue;
|
||||
const from = str(item.from);
|
||||
const to = str(item.to);
|
||||
if (!from || !to || !seen.has(from) || !seen.has(to)) continue;
|
||||
edges.push({ from, to, label: str(item.label) });
|
||||
}
|
||||
}
|
||||
return { relativePath, group: groupOf(relativePath), kind: str(obj.diagram_type), title: str(meta.title), used, decisions, nodes, edges };
|
||||
}
|
||||
|
||||
/** Groups in display order: the root group first, then folders in path order. */
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Decision, DecisionSet, effectiveStatus } from './decisions';
|
||||
import { DecisionAttachment, DiagramSummary, ModelMeta, Registry } from './format';
|
||||
|
||||
/**
|
||||
* The model as one graph: every element any diagram draws (and every registry
|
||||
* element, drawn or not), every connection any diagram has between two of
|
||||
* them, each once. Pure: built from the model index, no VS Code imports, so
|
||||
* the webview bundle and the unit tests share it.
|
||||
*/
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
type?: string;
|
||||
brand?: string;
|
||||
/** Defined in a registry (true) or inline in one diagram (false). */
|
||||
shared: boolean;
|
||||
description?: string;
|
||||
/** Registry file and 0-based line, for shared elements. */
|
||||
file?: string;
|
||||
line?: number;
|
||||
/** Diagram that explains the element, relative to the model root. */
|
||||
link?: string;
|
||||
/** Diagrams that draw it, relative paths in path order. */
|
||||
diagrams: string[];
|
||||
/** Decisions about it: named by the decision, or attached to its node in a diagram. */
|
||||
decisions: string[];
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
/** `from>to`, unique in the graph. */
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
/** Distinct labels the diagrams give this connection. */
|
||||
labels: string[];
|
||||
diagrams: string[];
|
||||
decisions: string[];
|
||||
}
|
||||
|
||||
export interface GraphDecision {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
export interface GraphDiagram {
|
||||
path: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface ModelGraph {
|
||||
meta: ModelMeta;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
decisions: GraphDecision[];
|
||||
diagrams: GraphDiagram[];
|
||||
}
|
||||
|
||||
export interface GraphInput {
|
||||
meta: ModelMeta;
|
||||
registry: Registry;
|
||||
diagrams: DiagramSummary[];
|
||||
decisions: DecisionSet;
|
||||
decisionUsage: Map<string, { path: string; attachedTo: DecisionAttachment }[]>;
|
||||
}
|
||||
|
||||
export const edgeId = (from: string, to: string): string => `${from}>${to}`;
|
||||
|
||||
function pushUnique(list: string[], value: string): void {
|
||||
if (!list.includes(value)) list.push(value);
|
||||
}
|
||||
|
||||
export function buildGraph(input: GraphInput): ModelGraph {
|
||||
const nodes = new Map<string, GraphNode>();
|
||||
const edges = new Map<string, GraphEdge>();
|
||||
|
||||
// Registry elements first: shared, and present even when no diagram draws them yet.
|
||||
for (const e of input.registry.elements.values()) {
|
||||
nodes.set(e.id, {
|
||||
id: e.id,
|
||||
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 = [...input.diagrams].filter((d) => !d.parseError).sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
for (const d of diagrams) {
|
||||
for (const n of d.nodes) {
|
||||
let node = nodes.get(n.id);
|
||||
if (!node) {
|
||||
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: [] };
|
||||
nodes.set(n.id, node);
|
||||
}
|
||||
pushUnique(node.diagrams, d.relativePath);
|
||||
}
|
||||
for (const e of d.edges) {
|
||||
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: [] };
|
||||
edges.set(id, edge);
|
||||
}
|
||||
if (e.label) pushUnique(edge.labels, e.label);
|
||||
pushUnique(edge.diagrams, d.relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
const all = input.decisions.decisions;
|
||||
for (const d of all.values()) for (const id of d.elements) nodes.get(id)?.decisions && pushUnique(nodes.get(id)!.decisions, d.id);
|
||||
for (const [decisionId, uses] of input.decisionUsage) {
|
||||
if (!all.has(decisionId)) continue;
|
||||
for (const use of uses) {
|
||||
if (use.attachedTo.kind === 'node') {
|
||||
const node = nodes.get(use.attachedTo.id);
|
||||
if (node) pushUnique(node.decisions, decisionId);
|
||||
} else if (use.attachedTo.kind === 'edge') {
|
||||
const edge = edges.get(edgeId(use.attachedTo.from, use.attachedTo.to));
|
||||
if (edge) pushUnique(edge.decisions, decisionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const decisionRow = (d: Decision): GraphDecision => ({ id: d.id, title: d.title, status: effectiveStatus(d, all).status, file: d.file });
|
||||
return {
|
||||
meta: input.meta,
|
||||
nodes: [...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
decisions: [...all.values()].map(decisionRow).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
diagrams: diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { GraphEdge, GraphNode, ModelGraph } from './graph';
|
||||
|
||||
/**
|
||||
* The graph page's query box. A few words, no language to learn: terms are
|
||||
* ANDed, a leading `-` excludes, `key:value` terms filter by a field or walk
|
||||
* the graph. Pure, shared by the webview bundle and the unit tests.
|
||||
*
|
||||
* database label, id, sublabel, brand or type contain it
|
||||
* type:backend elements of that type
|
||||
* diagram:0200-* drawn by a diagram whose path matches (glob)
|
||||
* decision:<id> what the decision is about or attached to
|
||||
* uses:api api and its neighbours
|
||||
* near:api,2 api and everything within two steps
|
||||
* from:web to:db the shortest path between two elements
|
||||
* unused registry elements no diagram draws
|
||||
* shared / -shared registry elements / inline elements
|
||||
*/
|
||||
|
||||
export interface QueryTerm {
|
||||
key: string;
|
||||
value: string;
|
||||
negate: boolean;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
/** Ids of the elements that match. Every element when the query is empty. */
|
||||
nodes: Set<string>;
|
||||
/** Ids of the connections shown: both ends match, or the path found. */
|
||||
edges: Set<string>;
|
||||
/** The path found by `from: to:`, in order. */
|
||||
path?: string[];
|
||||
/** Why the result is empty or partial, when it is. */
|
||||
message?: string;
|
||||
terms: QueryTerm[];
|
||||
}
|
||||
|
||||
const WALK_KEYS = new Set(['uses', 'near', 'from', 'to']);
|
||||
const FIELD_KEYS = new Set(['type', 'diagram', 'decision', 'shared', 'unused', 'text']);
|
||||
|
||||
export function parseQuery(text: string): QueryTerm[] {
|
||||
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
||||
const terms: QueryTerm[] = [];
|
||||
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;
|
||||
}
|
||||
// `near:api 2` is also accepted: a bare number after near is the hop count.
|
||||
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: string | undefined) => (s ?? '').toLowerCase();
|
||||
|
||||
/** An element by id, then by id or label ignoring case. */
|
||||
export function findNode(graph: ModelGraph, ref: string): GraphNode | undefined {
|
||||
const exact = graph.nodes.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);
|
||||
}
|
||||
|
||||
function globToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
return new RegExp(`^${escaped}$`, 'i');
|
||||
}
|
||||
|
||||
function diagramMatches(path: string, pattern: string): boolean {
|
||||
const bare = path.replace(/\.archify$/, '');
|
||||
const wanted = pattern.replace(/\.archify$/, '');
|
||||
if (wanted.includes('*') || wanted.includes('?')) return globToRegExp(wanted).test(bare) || globToRegExp(wanted).test(path);
|
||||
const l = wanted.toLowerCase();
|
||||
return bare.toLowerCase() === l || bare.toLowerCase().startsWith(`${l}/`) || bare.toLowerCase().startsWith(l) || bare.toLowerCase().includes(`/${l}`);
|
||||
}
|
||||
|
||||
function neighbours(graph: ModelGraph): Map<string, Set<string>> {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const n of graph.nodes) map.set(n.id, new Set());
|
||||
for (const e of graph.edges) {
|
||||
map.get(e.from)?.add(e.to);
|
||||
map.get(e.to)?.add(e.from);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function within(graph: ModelGraph, start: string, hops: number): Set<string> {
|
||||
const adjacency = neighbours(graph);
|
||||
let frontier = new Set([start]);
|
||||
const seen = new Set([start]);
|
||||
for (let i = 0; i < hops; i += 1) {
|
||||
const next = new Set<string>();
|
||||
for (const id of frontier) for (const other of adjacency.get(id) ?? []) if (!seen.has(other)) { seen.add(other); next.add(other); }
|
||||
frontier = next;
|
||||
if (!frontier.size) break;
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** Shortest path by breadth-first search; directed first, then ignoring direction. */
|
||||
export function shortestPath(graph: ModelGraph, from: string, to: string): { nodes: string[]; edges: string[] } | undefined {
|
||||
const attempt = (directed: boolean) => {
|
||||
const out = new Map<string, { to: string; edge: GraphEdge }[]>();
|
||||
for (const n of graph.nodes) out.set(n.id, []);
|
||||
for (const e of graph.edges) {
|
||||
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<string, { from: string; edge: GraphEdge }>();
|
||||
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: string[] = [];
|
||||
let at = to;
|
||||
while (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: ModelGraph, text: string): QueryResult {
|
||||
const terms = parseQuery(text);
|
||||
const everything = new Set(graph.nodes.map((n) => n.id));
|
||||
let result = new Set(everything);
|
||||
const notes: string[] = [];
|
||||
let from: string | undefined;
|
||||
let to: string | undefined;
|
||||
let pathEdges: Set<string> | undefined;
|
||||
let path: string[] | undefined;
|
||||
|
||||
const matching = (term: QueryTerm): Set<string> | undefined => {
|
||||
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.`);
|
||||
return ids;
|
||||
}
|
||||
case 'shared':
|
||||
return new Set(graph.nodes.filter((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));
|
||||
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(); }
|
||||
const hops = Math.max(1, Math.min(Number(hopsText) || 1, 50));
|
||||
return within(graph, node.id, hops);
|
||||
}
|
||||
case 'from':
|
||||
from = v;
|
||||
return undefined;
|
||||
case 'to':
|
||||
to = v;
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
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 (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<string>();
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user