Files
archify-vscode-ext/scripts/src/services/model.mjs
T
root-at-skic c68f7df108 Generate Archify cards from registry descriptions for model diagrams
An architecture diagram inside a model that defines no cards gets one card
per drawn shared element with a description, as the extension does. Test
covers the resolved JSON; formats.md and SKILL.md describe the rule.
2026-10-19 12:53:40 +03:00

301 lines
16 KiB
JavaScript

// The model: a folder with main.archify on top, diagrams (*.archify) and
// registries (*.archify-registry) below it. This module finds the root, loads
// the registry, resolves `ref` components and checks `diagram:` links.
// Formats follow FORMAT.md of the Archify Diagram Viewer design.
import fs from 'node:fs';
import path from 'node:path';
import { readSource } from './source.mjs';
import { receiptProblems, validateJson, withTempJson } from './archify.mjs';
export const MAIN = 'main.archify';
export const DIAGRAM_EXT = '.archify';
export const REGISTRY_EXT = '.archify-registry';
export const TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'];
export const COMPONENT_TYPES = ['frontend', 'backend', 'database', 'cloud', 'security', 'messagebus', 'external'];
export const STATE_TYPES = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external'];
export const ID = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
// Node collection of each diagram kind, and the registry fields that kind accepts.
export const NODE_COLLECTION = { architecture: 'components', workflow: 'nodes', dataflow: 'nodes', sequence: 'participants', lifecycle: 'states' };
const COPY_ALL = ['type', 'label', 'sublabel', 'tag', 'icon', 'brand', 'sources'];
const COPY = {
architecture: COPY_ALL,
workflow: COPY_ALL,
dataflow: COPY_ALL,
sequence: ['type', 'label', 'sublabel', 'icon', 'brand', 'sources'],
lifecycle: COPY_ALL,
};
// What a diagram may override on a shared element. Everything else in COPY is fixed.
export const OVERRIDE = ['label', 'sublabel', 'tag'];
// Archify card dot colour per component type (the colour Archify gives the node).
const DOT_BY_TYPE = { frontend: 'cyan', backend: 'emerald', database: 'violet', cloud: 'amber', security: 'rose', messagebus: 'orange', external: 'slate' };
export const dotForType = (type) => DOT_BY_TYPE[type ?? ''] ?? 'slate';
// Archify info cards for shared elements: one per element with a description,
// titled by its label. An architecture diagram without cards of its own gets
// these for the elements it draws, as the extension does.
export function cardsFromElements(elements) {
return elements
.filter((element) => typeof element.description === 'string' && element.description.trim())
.map((element) => ({ dot: dotForType(element.type), title: element.label ?? element.id, items: [element.description.trim()] }));
}
// Registry-only fields, never sent to Archify.
const REGISTRY_ONLY = ['description', 'diagram'];
const SKIP_DIRS = new Set(['node_modules', '.git']);
export function problem(severity, file, message, extra = {}) {
return { severity, file, ...extra, message };
}
// Nearest folder at or above `target` (file or folder) that holds main.archify.
export function findRoot(target) {
const start = path.resolve(target);
let dir = fs.existsSync(start) && fs.statSync(start).isDirectory() ? start : path.dirname(start);
for (;;) {
if (fs.existsSync(path.join(dir, MAIN))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
export function relative(root, file) {
return path.relative(root, file).split(path.sep).join('/');
}
// Every diagram and registry under the root, in folder order. A second
// main.archify deeper down is reported, not nested.
export function scanModel(root) {
const diagrams = [];
const registries = [];
const nestedMains = [];
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) walk(full);
} else if (entry.name === MAIN && dir !== root) nestedMains.push(full);
else if (entry.name.endsWith(REGISTRY_EXT)) registries.push(full);
else if (entry.name.endsWith(DIAGRAM_EXT)) diagrams.push(full);
}
};
walk(root);
// Root-level diagrams first (main.archify at the very top), then by folder path.
diagrams.sort((a, b) => {
const da = relative(root, path.dirname(a));
const db = relative(root, path.dirname(b));
if (da !== db) return da === '' ? -1 : db === '' ? 1 : da.localeCompare(db);
if (path.basename(a) === MAIN) return -1;
if (path.basename(b) === MAIN) return 1;
return a.localeCompare(b);
});
return { diagrams, registries, nestedMains };
}
function linkTarget(root, link) {
if (typeof link !== 'string' || !link.trim()) return { ok: false, reason: 'is not a path' };
if (path.isAbsolute(link) || link.split('/').includes('..')) return { ok: false, reason: 'must be relative to the model root, without ".."' };
if (!link.endsWith(DIAGRAM_EXT)) return { ok: false, reason: `must point at a ${DIAGRAM_EXT} file` };
const target = path.join(root, ...link.split('/'));
if (!fs.existsSync(target)) return { ok: false, reason: `does not exist under ${root}` };
return { ok: true, target };
}
// Loads every registry file under the root. Element ids are unique across
// all registry files; each element must have a type and a label.
export function loadRegistry(root, registries) {
const elements = {};
const problems = [];
const files = [];
for (const file of registries) {
const rel = relative(root, file);
const source = readSource(file);
files.push({ file: rel, elements: 0 });
for (const error of source.errors) problems.push(problem('error', rel, error.message, { line: error.line }));
if (!source.data) continue;
const doc = source.data;
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
problems.push(problem('error', rel, 'A registry file is a YAML mapping with an "elements" key.', { line: 1 }));
continue;
}
if (doc.elements === undefined) {
problems.push(problem('error', rel, 'Missing "elements" mapping.', { line: 1 }));
continue;
}
if (typeof doc.elements !== 'object' || doc.elements === null || Array.isArray(doc.elements)) {
problems.push(problem('error', rel, '"elements" must be a mapping of id to element.', { line: source.lineOf('/elements'), pointer: '/elements' }));
continue;
}
for (const [id, element] of Object.entries(doc.elements)) {
const pointer = `/elements/${id}`;
const line = source.lineOf(pointer);
files[files.length - 1].elements += 1;
if (!ID.test(id)) problems.push(problem('error', rel, `Element id "${id}" must match ${ID}.`, { line, pointer }));
if (elements[id]) {
problems.push(problem('error', rel, `Element "${id}" is defined twice (also in ${elements[id].file}, line ${elements[id].line}).`, { line, pointer }));
continue;
}
if (typeof element !== 'object' || element === null) {
problems.push(problem('error', rel, `Element "${id}" must be a mapping.`, { line, pointer }));
continue;
}
if (!element.type) problems.push(problem('error', rel, `Element "${id}" has no type.`, { line, pointer }));
else if (!COMPONENT_TYPES.includes(element.type) && !STATE_TYPES.includes(element.type)) {
problems.push(problem('error', rel, `Element "${id}" has type "${element.type}"; Archify knows ${COMPONENT_TYPES.join(', ')} (and lifecycle state types).`, { line: source.lineOf(`${pointer}/type`), pointer: `${pointer}/type` }));
}
if (!element.label) problems.push(problem('error', rel, `Element "${id}" has no label.`, { line, pointer }));
for (const key of Object.keys(element)) {
if (!COPY_ALL.includes(key) && !REGISTRY_ONLY.includes(key)) {
problems.push(problem('warning', rel, `Element "${id}": "${key}" is not a registry field and will not reach any diagram.`, { line: source.lineOf(`${pointer}/${key}`), pointer: `${pointer}/${key}` }));
}
}
if (element.diagram !== undefined) {
const link = linkTarget(root, element.diagram);
if (!link.ok) problems.push(problem('error', rel, `Element "${id}": diagram link "${element.diagram}" ${link.reason}.`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` }));
}
elements[id] = { ...element, id, file: rel, line };
}
}
return { elements, problems, files };
}
// Reads one diagram file and expands its ref nodes from the registry.
// `root` and `elements` are undefined outside a model.
export function resolveDiagram(file, { root, elements } = {}) {
const rel = root ? relative(root, file) : path.basename(file);
const isMain = path.basename(file) === MAIN;
const source = readSource(file);
const problems = [];
const refs = [];
const result = { file: rel, path: file, isMain, source, problems, refs, model: undefined, diagramType: undefined, hasDiagram: false, resolved: undefined, nodeIndex: {} };
for (const error of source.errors) problems.push(problem('error', rel, error.message, { line: error.line }));
if (!source.data) return result;
const data = source.data;
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
problems.push(problem('error', rel, 'A diagram file is a YAML mapping.', { line: 1 }));
return result;
}
if (data.model !== undefined) {
if (!isMain) problems.push(problem('warning', rel, `Only ${MAIN} carries a "model" block; it is ignored here.`, { line: source.lineOf('/model'), pointer: '/model' }));
else if (typeof data.model !== 'object' || data.model === null) problems.push(problem('error', rel, '"model" must be a mapping with id, name and description.', { line: source.lineOf('/model'), pointer: '/model' }));
else {
result.model = data.model;
if (!data.model.id) problems.push(problem('error', rel, 'model.id is missing.', { line: source.lineOf('/model'), pointer: '/model/id' }));
else if (!ID.test(String(data.model.id))) problems.push(problem('error', rel, `model.id "${data.model.id}" must match ${ID}.`, { line: source.lineOf('/model/id'), pointer: '/model/id' }));
if (!data.model.name) problems.push(problem('error', rel, 'model.name is missing.', { line: source.lineOf('/model'), pointer: '/model/name' }));
}
} else if (isMain) {
problems.push(problem('error', rel, `${MAIN} needs a "model" block (id, name, description).`, { line: 1, pointer: '/model' }));
}
const diagram = { ...data };
delete diagram.model;
const type = diagram.diagram_type;
if (type === undefined) {
if (!isMain) problems.push(problem('error', rel, `diagram_type is missing; one of ${TYPES.join(', ')}.`, { line: 1, pointer: '/diagram_type' }));
return result;
}
result.hasDiagram = true;
if (!TYPES.includes(type)) {
problems.push(problem('error', rel, `diagram_type "${type}" is unknown; one of ${TYPES.join(', ')}.`, { line: source.lineOf('/diagram_type'), pointer: '/diagram_type' }));
return result;
}
result.diagramType = type;
const collection = NODE_COLLECTION[type];
const copyKeys = COPY[type];
const nodes = Array.isArray(diagram[collection]) ? diagram[collection] : [];
const seen = new Map();
diagram[collection] = nodes.map((node, index) => {
const pointer = `/${collection}/${index}`;
if (typeof node !== 'object' || node === null) return node;
if (node.diagram !== undefined) {
if (root) {
const link = linkTarget(root, node.diagram);
if (!link.ok) problems.push(problem('error', rel, `diagram link "${node.diagram}" ${link.reason}.`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` }));
} else {
problems.push(problem('warning', rel, `diagram link "${node.diagram}" is ignored: links need a model (no ${MAIN} above this file).`, { line: source.lineOf(`${pointer}/diagram`), pointer: `${pointer}/diagram` }));
}
}
let merged;
if (node.ref === undefined) {
const { diagram: _link, ...plain } = node;
merged = plain;
} else {
const ref = String(node.ref);
const { ref: _ref, diagram: _link, ...own } = node;
refs.push(ref);
const element = elements?.[ref];
if (!element) {
const message = root
? `Unknown element "${ref}": no registry under ${root} defines it.`
: `"${ref}" cannot be resolved: no ${MAIN} above this file, so there is no registry. Define the element inline (id, type, label) instead.`;
problems.push(problem('error', rel, message, { line: source.lineOf(`${pointer}/ref`), pointer: `${pointer}/ref` }));
merged = { id: ref, type: 'external', label: ref, ...own };
} else {
for (const key of Object.keys(own)) {
if (copyKeys.includes(key) && !OVERRIDE.includes(key)) {
problems.push(problem('error', rel, `"${key}" of shared element "${ref}" cannot be overridden in a diagram; change it in ${element.file} or use another element.`, { line: source.lineOf(`${pointer}/${key}`), pointer: `${pointer}/${key}` }));
}
}
merged = { id: own.id ?? ref };
for (const key of copyKeys) if (element[key] !== undefined) merged[key] = element[key];
for (const [key, value] of Object.entries(own)) if (key !== 'id' && (!copyKeys.includes(key) || OVERRIDE.includes(key))) merged[key] = value;
}
}
if (merged.id !== undefined) {
if (seen.has(merged.id)) problems.push(problem('error', rel, `Duplicate id "${merged.id}" (also at line ${seen.get(merged.id)}). Use "id:" next to "ref:" to draw an element twice.`, { line: source.lineOf(pointer), pointer: `${pointer}/id` }));
else seen.set(merged.id, source.lineOf(pointer));
result.nodeIndex[merged.id] = index;
}
return merged;
});
if (type === 'architecture' && diagram.cards === undefined && refs.length) {
const drawn = [...new Set(refs)].map((ref) => elements?.[ref]).filter(Boolean);
const cards = cardsFromElements(drawn);
if (cards.length) diagram.cards = cards;
}
result.resolved = diagram;
return result;
}
// Runs the Archify validator on a resolved diagram and appends its problems.
export function validateResolved(entry, options = {}) {
if (!entry.resolved || !entry.diagramType) return undefined;
const name = path.basename(entry.path, DIAGRAM_EXT) || 'diagram';
const receipt = withTempJson(entry.resolved, `${name}.${entry.diagramType}`, (file) => validateJson(entry.diagramType, file, options));
for (const item of receiptProblems(receipt, entry.nodeIndex, NODE_COLLECTION[entry.diagramType])) {
entry.problems.push(problem(item.severity, entry.file, item.message, { line: item.pointer ? entry.source.lineOf(item.pointer) : undefined, pointer: item.pointer, code: item.code }));
}
return receipt;
}
// Loads a whole model: registry, every diagram resolved, model-level problems.
export function loadModel(root, options = {}) {
const scan = scanModel(root);
const registry = loadRegistry(root, scan.registries);
const problems = [...registry.problems];
for (const nested of scan.nestedMains) {
problems.push(problem('error', relative(root, nested), `A second ${MAIN} inside a model would start a nested model; nested models are not supported. Move it out or remove it.`, { line: 1 }));
}
const diagrams = scan.diagrams.map((file) => resolveDiagram(file, { root, elements: registry.elements }));
const main = diagrams.find((entry) => entry.isMain);
const usage = {};
for (const entry of diagrams) for (const ref of new Set(entry.refs)) usage[ref] = (usage[ref] ?? 0) + 1;
return { root, scan, registry, diagrams, main, usage, problems, options };
}
export function summarize(problems) {
const errors = problems.filter((item) => item.severity === 'error').length;
const warnings = problems.filter((item) => item.severity === 'warning').length;
return { errors, warnings };
}
export function presentProblem(item) {
const where = [item.file, item.line !== undefined ? `line ${item.line}` : undefined, item.pointer].filter(Boolean).join(' ');
return { severity: item.severity, file: item.file, line: item.line, pointer: item.pointer, code: item.code, message: item.message, where };
}