// 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 }; }