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.
301 lines
16 KiB
JavaScript
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 };
|
|
}
|