Skill Tracer
Skills › Archify › archify › cli.mjs

cli.mjs

430 lines · archify/renderers/shared/cli.mjs at a406594

1import { softwareNoticesHtml } from './software-notices.mjs';
2import { createHash } from 'node:crypto';
3import fs from 'node:fs';
4import path from 'node:path';
5import { applyTemplate, renderCards, esc } from './utils.mjs';
6import { validateSchema } from './validator.mjs';
7import { verifyRepositoryEvidence } from './repository-evidence.mjs';
8import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems, recordDiagnostic } from './diagnostics.mjs';
9import { validateEngineeringProfile } from './engineering-profiles.mjs';
10import {
11 resolveOutputPath,
12 validateAuthoredOutputPath,
13} from './output-path.mjs';
14import {
15 captureAtomicOutput,
16 captureRegularFileBinding,
17 publishRegularFileBinding,
18 releaseRegularFileBinding,
19 removeOwnedRegularFile,
20 verifyAtomicOutput,
21} from './atomic-output.mjs';
22import { resolveLocale, translateMessage, registerLocale, localeDiagnostics } from './i18n.mjs';
23import { prepareDiagramBrandMarks } from './brand-marks.mjs';
25const outputPathGuards = new Map();
26let renderCandidateSequence = 0;
28// meta.locale is renderer-owned Viewer UI, not authored content. Each message
29// resolves as: valid meta.translations value → bundled catalog enrolled in
30// locales/manifest.json → English (see i18n.mjs). Every fallback is disclosed
31// by human renderers on stderr. Diagnostic JSON mode records warnings without
32// mixing text into failure receipts; validate/deliver disclose them publicly.
33function applyLocaleTranslations(diagramType, diagram) {
34 const locale = diagram.meta?.locale;
35 if (!locale) return;
36 registerLocale(locale, diagram.meta.translations);
37 for (const diagnostic of localeDiagnostics(diagramType, diagram.meta)) {
38 recordDiagnostic(diagnostic);
39 if (process.env.ARCHIFY_DIAGNOSTIC_FORMAT !== 'json') {
40 console.warn(`archify: ${diagnostic.message}`);
41 }
42 }
45// Common CLI head: node render-<type>.mjs [input.json] [output.html]
46// Keep this synchronous because callers also use it to establish the guarded
47// output path before testing a last-moment filesystem alias change.
48export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
49 // Compilers also import this module for SVG helpers. Only CLI execution
50 // should install a process-level handler, before reading or validating input.
51 installRendererDiagnosticBoundary();
52 const skillRoot = path.resolve(rendererDir, '../..');
53 const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
54 let input;
55 try {
56 input = fs.readFileSync(inputPath, 'utf8');
57 } catch (error) {
58 if (!isFilesystemError(error)) throw error;
59 const message = `Input could not be read: ${error.message}`;
60 throwDiagnosticError(message, [{
61 code: 'input/read', message,
62 subject: { input: inputPath },
63 evidence: { systemCode: error.code, reason: error.message },
64 supportedFixes: ['provide one readable JSON input file'],
65 }]);
66 }
67 let diagram;
68 try {
69 diagram = JSON.parse(input);
70 } catch (error) {
71 if (!(error instanceof SyntaxError)) throw error;
72 const message = `Input JSON could not be parsed: ${error.message}`;
73 throwDiagnosticError(message, [{
74 code: 'input/json-parse', message,
75 subject: { input: inputPath },
76 evidence: { reason: error.message },
77 supportedFixes: ['repair the JSON syntax and run validation again'],
78 }]);
79 }
80 const authoredOutput = diagram?.meta?.output;
81 if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput);
82 validateSchema(diagramType, diagram);
83 applyLocaleTranslations(diagramType, diagram);
84 validateCrossCollectionContracts(diagramType, diagram);
85 validateEngineeringProfile(diagramType, diagram);
86 const sourceEvidence = verifyRepositoryEvidence(diagramType, diagram, process.env.ARCHIFY_REPO_ROOT);
87 const template = fs.readFileSync(path.join(skillRoot, 'assets/template.html'), 'utf8').replace('</head>', `${softwareNoticesHtml}\n</head>`);
88 const outputRequest = {
89 requestedOutput: argv[3],
90 authoredOutput: diagram.meta?.output,
91 defaultOutput: `${diagramType}.html`,
92 inputPaths: [inputPath],
93 cwd: process.cwd(),
94 };
95 let outPath;
96 try {
97 ({ outputPath: outPath } = resolveOutputPath(outputRequest));
98 } catch (error) {
99 throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
100 }
101 outputPathGuards.set(outPath, outputRequest);
102 return { diagram, template, outPath, sourceEvidence };
105// Brand URL capture is the only asynchronous authoring step. Typed renderers
106// opt into it through this wrapper without changing loadDiagram's long-lived
107// synchronous safety contract.
108export async function loadDiagramWithBrandMarks(options) {
109 const loaded = loadDiagram(options);
110 await prepareDiagramBrandMarks(options.diagramType, loaded.diagram);
111 return loaded;
114const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle', 'erd', 'tree', 'class', 'timeline', 'waterfall']);
116function isFilesystemError(error) {
117 return typeof error?.code === 'string'
118 && typeof error?.syscall === 'string'
119 && typeof error?.errno === 'number';
122function throwOutputError(error, output) {
123 if (error?.archifyDiagnostics || !isFilesystemError(error)) throw error;
124 const message = `Output could not be written: ${error.message}`;
125 throwDiagnosticError(message, [{
126 code: 'output/write', message,
127 subject: { output },
128 evidence: { systemCode: error.code, reason: error.message },
129 supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
130 }]);
133function throwAtomicOutputFailure(result, output) {
134 const reason = result.reason || { code: 'unclassified' };
135 const changed = result.status === 'different';
136 const candidate = reason.code.startsWith('candidate-');
137 const nonRegular = ['target-not-regular-file', 'candidate-not-regular-file'].includes(reason.code);
138 const hardlinked = ['target-hardlinked', 'candidate-hardlinked'].includes(reason.code);
139 const message = changed
140 ? 'Output target changed while the rendered artifact was being prepared.'
141 : nonRegular
142 ? candidate
143 ? 'Temporary output candidate is no longer a regular file.'
144 : 'Output already exists and is not a regular file.'
145 : hardlinked
146 ? candidate
147 ? 'Temporary output candidate has multiple hard-link names.'
148 : 'Output already exists through multiple hard-link names.'
149 : 'Output target stability could not be determined safely before commit.';
150 throwDiagnosticError(message, [{
151 code: changed
152 ? 'output/target-changed'
153 : nonRegular
154 ? 'output/target-not-regular-file'
155 : hardlinked
156 ? 'output/target-hardlinked'
157 : 'output/target-indeterminate',
158 message,
159 subject: { output },
160 evidence: { relation: reason },
161 supportedFixes: [hardlinked
162 ? 'choose a non-hardlinked output path; atomic replacement cannot update every hard-link name'
163 : 'retry after other processes stop replacing or redirecting the output path'],
164 }]);
167function stageRenderedHtml(outputPath, html, mode) {
168 for (let attempt = 0; attempt < 100; attempt += 1) {
169 renderCandidateSequence += 1;
170 const candidatePath = path.join(
171 path.dirname(outputPath),
172 `.archify-render-${process.pid}-${Date.now().toString(36)}-${renderCandidateSequence}.tmp`,
173 );
174 let descriptor;
175 let identity;
176 try {
177 const noFollow = process.platform === 'win32' ? 0 : (fs.constants.O_NOFOLLOW || 0);
178 descriptor = fs.openSync(
179 candidatePath,
180 fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | noFollow,
181 mode ?? 0o666,
182 );
183 let metadata;
184 try {
185 metadata = fs.fstatSync(descriptor, { bigint: true });
186 } catch (error) {
187 // A transient first inspection failure must not strand the exclusive
188 // candidate. A successful retry binds cleanup to the still-open file;
189 // if both inspections fail, preserving the unknown entry is safer.
190 try {
191 const retry = fs.fstatSync(descriptor, { bigint: true });
192 if (retry.isFile() && retry.ino !== 0n) {
193 identity = { device: retry.dev, inode: retry.ino };
194 }
195 } catch {}
196 throw error;
197 }
198 if (!metadata.isFile() || metadata.ino === 0n) {
199 throw new Error('Temporary render candidate identity could not be verified safely.');
200 }
201 identity = { device: metadata.dev, inode: metadata.ino };
202 fs.writeFileSync(descriptor, html);
203 // Creation modes are filtered through the process umask. An atomic
204 // replacement must retain the exact permissions of an existing target,
205 // while a brand-new target should keep normal umask behavior.
206 if (mode !== null) fs.fchmodSync(descriptor, mode);
207 fs.closeSync(descriptor);
208 descriptor = undefined;
209 return { candidatePath, identity };
210 } catch (error) {
211 if (descriptor !== undefined) {
212 try { fs.closeSync(descriptor); } catch {}
213 }
214 if (error.code === 'EEXIST') continue;
215 if (identity) {
216 const cleanup = removeOwnedRegularFile(candidatePath, identity);
217 if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
218 const cleanupError = new Error(`${error.message}; temporary render candidate cleanup also failed.`);
219 cleanupError.cause = error;
220 throw cleanupError;
221 }
222 }
223 throw error;
224 }
225 }
226 const error = new Error(`Could not reserve a temporary render candidate beside "${outputPath}".`);
227 error.code = 'EEXIST';
228 error.errno = -17;
229 error.syscall = 'open';
230 throw error;
233// Common CLI tail: fill the template and write the standalone HTML file.
234export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
235 if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
236 const outputGuard = outputPathGuards.get(outPath);
237 const html = applyTemplate(template, {
238 title: meta.title,
239 subtitle: meta.subtitle,
240 svg,
241 cards: renderCards(cards),
242 locale: meta.locale,
243 visualPreset: meta.visual_preset || 'classic',
244 sourceEvidence,
245 });
246 let candidatePath;
247 let candidateIdentity;
248 let candidateBinding;
249 try {
250 fs.mkdirSync(path.dirname(outPath), { recursive: true });
251 const outputCapture = captureAtomicOutput(outPath);
252 if (outputCapture.status !== 'captured') throwAtomicOutputFailure(outputCapture, outPath);
253 const beforeStage = verifyAtomicOutput(outputCapture.snapshot);
254 if (beforeStage.status !== 'match') throwAtomicOutputFailure(beforeStage, outPath);
255 ({ candidatePath, identity: candidateIdentity } = stageRenderedHtml(
256 outputCapture.commitPath,
257 html,
258 outputCapture.mode,
259 ));
261 // The renderer may spend substantial time building HTML after loadDiagram
262 // establishes the guard. Re-run it after staging so a last-moment alias
263 // cannot redirect the commit onto an input file.
264 if (outputGuard) resolveOutputPath(outputGuard);
265 const candidateCapture = captureRegularFileBinding(candidatePath, {
266 subject: 'candidate',
267 expectedSha256: createHash('sha256').update(html).digest('hex'),
268 expectedBytes: Buffer.byteLength(html),
269 expectedIdentity: candidateIdentity,
270 ...(outputCapture.mode === null ? {} : { expectedMode: outputCapture.mode }),
271 });
272 if (candidateCapture.status !== 'captured') throwAtomicOutputFailure(candidateCapture, outPath);
273 candidateBinding = candidateCapture.binding;
274 const beforeCommit = verifyAtomicOutput(outputCapture.snapshot);
275 if (beforeCommit.status !== 'match') throwAtomicOutputFailure(beforeCommit, outPath);
276 const publication = publishRegularFileBinding(
277 candidateBinding,
278 candidatePath,
279 outputCapture.snapshot,
280 { subject: 'candidate' },
281 );
282 if (!['committed', 'committed-with-warning'].includes(publication.status)) {
283 throwAtomicOutputFailure(publication, outPath);
284 }
285 const releasedCandidate = releaseRegularFileBinding(candidateBinding);
286 candidateBinding = undefined;
287 if (releasedCandidate.status !== 'released') throwAtomicOutputFailure(releasedCandidate, outPath);
288 candidatePath = undefined;
289 candidateIdentity = undefined;
290 } catch (error) {
291 throwOutputError(error, outPath);
292 } finally {
293 outputPathGuards.delete(outPath);
294 if (candidateBinding) releaseRegularFileBinding(candidateBinding);
295 if (candidatePath && candidateIdentity) {
296 const cleanup = removeOwnedRegularFile(candidatePath, candidateIdentity);
297 if (!['removed', 'absent', 'preserved'].includes(cleanup.status)) {
298 throwAtomicOutputFailure(cleanup, outPath);
299 }
300 }
301 }
302 console.log(outPath);
305const SEMANTIC_COLLECTIONS = {
306 architecture: 'components',
307 workflow: 'nodes',
308 sequence: 'participants',
309 dataflow: 'nodes',
310 lifecycle: 'states',
311 erd: 'entities',
312 tree: 'nodes',
313 class: 'types',
314 timeline: 'events',
315 waterfall: 'spans',
316};
318const RELATIONSHIP_COLLECTIONS = {
319 architecture: 'connections',
320 workflow: 'edges',
321 sequence: 'messages',
322 dataflow: 'flows',
323 lifecycle: 'transitions',
324 erd: 'relationships',
325 class: 'relationships',
326};
328// Relationship IDs are optional for backwards compatibility, but once an
329// author supplies one it becomes the durable identity used by viewer links.
330// Keep uniqueness enforcement in the shared zero-install path so every typed
331// renderer fails the same way even when development dependencies are absent.
332export function validateRelationshipIds(diagramType, diagram) {
333 const collection = RELATIONSHIP_COLLECTIONS[diagramType];
334 const relationships = collection && Array.isArray(diagram[collection]) ? diagram[collection] : [];
335 const seen = new Set();
336 const problems = [];
338 relationships.forEach((relationship, index) => {
339 if (relationship.id === undefined || relationship.id === null || relationship.id === '') return;
340 if (seen.has(relationship.id)) {
341 problems.push(`/${collection}/${index}/id duplicates relationship id ${JSON.stringify(relationship.id)}`);
342 }
343 seen.add(relationship.id);
344 });
346 if (problems.length) {
347 throwDiagnosticProblems('Relationship identity validation failed', problems, {
348 code: 'relationship/duplicate-id',
349 subject: { diagramType, collection },
350 });
351 }
354// Share relationship-ID semantic checks between the loader
355// and workflow compiler without performing filesystem operations (see #429).
356export function validateCrossCollectionContracts(diagramType, diagram) {
357 validateRelationshipIds(diagramType, diagram);
360// Accessible name for the generated diagram SVG.
361export function svgRootAttrs(meta, explicitQualityProfile) {
362 // Motion is a Viewer capability for every diagram; Live/Still controls playback.
363 const animation = ' data-animation="trace"';
364 const preset = ` data-preset="${esc(meta.visual_preset || 'classic')}"`;
365 const engineeringProfile = meta.engineering_profile
366 ? ` data-engineering-profile="${esc(meta.engineering_profile)}"`
367 : '';
368 const requestedProfile = explicitQualityProfile || process.env.ARCHIFY_QUALITY_PROFILE || meta.quality_profile;
369 const qualityProfile = requestedProfile === 'showcase' ? 'showcase' : 'standard';
370 const advisory = requestedProfile ? '' : ' data-quality-gates="advisory"';
371 return `role="img" lang="${esc(resolveLocale(meta.locale))}" aria-labelledby="archify-diagram-title archify-diagram-description"${animation}${preset}${engineeringProfile} data-quality-profile="${esc(qualityProfile)}"${advisory}`;
374// Keep the accessible name inside the SVG so it survives standalone SVG
375// export and embedding. The fixed IDs are deterministic because an Archify
376// artifact intentionally contains one primary diagram SVG.
377export function svgAccessibleText(meta, kind) {
378 const description = meta.subtitle || translateMessage(meta.locale, `diagram.description.${kind}`);
379 return ` <title id="archify-diagram-title">${esc(meta.title)}</title>\n <desc id="archify-diagram-description">${esc(description)}</desc>`;
382export function animateAttr(meta, kind, step) {
383 // Ambient trace must finish inside the fixed six-second WebM capture. The
384 // cap affects visual delay only; authored order and semantic identity stay
385 // untouched in the JSON, DOM, and relationship contracts.
386 const safeStep = Number.isFinite(step) && step >= 0 ? Math.min(12, Math.floor(step)) : 0;
387 return ` data-animate="${kind}" style="--step:${safeStep}"`;
390// Stable semantic hooks for the standalone HTML explorer. IDs already pass
391// the schema's conservative identifier pattern; escape again at the markup
392// boundary so these helpers remain safe if that contract expands later.
393export function focusNodeAttrs(id, label, metadata = {}, locale) {
394 const optional = [
395 ['data-node-kind', metadata.kind],
396 ['data-node-sublabel', metadata.sublabel],
397 ['data-node-tag', metadata.tag],
398 ['data-node-context', metadata.context],
399 ['data-node-brand', metadata.brand],
400 ['data-node-brand-id', metadata.brandId],
401 ['data-node-brand-status', metadata.brandStatus],
402 ['data-node-brand-source', metadata.brandSource],
403 ].filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
404 .map(([name, value]) => ` ${name}="${esc(String(value))}"`)
405 .join('');
406 const detail = [metadata.sublabel, metadata.context, metadata.brand]
407 .filter((value) => value !== undefined && value !== null && String(value).trim() !== '')
408 .join(', ');
409 const aria = detail
410 ? translateMessage(locale, 'node.focus.detail', { label, detail })
411 : translateMessage(locale, 'node.focus', { label });
412 return `id="node-${esc(id)}" data-node-id="${esc(id)}" data-node-label="${esc(label)}" tabindex="0" role="button" aria-label="${esc(aria)}" aria-pressed="false"${optional}`;
415// Native SVG titles preserve a compact details-on-demand fallback when the
416// canonical SVG is embedded inline outside the full Archify viewer.
417export function focusNodeTitle(label, metadata = {}) {
418 const parts = [label, metadata.sublabel, metadata.context, metadata.tag, metadata.brand]
419 .filter((value) => value !== undefined && value !== null && String(value).trim() !== '');
420 return `<title>${esc(parts.join(' · '))}</title>`;
423export function focusEdgeAttrs(from, to, label, key, id) {
424 const named = label ? ` data-edge-label="${esc(label)}"` : '';
425 const keyed = key !== undefined && key !== null ? ` data-edge-key="${esc(String(key))}"` : '';
426 const identified = id !== undefined && id !== null && String(id).trim() !== ''
427 ? ` data-edge-id="${esc(String(id))}"`
428 : '';
429 return `data-edge-from="${esc(from)}" data-edge-to="${esc(to)}"${named}${keyed}${identified}`;