Skill Tracer
Skills › Archify › archify › diagrams.mjs

diagrams.mjs

517 lines · archify/explainer/diagrams.mjs at a406594

1import fs from 'node:fs';
2import path from 'node:path';
3import { fileURLToPath } from 'node:url';
4import { createHash } from 'node:crypto';
5import { execFile } from 'node:child_process';
6import { promisify } from 'node:util';
7import { validateSchema } from '../renderers/shared/validator.mjs';
8import { textUnits, SEMANTIC_SIGIL_FOOTPRINT } from '../renderers/shared/utils.mjs';
9import { minimumNodeTextWidth } from '../renderers/shared/text-fit.mjs';
10import { throwDiagnosticError } from '../renderers/shared/diagnostics.mjs';
11import { createOwnedTempDirectory } from '../renderers/shared/owned-temp-directory.mjs';
13const runFile = promisify(execFile);
14const skillRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15const TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle', 'erd', 'class', 'tree', 'timeline', 'waterfall']);
16const MAX_SPEC_BYTES = 4 * 1024 * 1024;
17const MAX_DIAGRAM_BYTES = 32 * 1024 * 1024;
18const ID = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
19const hash = bytes => createHash('sha256').update(bytes).digest('hex');
20const freezeBytes = bytes => ({ sha256: hash(bytes), bytes: bytes.length, contentBase64: bytes.toString('base64') });
22function subject(context, pointer, extra = {}) {
23 const map = context.sourceMap;
24 let lookup = pointer, location;
25 while (lookup && !location) {
26 location = map instanceof Map ? map.get(lookup) : map?.[lookup];
27 lookup = lookup.slice(0, lookup.lastIndexOf('/'));
28 }
29 location ||= {};
30 return { component: 'diagram', line: location.line || context.line || 1, path: pointer,
31 ...(location.column ? { column: location.column } : {}), ...(context.sourcePath ? { input: context.sourcePath } : {}),
32 ...(context.panelId ? { panelId: context.panelId } : {}), ...extra };
35function fail(context, reason, message, example, fixes = [], cause) {
36 throwDiagnosticError(message, [{ code: `explainer/${reason}`, severity: 'error', message,
37 subject: subject(context, context.pointer || '/', context.subject),
38 evidence: { reason, example: example || 'Use a native diagram spec with matching diagram_type.', ...(cause ? { native: cause } : {}) },
39 supportedFixes: fixes.length ? fixes : [`Use this form: ${example || 'native JSON'}`] }]);
42function safeId(value, context) {
43 if (!ID.test(value || '')) fail(context, 'diagram-id', 'Diagram identities must use the native identifier syntax.', 'api | API | backend');
44 return value;
47function inferredId(label) {
48 const slug = label.normalize('NFKC').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
49 return ID.test(slug) ? slug : `actor-${hash(label).slice(0, 12)}`;
52function rowParts(text) {
53 let value = text.trim();
54 if (value.startsWith('|') && value.endsWith('|')) value = value.slice(1, -1);
55 return value.split('|').map(part => part.trim());
58function duplicateCheck(items, context, kind) {
59 const seen = new Set();
60 for (const item of items) {
61 if (seen.has(item.id)) fail(context, 'diagram-id-duplicate', `Duplicate ${kind} identity ${item.id}.`, 'Use a unique id for every declaration.');
62 seen.add(item.id);
63 }
66// Estimated native workflow canvas width for a column assignment: the widest node per column plus the
67// column gaps, which widen for edge labels. Measured within about 10% of real canvases. The native gate
68// projects 8px edge labels onto a 930px reader, so 1240px is the hard limit; this keeps a margin.
69const READABLE_WORKFLOW_WIDTH = 1100;
70function workflowWidth(nodes, edges) {
71 const columns = Math.max(...nodes.map(node => node.col)) + 1;
72 const widest = Array.from({ length: columns }, (_, col) => Math.max(92, ...nodes.filter(node => node.col === col).map(node => node.width || 0)));
73 const colOf = new Map(nodes.map(node => [node.id, node.col]));
74 const gaps = Array.from({ length: columns - 1 }, (_, col) => Math.max(34, ...edges.filter(edge => colOf.get(edge.from) === col && colOf.get(edge.to) === col + 1).map(edge => (edge.label ? textUnits(edge.label) * 5.4 : 0) + 16)));
75 return widest.reduce((sum, width) => sum + width, 0) + gaps.reduce((sum, gap) => sum + gap, 0) + 80;
78// Placement supplies native grid/lane hints only. Native renderers own sizing,
79// ports, routes, labels, and all quality validation.
80function rankedPlacement(nodes, edges) {
81 const ids = new Set(nodes.map(node => node.id));
82 const incoming = new Map(nodes.map(node => [node.id, 0]));
83 for (const edge of edges) if (ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to) incoming.set(edge.to, incoming.get(edge.to) + 1);
84 const rank = new Map();
85 const queue = nodes.filter(node => incoming.get(node.id) === 0).map(node => node.id);
86 const visit = id => {
87 if (!rank.has(id)) rank.set(id, 0);
88 for (const edge of edges.filter(edge => edge.from === id && edge.to !== id)) {
89 if (!ids.has(edge.to)) continue;
90 incoming.set(edge.to, incoming.get(edge.to) - 1);
91 rank.set(edge.to, Math.max(rank.get(edge.to) || 0, rank.get(id) + 1));
92 if (incoming.get(edge.to) === 0) queue.push(edge.to);
93 }
94 };
95 for (let cursor = 0; cursor < queue.length; cursor++) visit(queue[cursor]);
96 // A cyclic remainder stays a deterministic separate row; no authored edge is
97 // dropped or converted into an invented forward relation.
98 const nextRow = new Map();
99 return nodes.map(node => {
100 const col = rank.has(node.id) && incoming.get(node.id) === 0 ? rank.get(node.id) : 0;
101 const row = nextRow.get(col) || 0;
102 nextRow.set(col, row + 1);
103 return { ...node, row, col };
104 });
107/** Compile a small author shorthand into an existing native Archify model. */
108export function compileExplainerDiagram(diagramType, rows, { title, id, line = 1, quality = 'showcase' } = {}) {
109 const context = { line, pointer: '/', id, diagramType, rowMap: {}, subject: { diagram: id, diagramType } };
110 if (!TYPES.has(diagramType)) fail(context, 'diagram-type', 'Unknown native diagram type.', 'sequence');
111 if (!Array.isArray(rows) || !rows.length) fail(context, 'diagram-rows', 'A shorthand diagram needs at least one row.', 'Client | API | 请求记录');
112 const entries = rows.map((row, index) => {
113 const ctx = { ...context, line: Number.isInteger(row?.line) ? row.line : line + index };
114 const cells = Array.isArray(row) ? row : row?.cells;
115 if (cells !== undefined) {
116 if (!Array.isArray(cells) || !cells.length || cells.some(cell => typeof cell !== 'string')) fail(ctx, 'diagram-row', 'Each diagram row needs text cells.', 'Client | API | 请求记录');
117 return { parts: cells.map(cell => cell.trim()), context: ctx };
118 }
119 const text = typeof row === 'string' ? row : row?.text;
120 if (typeof text !== 'string' || !text.trim()) fail(ctx, 'diagram-row', 'Each diagram row needs text.', 'Client | API | 请求记录');
121 return { parts: rowParts(text), context: ctx };
122 });
123 const spec = { schema_version: diagramType === 'workflow' ? 2 : 1, diagram_type: diagramType,
124 meta: { title: title || id || diagramType, output: `${id && ID.test(id) ? id : 'diagram'}.html`, quality_profile: quality } };
125 const expect = (entry, min, max, example) => {
126 if (entry.parts.length < min || entry.parts.length > max || entry.parts.slice(0, min).some(part => !part)) {
127 fail(entry.context, 'diagram-row', `Expected ${min === max ? min : `${min}–${max}`} pipe-separated fields.`, example);
128 }
129 };
130 const edgeCounts = new Map();
131 const edgeId = (from, to) => {
132 const base = `${from}-to-${to}`;
133 const count = (edgeCounts.get(base) || 0) + 1;
134 edgeCounts.set(base, count);
135 return `${base}-${count}`;
136 };
137 if (diagramType === 'sequence') {
138 spec.participants = [];
139 spec.messages = [];
140 const aliases = new Map();
141 const byId = new Map();
142 for (const entry of entries.filter(entry => entry.parts[0].startsWith('actor '))) {
143 expect(entry, 2, 3, 'actor api | API | backend');
144 const actor = { id: safeId(entry.parts[0].slice(6).trim(), entry.context), label: entry.parts[1], type: entry.parts[2] || 'backend' };
145 context.rowMap[`/participants/${spec.participants.length}`] = entry.context.line;
146 spec.participants.push(actor); byId.set(actor.id, actor); aliases.set(actor.label, actor.id); aliases.set(actor.id, actor.id);
147 }
148 const actorId = (label, ctx) => {
149 if (aliases.has(label)) return aliases.get(label);
150 const actor = { id: inferredId(label), label, type: 'external' };
151 if (byId.has(actor.id)) fail(ctx, 'diagram-actor-collision', 'Actor labels resolve to the same id; declare explicit actor identities.', 'actor client | Client | frontend');
152 spec.participants.push(actor); byId.set(actor.id, actor); aliases.set(label, actor.id);
153 return actor.id;
154 };
155 // "phase | label" rows open a labelled band over the messages that follow, up to the next phase.
156 const phases = [];
157 for (const entry of entries.filter(entry => !entry.parts[0].startsWith('actor '))) {
158 if (entry.parts[0] === 'phase') { expect(entry, 2, 2, 'phase | TCP handshake'); phases.push({ label: entry.parts[1], first: spec.messages.length, context: entry.context }); continue; }
159 expect(entry, 3, 4, 'Client | API | 请求记录 | return');
160 const [fromLabel, toLabel, label, variant] = entry.parts;
161 const from = actorId(fromLabel, entry.context), to = actorId(toLabel, entry.context);
162 context.rowMap[`/messages/${spec.messages.length}`] = entry.context.line;
163 spec.messages.push({ id: edgeId(from, to), from, to, label, y: 160 + spec.messages.length * 48, ...(variant ? { variant } : {}) });
164 }
165 phases.forEach((phase, index) => {
166 const last = (phases[index + 1]?.first ?? spec.messages.length) - 1;
167 if (last < phase.first) fail(phase.context, 'diagram-row', 'A phase needs at least one message after it.', 'phase | TCP handshake\n| browser | server | SYN |');
168 (spec.segments ||= []).push({ from: spec.messages[phase.first].y - 22, to: spec.messages[last].y + 16, label: phase.label });
169 });
170 duplicateCheck(spec.participants, context, 'actor');
171 if (!spec.messages.length) fail(context, 'diagram-rows', 'Sequence shorthand needs a message.', 'client | api | 请求记录');
172 } else if (diagramType === 'architecture' || diagramType === 'workflow') {
173 const nodes = [], edges = [], lanes = new Map(), nodeLanes = new Map();
174 for (const entry of entries) {
175 const route = entry.parts[0].match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*>\s*([a-zA-Z][a-zA-Z0-9_-]*)$/);
176 if (diagramType === 'workflow' && entry.parts[0].startsWith('lane ')) {
177 expect(entry, 2, 2, 'lane locate | Locate the cause');
178 const laneId = safeId(entry.parts[0].slice(5).trim(), entry.context);
179 if (lanes.has(laneId)) fail(entry.context, 'diagram-id-duplicate', 'Workflow lane ids must be unique.', 'lane locate | Locate the cause');
180 lanes.set(laneId, { id: laneId, label: entry.parts[1] });
181 } else if (route) {
182 if (entry.parts.length > 3) fail(entry.context, 'diagram-row', 'An edge supports a label and optional native variant.', 'api > redis | 查询 | dashed');
183 context.rowMap[`/${diagramType === 'architecture' ? 'connections' : 'edges'}/${edges.length}`] = entry.context.line;
184 edges.push({ id: edgeId(route[1], route[2]), from: route[1], to: route[2], ...(entry.parts[1] ? { label: entry.parts[1] } : {}), ...(entry.parts[2] ? { variant: entry.parts[2] } : {}) });
185 } else {
186 if (diagramType === 'workflow') expect(entry, 2, 4, 'trace | Compare slow traces | backend | locate');
187 else expect(entry, 2, 3, 'api | API | backend');
188 context.rowMap[`/${diagramType === 'architecture' ? 'components' : 'nodes'}/${nodes.length}`] = entry.context.line;
189 const node = { id: safeId(entry.parts[0], entry.context), label: entry.parts[1], type: entry.parts[2] || 'backend' };
190 // Wide enough that the label stays centred between the corner icon and the opposite edge;
191 // a narrower node drops the label below the icon, out of line with its neighbours.
192 const labelWidth = Math.ceil(Math.max(textUnits(node.label) * 6.8 + 12, minimumNodeTextWidth(node.label, 11) + 2 * (SEMANTIC_SIGIL_FOOTPRINT + 2)));
193 if (diagramType === 'workflow' && labelWidth > 92) node.width = labelWidth;
194 // A question is a decision point: the native decision mark tells it apart from a step.
195 if (diagramType === 'workflow' && /[??]$/u.test(node.label)) node.icon = 'decision';
196 if (entry.parts[3]) nodeLanes.set(node.id, { lane: safeId(entry.parts[3], entry.context), context: entry.context });
197 nodes.push(node);
198 }
199 }
200 duplicateCheck(nodes, context, 'node');
201 const placed = rankedPlacement(nodes, edges);
202 if (diagramType === 'architecture') {
203 const cols = Math.max(1, ...placed.map(node => node.col + 1));
204 if (cols > 12) fail(context, 'diagram-shorthand-limit', 'This architecture needs more than twelve native grid columns.', 'Use native JSON with explicit component positions.');
205 const fitWidth = node => Math.ceil(textUnits(node.label) * 6.6 + 16);
206 // Order each column by where its upstream nodes sit, then centre it against the tallest column,
207 // so a hub sits between the nodes it connects instead of on the first row.
208 const height = Math.max(...Array.from({ length: cols }, (_, col) => placed.filter(node => node.col === col).length));
209 const rowOf = new Map();
210 for (let col = 0; col < cols; col++) {
211 const column = placed.filter(node => node.col === col);
212 const upstream = node => { const rows = edges.filter(edge => edge.to === node.id && rowOf.has(edge.from)).map(edge => rowOf.get(edge.from)); return rows.length ? rows.reduce((sum, row) => sum + row, 0) / rows.length : node.row; };
213 column.sort((a, b) => upstream(a) - upstream(b) || a.row - b.row)
214 .forEach((node, i) => rowOf.set(node.id, Math.floor((height - column.length) / 2) + i));
215 }
216 spec.layout = { mode: 'grid', cols, cellW: Math.max(176, ...placed.map(fitWidth)), cellH: 76, gapX: 100, gapY: 110 };
217 spec.components = placed.map(node => ({ ...node, row: rowOf.get(node.id), ...(fitWidth(node) > 120 ? { size: [fitWidth(node), 60] } : {}) }));
218 spec.connections = edges;
219 } else if (lanes.size) {
220 for (const [id, { lane, context: ctx }] of nodeLanes) if (!lanes.has(lane)) fail(ctx, 'diagram-lane', `Node ${id} names an undeclared lane.`, 'lane locate | Locate the cause');
221 const first = lanes.keys().next().value;
222 const order = [...lanes.keys()];
223 const ranked = [...placed].sort((a, b) => a.col - b.col || a.row - b.row).map(({ row, col, ...node }) => ({ ...node, lane: nodeLanes.get(node.id)?.lane || first }));
224 // Prefer, in order: one row with phase labels above it; phase rows that step down so each hand-off
225 // edge drops straight; phase rows that each restart at the left. Take the first that stays within
226 // the readable width, since a wider canvas shrinks its edge labels below the native text floor.
227 const row = [...ranked].sort((a, b) => order.indexOf(a.lane) - order.indexOf(b.lane));
228 const used = [...lanes.values()].filter(lane => ranked.some(node => node.lane === lane.id));
229 const sizes = used.map(lane => ranked.filter(node => node.lane === lane.id).length);
230 const wrapped = stair => {
231 const starts = sizes.map((_, i) => stair ? sizes.slice(0, i).reduce((sum, size) => sum + size - 1, 0) : 0);
232 const nextCol = new Map(used.map((lane, i) => [lane.id, starts[i]]));
233 return ranked.map(node => { const col = nextCol.get(node.lane); nextCol.set(node.lane, col + 1); return { ...node, col }; });
234 };
235 const single = row.map(({ lane, ...node }, col) => ({ ...node, lane: 'flow', col }));
236 // Grouping by phase keeps the flow order only when each phase is one stretch of the flow.
237 const inOrder = ranked.every((node, i) => i === 0 || order.indexOf(node.lane) >= order.indexOf(ranked[i - 1].lane));
238 const candidates = [
239 ...(inOrder && ranked.length <= 6 && placed.every(node => node.row === 0) ? [{ nodes: single, single: true }] : []),
240 ...(inOrder && sizes.reduce((sum, size) => sum + size, 0) - (sizes.length - 1) <= 6 ? [{ nodes: wrapped(true) }] : []),
241 { nodes: wrapped(false) },
242 ];
243 const chosen = candidates.find(candidate => workflowWidth(candidate.nodes, edges) <= READABLE_WORKFLOW_WIDTH) || candidates.at(-1);
244 spec.nodes = chosen.nodes;
245 if (chosen.single) {
246 spec.lanes = [{ id: 'flow', label: spec.meta.title }];
247 spec.phases = order.map(id => ({ id, label: lanes.get(id).label, cols: row.flatMap((node, col) => node.lane === id ? [col] : []) })).filter(phase => phase.cols.length).map(({ cols, ...phase }) => ({ ...phase, fromCol: cols[0], toCol: cols.at(-1) }));
248 } else spec.lanes = used;
249 if (spec.nodes.some(node => node.col > 5)) fail(context, 'diagram-shorthand-limit', 'A workflow lane holds at most six nodes.', 'Split the process into another lane.');
250 spec.edges = edges;
251 } else {
252 // A process too wide for one readable row wraps by columns into stacked bands, keeping each
253 // band's branch rows, and steps each band down under the end of the previous one where six
254 // columns allow, the same way declared phases do.
255 const fits = laid => laid.every(node => node.col <= 5) && workflowWidth(laid, edges) <= READABLE_WORKFLOW_WIDTH;
256 let wrapped;
257 if (!fits(placed)) {
258 const branchRows = Math.max(...placed.map(node => node.row)) + 1;
259 for (let size = 5; size >= 2 && !wrapped; size--) {
260 for (const stair of [true, false]) {
261 const laid = placed.map(node => { const band = Math.floor(node.col / size); return { ...node, row: band * branchRows + node.row, col: (stair ? band * (size - 1) : 0) + node.col - band * size }; });
262 const used = [...new Set(laid.map(node => node.row))].sort((a, b) => a - b);
263 const compact = laid.map(node => ({ ...node, row: used.indexOf(node.row) }));
264 if (fits(compact)) { wrapped = compact; break; }
265 }
266 }
267 }
268 const rows = wrapped || placed;
269 if (rows.some(node => node.col > 5)) fail(context, 'diagram-shorthand-limit', 'Native workflow columns are limited to 0–5.', 'Declare phase lanes with "lane <id> | label" rows and assign nodes to them.');
270 spec.lanes = Array.from({ length: Math.max(1, ...rows.map(node => node.row + 1)) }, (_, row) => ({ id: `lane-${row}`, label: wrapped ? `${spec.meta.title} · ${row + 1}` : row ? `分支 ${row}` : spec.meta.title }));
271 spec.nodes = rows.map(({ row, ...node }) => ({ ...node, lane: `lane-${row}` }));
272 spec.edges = edges;
273 }
274 } else if (diagramType === 'tree') {
275 spec.nodes = entries.map((entry, index) => {
276 expect(entry, 2, 3, 'api | API 服务 | root');
277 context.rowMap[`/nodes/${index}`] = entry.context.line;
278 const [nodeId, label, parent] = entry.parts;
279 return { id: safeId(nodeId, entry.context), label, ...(parent && parent !== '-' ? { parent: safeId(parent, entry.context) } : {}) };
280 });
281 duplicateCheck(spec.nodes, context, 'node');
282 } else if (diagramType === 'timeline') {
283 spec.meta.evidence = 'illustrative';
284 const lanes = new Map(); spec.events = [];
285 for (const entry of entries) {
286 if (entry.parts[0].startsWith('lane ')) {
287 expect(entry, 2, 2, 'lane release | 发布');
288 const laneId = safeId(entry.parts[0].slice(5).trim(), entry.context);
289 if (lanes.has(laneId)) fail(entry.context, 'diagram-id-duplicate', 'Timeline lane ids must be unique.', 'lane release | 发布');
290 lanes.set(laneId, { id: laneId, label: entry.parts[1] });
291 }
292 }
293 for (const entry of entries.filter(entry => !entry.parts[0].startsWith('lane '))) {
294 expect(entry, 3, 5, 'deploy | 2026-10-01T10:00:00+08:00 | 发布 v2 | release | change');
295 const [eventId, at, eventTitle, authoredLane, kind] = entry.parts;
296 const lane = authoredLane || 'events'; safeId(lane, entry.context);
297 if (!lanes.has(lane)) lanes.set(lane, { id: lane, label: lane });
298 context.rowMap[`/events/${spec.events.length}`] = entry.context.line;
299 spec.events.push({ id: safeId(eventId, entry.context), at, title: eventTitle, lane, ...(kind ? { kind } : {}) });
300 }
301 spec.lanes = [...lanes.values()]; duplicateCheck(spec.events, context, 'event');
302 } else if (diagramType === 'erd') {
303 const entities = new Map(); spec.relationships = [];
304 for (const entry of entries.filter(entry => entry.parts[0].startsWith('entity '))) {
305 expect(entry, 2, 2, 'entity user | 用户');
306 const entityId = safeId(entry.parts[0].slice(7).trim(), entry.context);
307 if (entities.has(entityId)) fail(entry.context, 'diagram-id-duplicate', 'Entity ids must be unique.', 'entity user | 用户');
308 entities.set(entityId, { id: entityId, label: entry.parts[1], row: 0, col: entities.size, attributes: [] });
309 }
310 for (const entry of entries.filter(entry => !entry.parts[0].startsWith('entity '))) {
311 const route = entry.parts[0].match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*>\s*([a-zA-Z][a-zA-Z0-9_-]*)$/);
312 if (route) {
313 expect(entry, 2, 3, 'order > user | many:one | 下单者');
314 const cardinality = entry.parts[1].match(/^(one|many):(one|many)$/);
315 if (!cardinality) fail(entry.context, 'diagram-cardinality', 'Relationship cardinality must be explicit.', 'order > user | many:one | 下单者');
316 spec.relationships.push({ id: edgeId(route[1], route[2]), from: route[1], to: route[2], fromCardinality: cardinality[1], toCardinality: cardinality[2], ...(entry.parts[2] ? { label: entry.parts[2] } : {}) });
317 } else {
318 expect(entry, 2, 4, 'order.user_id | bigint | fk | user.id');
319 const field = entry.parts[0].match(/^([a-zA-Z][a-zA-Z0-9_-]*)\.([a-zA-Z][a-zA-Z0-9_]*)$/);
320 if (!field || !entities.has(field[1])) fail(entry.context, 'diagram-field', 'A field must name an explicitly declared entity.', 'entity user | 用户\nuser.id | bigint | pk');
321 const roles = entry.parts[2]?.split(',').map(value => value.trim()).filter(Boolean);
322 entities.get(field[1]).attributes.push({ name: field[2], type: entry.parts[1], ...(roles?.length ? { key: roles.length === 1 ? roles[0] : roles } : {}), ...(entry.parts[3] ? { references: entry.parts[3] } : {}) });
323 }
324 }
325 spec.layout = { mode: 'grid', gapX: 96, gapY: 64 }; spec.entities = [...entities.values()];
326 } else {
327 fail(context, 'diagram-shorthand-unsupported', `The ${diagramType} type uses native JSON instead of shorthand rows.`, `Use spec with diagram_type: ${diagramType}, or source: diagram.${diagramType}.json.`);
328 }
329 try { validateSchema(diagramType, spec); } catch (error) { rethrowNative(error, context, 'diagram-schema'); }
330 return spec;
333function rethrowNative(error, context, reason) {
334 let details = error.archifyDiagnostics || error.diagnostics || [];
335 if (!details.length && Array.isArray(error.checks)) {
336 details = error.checks.filter(check => !check.ok).flatMap(check => (check.details?.length ? check.details : [`Native check ${check.name} failed.`])
337 .map(message => ({ code: `check/${check.name}`, severity: 'error', message, evidence: check })));
338 if (!details.length) details = (error.composition?.issues || []).filter(issue => issue.severity === 'error')
339 .map(issue => ({ ...issue, message: issue.code === 'composition/desktop-readability' && issue.projectedFontPx
340 ? `Text "${issue.text}" on ${issue.owner?.kind || 'element'} ${issue.owner?.id || ''} would show at ${issue.projectedFontPx.toFixed(1)}px, below ${issue.minimumProjectedFontPx}px: the diagram is ${Math.round(issue.viewBoxWidth)}px wide and shrinks to ${Math.round(issue.scale * 100)}% on the page. Make it narrower: at most four or five nodes per row, shorter node labels, or phases.`
341 : issue.detail || `Native quality check ${issue.code} failed.` }));
342 }
343 if (details.length) throwDiagnosticError('Native diagram validation failed.', details.map(entry => ({
344 code: `explainer/${reason}`, severity: entry.severity || 'error', message: entry.message,
345 subject: subject({ ...context, line: context.rowMap?.[entry.subject?.path?.match(/^\/[^/]+\/\d+/)?.[0]] || context.line },
346 (context.pointer && entry.subject?.path && !context.rowMap) ? `${context.pointer}/spec${entry.subject.path}` : context.pointer || '/',
347 { diagramType: context.diagramType, diagram: context.id }),
348 evidence: { reason, example: `Use a valid native ${context.diagramType || 'diagram'} JSON specification.`, native: entry },
349 supportedFixes: entry.supportedFixes?.length ? entry.supportedFixes : ['Repair the native diagnostic and render again.'],
350 })));
351 fail(context, reason, 'The native diagram toolchain could not complete this diagram.', `Use a valid native ${context.diagramType || 'diagram'} JSON specification.`, [], { message: String(error.message || error).slice(0, 1000) });
354function readSpec(baseDir, relative, context) {
355 if (!baseDir || typeof relative !== 'string' || !relative || path.isAbsolute(relative) || /[\\:\u0000-\u001f?#]/.test(relative)
356 || relative.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git') || !/\.json$/i.test(relative)) {
357 fail(context, 'diagram-source-path', 'Diagram source must be a relative JSON file inside the input directory.', 'diagrams/request.sequence.json');
358 }
359 let fd;
360 try {
361 const root = fs.realpathSync(baseDir), ancestors = [[root, fs.lstatSync(root)]];
362 let candidate = root;
363 for (const part of relative.split('/')) {
364 candidate = path.join(candidate, part); const stat = fs.lstatSync(candidate);
365 if (stat.isSymbolicLink()) fail(context, 'diagram-source-symlink', 'Diagram sources may not traverse symbolic links.', 'request.sequence.json');
366 ancestors.push([candidate, stat]);
367 }
368 const expected = ancestors.at(-1)[1];
369 if (!expected.isFile() || expected.size > MAX_SPEC_BYTES) fail(context, 'diagram-source-size', 'Diagram source must be a regular JSON file smaller than 4 MiB.', 'request.sequence.json');
370 fd = fs.openSync(candidate, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
371 const before = fs.fstatSync(fd);
372 const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
373 if (!same(before, expected)) fail(context, 'diagram-source-changed', 'Diagram source changed while being frozen.', 'Finish edits before rendering.');
374 const bytes = Buffer.alloc(before.size);
375 let offset = 0;
376 while (offset < bytes.length) {
377 const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
378 if (!count) break;
379 offset += count;
380 }
381 if (offset !== bytes.length || !same(before, fs.fstatSync(fd)) || !ancestors.every(([name, stat]) => { const current = fs.lstatSync(name); return !current.isSymbolicLink() && same(current, stat); })) {
382 fail(context, 'diagram-source-changed', 'Diagram source changed while being frozen.', 'Finish edits before rendering.');
383 }
384 return bytes;
385 } catch (error) {
386 if (error.archifyDiagnostics) throw error;
387 fail(context, 'diagram-source-read', 'Diagram source cannot be read safely inside the input directory.', 'request.sequence.json');
388 } finally { if (fd !== undefined) fs.closeSync(fd); }
391function decode(value) {
392 return value.replace(/&(?:quot|apos|lt|gt|amp|#\d+|#x[\da-f]+);/gi, entity => {
393 const named = { '&quot;': '"', '&apos;': "'", '&lt;': '<', '&gt;': '>', '&amp;': '&' };
394 if (Object.hasOwn(named, entity)) return named[entity];
395 const code = entity.startsWith('&#x') ? parseInt(entity.slice(3, -1), 16) : parseInt(entity.slice(2, -1), 10);
396 return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
397 });
400function attrs(tag) {
401 return Object.fromEntries([...tag.matchAll(/([\w:-]+)="([^"]*)"/g)].map(match => [match[1], decode(match[2])]));
404function semanticOf(svg) {
405 const nodes = new Map(), edges = new Map();
406 for (const match of svg.matchAll(/<[a-zA-Z][^>]*>/g)) {
407 const values = attrs(match[0]);
408 if (values['data-node-id']) nodes.set(values['data-node-id'], { id: values['data-node-id'], label: values['data-node-label'] || values['data-node-id'], svgId: values.id });
409 if (values['data-edge-from'] && values['data-edge-to']) {
410 const id = values['data-edge-id'] || values['data-edge-key'] || `${values['data-edge-from']}~${values['data-edge-to']}`;
411 edges.set(id, { id, from: values['data-edge-from'], to: values['data-edge-to'], label: values['data-edge-label'] || '',
412 ...(values['data-edge-key'] ? { key: values['data-edge-key'] } : {}) });
413 }
414 }
415 return { nodes: [...nodes.values()], edges: [...edges.values()] };
418function changesPreview(svg, changes, semantic, context) {
419 if (changes !== undefined && !Array.isArray(changes)) fail(context, 'diagram-change', 'Structural changes must be an array of explicit target marks.', '[{target:"api",change:"modified"}]');
420 const nodes = new Set(semantic.nodes.map(node => node.id)), edges = new Set(semantic.edges.map(edge => edge.id));
421 const byTarget = new Map();
422 for (const change of changes || []) {
423 if (!change || !['added', 'removed', 'modified'].includes(change.change) || typeof change.target !== 'string') fail(context, 'diagram-change', 'A structural change needs an explicit target and change type.', '{target:"api",change:"modified"}');
424 if (!nodes.has(change.target) && !edges.has(change.target)) fail(context, 'diagram-change-target', 'The change target is absent from the authored native topology.', '{target:"api",change:"modified"}');
425 if (byTarget.has(change.target)) fail(context, 'diagram-change-duplicate', 'A target may have only one structural change mark.', '{target:"api",change:"modified"}');
426 byTarget.set(change.target, change.change);
427 }
428 return svg.replace(/<[a-zA-Z][^>]*>/g, tag => {
429 const values = attrs(tag), change = byTarget.get(values['data-node-id']) || byTarget.get(values['data-edge-id']);
430 return change ? tag.replace(/(\/?>)$/, ` data-explainer-change="${change}"$1`) : tag;
431 });
434async function nativeProcess(script, args, options, context, reason) {
435 try {
436 const result = await runFile(process.execPath, [script, ...args], { cwd: options.cwd || skillRoot, maxBuffer: MAX_DIAGRAM_BYTES,
437 timeout: 120000, env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json', ARCHIFY_QUALITY_PROFILE: options.quality,
438 ...(options.repoRoot ? { ARCHIFY_REPO_ROOT: path.resolve(options.repoRoot) } : {}) } });
439 return result;
440 } catch (error) {
441 let native;
442 for (const output of [error.stdout, error.stderr]) { try { native = JSON.parse(output); if (native.diagnostics) break; } catch {} }
443 rethrowNative(native || error, context, reason);
444 }
447/** Freeze native diagrams, validate, render, and check without a per-child browser gate. */
448export async function prepareExplainerDiagrams(document, { repoRoot, baseDir, quality = 'showcase', sourceMap, sourcePath } = {}) {
449 const diagrams = Object.create(null), dependencies = [], blocks = [];
450 for (const [panelIndex, panel] of (document.panels || []).entries()) {
451 for (const [blockIndex, block] of (panel.blocks || []).entries()) if (block.type === 'diagram') blocks.push({ block, pointer: `/panels/${panelIndex}/blocks/${blockIndex}`, panelId: panel.id });
452 }
453 // Legacy flat draft shapes are accepted only as adapter inputs; canonical
454 // explainer validation remains the parent pipeline's responsibility.
455 for (const [blockIndex, block] of (document.blocks || []).entries()) if (block.type === 'diagram') blocks.push({ block, pointer: `/blocks/${blockIndex}` });
456 for (const entry of blocks) {
457 const { block, pointer, panelId } = entry, context = { pointer, panelId, sourceMap, sourcePath, id: block.id, diagramType: block.diagramType };
458 if (typeof block.id !== 'string' || !block.id || Object.hasOwn(diagrams, block.id)) fail(context, 'diagram-id', 'Diagram blocks need unique stable identities.', '{id:"request",type:"diagram",diagramType:"sequence",spec:{...}}');
459 if (!TYPES.has(block.diagramType)) fail(context, 'diagram-type', 'Unknown native diagram type.', 'sequence');
460 if ((block.spec !== undefined) === (block.source !== undefined)) fail(context, 'diagram-input', 'Provide exactly one inline spec or relative source.', 'source: request.sequence.json');
461 let originalBytes;
462 if (block.source !== undefined) originalBytes = readSpec(baseDir, block.source, { ...context, pointer: `${pointer}/source` });
463 else {
464 try { originalBytes = Buffer.from(JSON.stringify(block.spec)); } catch { fail(context, 'diagram-spec', 'Inline native JSON must be serializable.', 'spec: {schema_version:1,diagram_type:"tree",meta:{title:"Tree"},nodes:[...]}'); }
465 if (originalBytes.length > MAX_SPEC_BYTES) fail(context, 'diagram-source-size', 'Inline diagram JSON exceeds 4 MiB.', 'Use a smaller native JSON specification.');
466 }
467 let original;
468 try { original = JSON.parse(originalBytes.toString('utf8')); } catch { fail(context, 'diagram-json', 'Diagram source is not valid JSON.', '{"schema_version":1,"diagram_type":"sequence","meta":{"title":"Request"},"participants":[],"messages":[]}'); }
469 if (!original || typeof original !== 'object' || Array.isArray(original) || original.diagram_type !== block.diagramType) fail(context, 'diagram-type-mismatch', 'Native specification diagram_type must match its explainer block.', `diagram_type: ${block.diagramType}`);
470 dependencies.push({ kind: 'diagram-spec', id: block.id, ...(block.source ? { path: path.resolve(baseDir, block.source), relativePath: block.source } : { inline: true }), ...freezeBytes(originalBytes) });
471 const temporary = createOwnedTempDirectory('archify-explainer-diagram-');
472 try {
473 const output = path.join(temporary.path, 'diagram.html'), input = path.join(temporary.path, 'diagram.json');
474 // Inline diagrams belong to the explainer's reading context. Keep the
475 // original author bytes frozen; apply inheritance only to native render
476 // input. Referenced standalone specs retain their own locale defaults.
477 const inheritedLocale = block.spec !== undefined && original.meta?.locale === undefined && document.meta?.locale !== undefined
478 ? { locale: document.meta.locale } : {};
479 const spec = { ...original, meta: { ...original.meta, ...inheritedLocale, output: 'diagram.html', quality_profile: quality } };
480 try { validateSchema(block.diagramType, spec); } catch (error) { rethrowNative(error, context, 'diagram-schema'); }
481 fs.writeFileSync(input, JSON.stringify(spec), { flag: 'wx' });
482 await nativeProcess(path.join(skillRoot, 'renderers', block.diagramType, `render-${block.diagramType}.mjs`), [input, output], { repoRoot, quality, cwd: temporary.path }, context, 'diagram-render');
483 const checked = await nativeProcess(path.join(skillRoot, 'scripts/check-render-output.mjs'), [output], { repoRoot, quality, cwd: temporary.path }, context, 'diagram-check');
484 let check;
485 try { check = JSON.parse(checked.stdout); } catch { fail(context, 'diagram-check-receipt', 'Native checker returned no structured receipt.', 'Rerun the native diagram check.'); }
486 if (check.ok === false) rethrowNative(check, context, 'diagram-check');
487 const bytes = fs.readFileSync(output);
488 if (bytes.length > MAX_DIAGRAM_BYTES) fail(context, 'diagram-artifact-size', 'The native diagram artifact exceeds 32 MiB.', 'Use a smaller native diagram.');
489 const html = bytes.toString('utf8'), svgMatch = html.match(/<svg\b[^>]*\bdata-quality-profile="[^"]*"[^>]*>[\s\S]*?<\/svg>/);
490 if (!svgMatch) fail(context, 'diagram-svg', 'The native renderer produced no primary semantic SVG.', 'Use a supported native diagram renderer.');
491 const canonicalSvg = svgMatch[0], semantic = semanticOf(canonicalSvg);
492 const svg = changesPreview(canonicalSvg, block.changes, semantic, context);
493 // Preview styles are derived from html at render time; storing them again would double the page.
494 diagrams[block.id] = { diagramType: block.diagramType, spec: original, html, svg, sha256: hash(bytes), bytes: bytes.length,
495 specification: freezeBytes(originalBytes), checks: { validate: { ok: true }, render: { ok: true }, check }, semantic,
496 ...(block.changes ? { changes: block.changes.map(change => ({ ...change })) } : {}) };
497 } finally { await temporary.cleanup(); }
498 }
499 return { diagrams, dependencies };
502/** Resolve a reference against the actual native SVG graph, including real routes. */
503export function validateExplainerDiagramTarget(diagrams, target, { sourceMap, sourcePath, pointer = '/', line = 1 } = {}) {
504 const context = { sourceMap, sourcePath, pointer, line };
505 const diagram = diagrams?.[target?.diagram];
506 if (!diagram) fail(context, 'diagram-reference', 'The reference names an unknown diagram block.', '{diagram:"request",node:"api"}');
507 const nodes = new Set(diagram.semantic.nodes.map(node => node.id)), edges = diagram.semantic.edges;
508 if (target.node !== undefined && !nodes.has(target.node)) fail(context, 'diagram-node-reference', 'The reference names a node absent from the native diagram.', `{diagram:"${target.diagram}",node:"${diagram.semantic.nodes[0]?.id || 'api'}"}`);
509 if (target.edges !== undefined && (!Array.isArray(target.edges) || !target.edges.length || target.edges.some(id => !edges.some(edge => edge.id === id)))) fail(context, 'diagram-edge-reference', 'The reference names an edge absent from the native diagram.', `{diagram:"${target.diagram}",edges:["${edges[0]?.id || 'api-to-db-1'}"]}`);
510 if (target.route !== undefined) {
511 if (!Array.isArray(target.route) || target.route.length !== 2 || !target.route.every(id => nodes.has(id))) fail(context, 'diagram-route-reference', 'A route needs two real native node identities.', `{diagram:"${target.diagram}",route:["api","db"]}`);
512 const [from, to] = target.route, visited = new Set([from]), queue = [from];
513 for (let index = 0; index < queue.length; index++) for (const edge of edges.filter(edge => edge.from === queue[index])) if (!visited.has(edge.to)) { visited.add(edge.to); queue.push(edge.to); }
514 if (!visited.has(to)) fail(context, 'diagram-route-reference', 'No directed route connects these nodes in the native diagram.', `{diagram:"${target.diagram}",route:["${edges[0]?.from || 'api'}","${edges[0]?.to || 'db'}"]}`);
515 }
516 return true;