- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
1989 lines
70 KiB
JavaScript
Executable File
1989 lines
70 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const skillRoot = path.resolve(__dirname, '..');
|
|
|
|
const TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);
|
|
|
|
function usage() {
|
|
return `Usage:
|
|
archify render <type> <input.json> [output.html] [--quality standard|showcase] [--repo-root path (architecture only)]
|
|
archify compare architecture <base.json> <head.json> [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path]
|
|
archify deliver <type> <input.json> [output.html] [--json] [--open] [--quality standard|showcase] [--repo-root path (architecture only)]
|
|
archify preview <type> <input.json> [output.html] [--no-open] [--quality standard|showcase] [--repo-root path (architecture only)]
|
|
archify validate <type> <input.json> [--json] [--layout-json] [--quality standard|showcase] [--repo-root path (architecture only)]
|
|
archify migrate workflow <old.json> <new.json> --to-schema 2 [--json]
|
|
archify inspect <type> <input.json>
|
|
archify check <output.html>
|
|
archify visual-check <output.html> [--json]
|
|
archify guide [scenario or question] [--json] [--lang en|zh]
|
|
archify brands [name, alias, domain, or category] [--json]
|
|
archify brands capture <url> [--json]
|
|
archify examples
|
|
archify doctor
|
|
archify demo [output-directory]
|
|
|
|
Types:
|
|
architecture, workflow, sequence, dataflow, lifecycle
|
|
`;
|
|
}
|
|
|
|
function fail(message, code = 2) {
|
|
console.error(message);
|
|
process.exit(code);
|
|
}
|
|
|
|
function rendererPath(type) {
|
|
if (!TYPES.has(type)) {
|
|
fail(`Unknown diagram type "${type}". Expected one of: ${[...TYPES].join(', ')}`);
|
|
}
|
|
return path.join(skillRoot, 'renderers', type, `render-${type}.mjs`);
|
|
}
|
|
|
|
function runNode(args, options = {}) {
|
|
return spawnSync(process.execPath, args, {
|
|
cwd: options.cwd || process.cwd(),
|
|
encoding: 'utf8',
|
|
stdio: options.stdio || 'inherit',
|
|
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
});
|
|
}
|
|
|
|
function extractQualityArgs(args) {
|
|
const rest = [];
|
|
let quality;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--quality') {
|
|
quality = args[index + 1];
|
|
if (!quality || quality.startsWith('--')) fail('--quality requires standard or showcase.');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--quality=')) {
|
|
quality = arg.slice('--quality='.length);
|
|
if (!quality) fail('--quality requires standard or showcase.');
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
if (quality !== undefined && !['standard', 'showcase'].includes(quality)) {
|
|
fail(`Unknown quality profile "${quality}". Expected standard or showcase.`);
|
|
}
|
|
return { rest, quality };
|
|
}
|
|
|
|
function extractRepoRootArgs(args) {
|
|
const rest = [];
|
|
let repoRoot;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--repo-root') {
|
|
repoRoot = args[index + 1];
|
|
if (!repoRoot || repoRoot.startsWith('--')) fail('--repo-root requires a repository path.');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--repo-root=')) {
|
|
repoRoot = arg.slice('--repo-root='.length);
|
|
if (!repoRoot) fail('--repo-root requires a repository path.');
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
return { rest, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined };
|
|
}
|
|
|
|
function rendererEnv(quality, repoRoot, diagnosticJson = false) {
|
|
return {
|
|
...(quality ? { ARCHIFY_QUALITY_PROFILE: quality } : {}),
|
|
...(repoRoot ? { ARCHIFY_REPO_ROOT: repoRoot } : {}),
|
|
...(diagnosticJson ? { ARCHIFY_DIAGNOSTIC_FORMAT: 'json' } : {}),
|
|
};
|
|
}
|
|
|
|
function diagnostic({ code, message, subject = {}, evidence = {}, supportedFixes = [], severity = 'error' }) {
|
|
return {
|
|
code,
|
|
severity,
|
|
message,
|
|
subject,
|
|
evidence,
|
|
supportedFixes,
|
|
};
|
|
}
|
|
|
|
function inputDiagnostic(error, inputPath) {
|
|
const isSyntax = error instanceof SyntaxError;
|
|
return diagnostic({
|
|
code: isSyntax ? 'input/json-parse' : 'input/read',
|
|
message: isSyntax
|
|
? `Input JSON could not be parsed: ${error.message}`
|
|
: `Input could not be read: ${error.message}`,
|
|
subject: { input: inputPath },
|
|
evidence: {
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
reason: error.message,
|
|
},
|
|
supportedFixes: [isSyntax
|
|
? 'repair the JSON syntax and run validation again'
|
|
: 'provide one readable JSON input file'],
|
|
});
|
|
}
|
|
|
|
function rendererFailure(result) {
|
|
if (result.error) {
|
|
return {
|
|
error: 'Renderer process could not start.',
|
|
diagnostics: [diagnostic({
|
|
code: 'internal/renderer-process',
|
|
message: 'Renderer process could not start.',
|
|
evidence: { reason: result.error.message },
|
|
})],
|
|
};
|
|
}
|
|
try {
|
|
const payload = JSON.parse((result.stderr || '').trim());
|
|
if (payload?.ok === false && Array.isArray(payload.diagnostics) && payload.diagnostics.length) {
|
|
return {
|
|
error: payload.error || payload.diagnostics[0].message,
|
|
diagnostics: payload.diagnostics,
|
|
};
|
|
}
|
|
} catch {
|
|
// The diagnostic boundary is intentionally fail-closed. Never copy a raw
|
|
// Node stack into a machine receipt when a renderer exits unexpectedly.
|
|
}
|
|
return {
|
|
error: 'Renderer failed before emitting a structured diagnostic.',
|
|
diagnostics: [diagnostic({
|
|
code: 'internal/unclassified',
|
|
message: 'Renderer failed before emitting a structured diagnostic.',
|
|
evidence: { exitCode: result.status ?? 1 },
|
|
})],
|
|
};
|
|
}
|
|
|
|
const COMPOSITION_CHECKS = new Set([
|
|
'label_route_clearance',
|
|
'relationship_crossings',
|
|
'relationship_corridors',
|
|
'container_border_runs',
|
|
'route_rhythm',
|
|
]);
|
|
|
|
const CHECK_FIXES = {
|
|
single_svg: ['remove additional SVG roots so the artifact contains exactly one diagram SVG'],
|
|
finite_svg: ['replace non-finite coordinates before rendering again'],
|
|
orthogonal_arrows: ['use renderer-supported orthogonal routing controls'],
|
|
legend_clearance: ['move the route or enlarge the viewBox so relationships do not enter the legend'],
|
|
};
|
|
|
|
const COMPOSITION_FIXES = {
|
|
'composition/proper-crossing': ['adjust route/via or channel coordinates so unrelated relationships use separate corridors'],
|
|
'composition/ambiguous-corridor': ['adjust route/via or channel coordinates so unrelated relationships do not visually merge'],
|
|
'composition/container-border-run': ['route across the frame perpendicularly through a clear opening'],
|
|
'composition/label-route-clearance': ['adjust labelAt, labelDx, labelDy, labelSegment, message y, or the other relationship route'],
|
|
'composition/desktop-readability': ['reduce the viewBox width, shorten node copy, widen affected nodes, or split the diagram so node context remains at least 6px at a 1440px desktop viewport'],
|
|
'composition/micro-segment': ['move the route/channel/via point so every visible segment is at least 8px'],
|
|
'composition/short-interior-segment': ['move the route/channel/via point so every interior turn has at least 16px'],
|
|
};
|
|
|
|
function checkerDiagnostics(checker) {
|
|
const diagnostics = [];
|
|
for (const issue of checker?.composition?.issues || []) {
|
|
if (issue.severity !== 'error') continue;
|
|
const { severity, code, relationship, ...evidence } = issue;
|
|
diagnostics.push(diagnostic({
|
|
code,
|
|
severity,
|
|
message: `Final artifact failed ${code}.`,
|
|
subject: relationship ? { relationship } : { check: 'composition' },
|
|
evidence,
|
|
supportedFixes: COMPOSITION_FIXES[code] || [],
|
|
}));
|
|
}
|
|
for (const check of checker?.checks || []) {
|
|
if (check.ok || COMPOSITION_CHECKS.has(check.name)) continue;
|
|
diagnostics.push(diagnostic({
|
|
code: `artifact/${check.name.replaceAll('_', '-')}`,
|
|
message: (check.details || []).find(Boolean) || `Final artifact failed ${check.name}.`,
|
|
subject: { check: check.name },
|
|
evidence: { details: check.details || [] },
|
|
supportedFixes: CHECK_FIXES[check.name] || [],
|
|
}));
|
|
}
|
|
return diagnostics.length ? diagnostics : [diagnostic({
|
|
code: 'artifact/check-failed',
|
|
message: 'Final artifact check failed without a classified diagnostic.',
|
|
subject: { check: 'unknown' },
|
|
evidence: {},
|
|
})];
|
|
}
|
|
|
|
function formatDiagnostics(error, diagnostics = []) {
|
|
if (!diagnostics.length) return error;
|
|
return [
|
|
error,
|
|
...diagnostics.map((entry) => {
|
|
const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : '';
|
|
return `[${entry.code}] ${entry.message}${fix}`;
|
|
}),
|
|
].join('\n');
|
|
}
|
|
|
|
function assertEvidenceType(type, repoRoot) {
|
|
if (repoRoot && type !== 'architecture') {
|
|
fail('--repo-root is currently supported for architecture diagrams only.');
|
|
}
|
|
}
|
|
|
|
function exitFrom(result) {
|
|
if (result.error) fail(result.error.message, 1);
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
function reportCompareFailure({ json, stage, error, code = 'delta/internal', details = {}, status = 1 }) {
|
|
const receipt = {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command: 'compare',
|
|
type: 'architecture',
|
|
stage,
|
|
error,
|
|
diagnostics: [{
|
|
code,
|
|
severity: 'error',
|
|
message: error,
|
|
subject: details.side ? { side: details.side, ...(details.path ? { path: details.path } : {}) } : {},
|
|
evidence: Object.fromEntries(Object.entries(details).filter(([key]) => !['side', 'path', 'supportedFixes'].includes(key))),
|
|
supportedFixes: details.supportedFixes || [],
|
|
}],
|
|
};
|
|
if (json) console.log(JSON.stringify(receipt, null, 2));
|
|
else console.error(formatDiagnostics(error, receipt.diagnostics));
|
|
process.exitCode = status;
|
|
}
|
|
|
|
function extractCompareOptions(args) {
|
|
const positional = [];
|
|
let receipt;
|
|
let json = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--json') {
|
|
json = true;
|
|
continue;
|
|
}
|
|
if (arg === '--receipt') {
|
|
receipt = args[index + 1];
|
|
if (!receipt || receipt.startsWith('--')) fail('--receipt requires a JSON output path.');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--receipt=')) {
|
|
receipt = arg.slice('--receipt='.length);
|
|
if (!receipt) fail('--receipt requires a JSON output path.');
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--')) fail(`Unknown compare option "${arg}".`);
|
|
positional.push(arg);
|
|
}
|
|
return { positional, receipt, json };
|
|
}
|
|
|
|
function compareReceiptPath(outputPath) {
|
|
const extension = path.extname(outputPath);
|
|
return extension ? `${outputPath.slice(0, -extension.length)}.receipt.json` : `${outputPath}.receipt.json`;
|
|
}
|
|
|
|
function compareCommitError(message, code, details = {}) {
|
|
const error = new Error(message);
|
|
error.compareStage = 'commit';
|
|
error.compareCode = code;
|
|
error.compareDetails = details;
|
|
return error;
|
|
}
|
|
|
|
function commitComparePair({ htmlCandidate, receiptCandidate, outputPath, receiptPath, stagingDirectory }) {
|
|
const targets = [
|
|
{ label: 'HTML artifact', target: outputPath, candidate: htmlCandidate, backup: path.join(stagingDirectory, '.previous-output') },
|
|
{ label: 'receipt', target: receiptPath, candidate: receiptCandidate, backup: path.join(stagingDirectory, '.previous-receipt') },
|
|
];
|
|
|
|
// Preflight the whole pair before moving either trusted target. This avoids
|
|
// replacing the HTML and only then discovering that its receipt destination
|
|
// cannot be committed (for example, because it is a directory).
|
|
for (const item of targets) {
|
|
if (!fs.existsSync(item.target)) continue;
|
|
const existing = fs.lstatSync(item.target);
|
|
if (!existing.isFile()) {
|
|
throw compareCommitError(
|
|
`Could not commit Architecture Delta: existing ${item.label} target is not a regular file.`,
|
|
'delta/commit-target',
|
|
{
|
|
target: path.basename(item.target),
|
|
targetType: existing.isDirectory() ? 'directory' : 'non-file',
|
|
supportedFixes: [`choose a regular-file path for the ${item.label}`],
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
const backedUp = [];
|
|
const committed = [];
|
|
try {
|
|
for (const item of targets) {
|
|
if (!fs.existsSync(item.target)) continue;
|
|
fs.renameSync(item.target, item.backup);
|
|
backedUp.push(item);
|
|
}
|
|
for (const item of targets) {
|
|
fs.renameSync(item.candidate, item.target);
|
|
committed.push(item);
|
|
}
|
|
} catch (cause) {
|
|
const rollbackErrors = [];
|
|
for (const item of [...committed].reverse()) {
|
|
try {
|
|
fs.rmSync(item.target, { force: true });
|
|
} catch (error) {
|
|
rollbackErrors.push(`${item.label}: remove failed (${error.message})`);
|
|
}
|
|
}
|
|
for (const item of [...backedUp].reverse()) {
|
|
try {
|
|
if (fs.existsSync(item.target)) fs.rmSync(item.target, { force: true });
|
|
fs.renameSync(item.backup, item.target);
|
|
} catch (error) {
|
|
rollbackErrors.push(`${item.label}: restore failed (${error.message})`);
|
|
}
|
|
}
|
|
throw compareCommitError(
|
|
rollbackErrors.length
|
|
? 'Architecture Delta pair commit failed and its previous files could not be fully restored.'
|
|
: 'Architecture Delta pair commit failed; the previous files were restored.',
|
|
rollbackErrors.length ? 'delta/commit-rollback-failed' : 'delta/commit-failed',
|
|
{
|
|
reason: cause.message,
|
|
...(rollbackErrors.length ? { rollbackErrors } : {}),
|
|
supportedFixes: ['check that both output paths are writable regular files, then retry'],
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
function renderValidatedArchitecture(inputPath, outputPath, quality, repoRoot) {
|
|
const render = runNode([rendererPath('architecture'), inputPath, outputPath], {
|
|
stdio: 'pipe',
|
|
env: rendererEnv(quality, repoRoot, true),
|
|
});
|
|
if (render.status !== 0) {
|
|
const failure = rendererFailure(render);
|
|
const error = new Error(failure.error);
|
|
error.compareStage = 'input';
|
|
error.compareStatus = render.status ?? 1;
|
|
error.diagnostics = failure.diagnostics;
|
|
throw error;
|
|
}
|
|
const check = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), outputPath], { stdio: 'pipe' });
|
|
if (check.status !== 0) {
|
|
const error = new Error('Validated snapshot failed final artifact checks.');
|
|
error.compareStage = 'check';
|
|
error.compareStatus = check.status ?? 1;
|
|
try {
|
|
error.checker = JSON.parse(check.stdout);
|
|
error.diagnostics = checkerDiagnostics(error.checker);
|
|
} catch {
|
|
error.diagnostics = [];
|
|
}
|
|
throw error;
|
|
}
|
|
const artifact = fs.readFileSync(outputPath);
|
|
return {
|
|
artifact,
|
|
html: artifact.toString('utf8'),
|
|
checks: JSON.parse(check.stdout),
|
|
sourceEvidence: sourceEvidenceFromArtifact(artifact),
|
|
};
|
|
}
|
|
|
|
async function commandCompare(args) {
|
|
const { resolveOutputPath } = await import('../renderers/shared/output-path.mjs');
|
|
const qualityArgs = extractQualityArgs(args);
|
|
const repoArgs = extractRepoRootArgs(qualityArgs.rest);
|
|
const options = extractCompareOptions(repoArgs.rest);
|
|
const [type, baseInput, headInput, requestedOutput] = options.positional;
|
|
if (type !== 'architecture' || !baseInput || !headInput || options.positional.length > 4) fail(usage());
|
|
let deltaRuntime;
|
|
try {
|
|
deltaRuntime = await import(pathToFileURL(path.join(skillRoot, 'delta/architecture-delta.mjs')).href);
|
|
} catch (error) {
|
|
reportCompareFailure({ json: options.json, stage: 'prepare', error: 'Architecture compare runtime is unavailable.', code: 'delta/runtime-missing', details: { reason: error.message, supportedFixes: ['install the complete Archify skill package'] } });
|
|
return;
|
|
}
|
|
const {
|
|
ArchitectureDeltaError,
|
|
annotateArchitectureSideSvg,
|
|
buildDeltaSvg,
|
|
canonicalArchitecture,
|
|
canonicalArchitectureJson,
|
|
compareArchitecture,
|
|
extractArchitectureSvg,
|
|
extractArtifactCss,
|
|
renderArchitectureDeltaHtml,
|
|
validateArchitectureDeltaHtml,
|
|
} = deltaRuntime;
|
|
|
|
const basePath = path.resolve(baseInput);
|
|
const headPath = path.resolve(headInput);
|
|
let outputPath;
|
|
try {
|
|
({ outputPath } = resolveOutputPath({
|
|
requestedOutput,
|
|
defaultOutput: 'architecture-delta.html',
|
|
inputPaths: [basePath, headPath],
|
|
}));
|
|
} catch (error) {
|
|
const outputDiagnostic = error.archifyDiagnostics?.[0];
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: error.message,
|
|
code: outputDiagnostic?.code || 'output/path-resolution',
|
|
details: {
|
|
...(outputDiagnostic?.subject || {}),
|
|
...(outputDiagnostic?.evidence || {}),
|
|
supportedFixes: outputDiagnostic?.supportedFixes || ['choose a safe output path and retry'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
let receiptPath;
|
|
try {
|
|
({ outputPath: receiptPath } = resolveOutputPath({
|
|
requestedOutput: options.receipt || compareReceiptPath(outputPath),
|
|
defaultOutput: compareReceiptPath(outputPath),
|
|
inputPaths: [basePath, headPath],
|
|
otherOutputPaths: [outputPath],
|
|
}));
|
|
} catch (error) {
|
|
const outputDiagnostic = error.archifyDiagnostics?.[0];
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: error.message,
|
|
code: outputDiagnostic?.code || 'output/path-resolution',
|
|
details: {
|
|
...(outputDiagnostic?.subject || {}),
|
|
...(outputDiagnostic?.evidence || {}),
|
|
supportedFixes: outputDiagnostic?.supportedFixes || ['choose a safe receipt path and retry'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
let baseBuffer;
|
|
let headBuffer;
|
|
let base;
|
|
let head;
|
|
try {
|
|
baseBuffer = fs.readFileSync(basePath);
|
|
base = JSON.parse(baseBuffer.toString('utf8'));
|
|
} catch (error) {
|
|
reportCompareFailure({ json: options.json, stage: 'input', error: `Could not read base input: ${error.message}`, code: 'delta/base-input', details: { side: 'base', reason: error.message } });
|
|
return;
|
|
}
|
|
try {
|
|
headBuffer = fs.readFileSync(headPath);
|
|
head = JSON.parse(headBuffer.toString('utf8'));
|
|
} catch (error) {
|
|
reportCompareFailure({ json: options.json, stage: 'input', error: `Could not read head input: ${error.message}`, code: 'delta/head-input', details: { side: 'head', reason: error.message } });
|
|
return;
|
|
}
|
|
|
|
const outputDirectory = path.dirname(outputPath);
|
|
if (path.dirname(receiptPath) !== outputDirectory) {
|
|
reportCompareFailure({ json: options.json, stage: 'prepare', error: 'The compare receipt must be written beside the HTML artifact.', code: 'delta/receipt-directory', details: { supportedFixes: ['choose a --receipt path in the same directory as output.html'] } });
|
|
return;
|
|
}
|
|
try {
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
} catch (error) {
|
|
reportCompareFailure({ json: options.json, stage: 'prepare', error: `Could not create compare output directory: ${error.message}`, code: 'delta/output-directory', details: { reason: error.message } });
|
|
return;
|
|
}
|
|
|
|
let stagingDirectory;
|
|
try {
|
|
stagingDirectory = fs.mkdtempSync(path.join(outputDirectory, '.archify-compare-'));
|
|
} catch (error) {
|
|
reportCompareFailure({ json: options.json, stage: 'prepare', error: `Could not create compare candidate: ${error.message}`, code: 'delta/candidate-directory', details: { reason: error.message } });
|
|
return;
|
|
}
|
|
|
|
const baseCandidate = path.join(stagingDirectory, 'base.html');
|
|
const headCandidate = path.join(stagingDirectory, 'head.html');
|
|
const rawBaseCandidate = path.join(stagingDirectory, 'base.raw.html');
|
|
const rawHeadCandidate = path.join(stagingDirectory, 'head.raw.html');
|
|
const canonicalBaseInput = path.join(stagingDirectory, 'base.architecture.json');
|
|
const canonicalHeadInput = path.join(stagingDirectory, 'head.architecture.json');
|
|
const htmlCandidate = path.join(stagingDirectory, path.basename(outputPath));
|
|
const receiptCandidate = path.join(stagingDirectory, path.basename(receiptPath));
|
|
|
|
try {
|
|
let baseResult;
|
|
let headResult;
|
|
try {
|
|
renderValidatedArchitecture(basePath, rawBaseCandidate, qualityArgs.quality, repoArgs.repoRoot);
|
|
} catch (error) {
|
|
const diagnosticEntry = error.diagnostics?.[0];
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: error.compareStage || 'validate',
|
|
error: `Base snapshot failed validation: ${error.message}`,
|
|
code: diagnosticEntry?.code || 'delta/base-validation',
|
|
details: { side: 'base', ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), ...(diagnosticEntry?.evidence || {}), supportedFixes: diagnosticEntry?.supportedFixes || [] },
|
|
status: error.compareStatus || 1,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
renderValidatedArchitecture(headPath, rawHeadCandidate, qualityArgs.quality, repoArgs.repoRoot);
|
|
} catch (error) {
|
|
const diagnosticEntry = error.diagnostics?.[0];
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: error.compareStage || 'validate',
|
|
error: `Head snapshot failed validation: ${error.message}`,
|
|
code: diagnosticEntry?.code || 'delta/head-validation',
|
|
details: { side: 'head', ...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}), ...(diagnosticEntry?.evidence || {}), supportedFixes: diagnosticEntry?.supportedFixes || [] },
|
|
status: error.compareStatus || 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Validation must see the exact authored inputs. Only after both sides
|
|
// pass do we canonicalize their collection order for deterministic SVG
|
|
// geometry and stable artifact bytes.
|
|
fs.writeFileSync(canonicalBaseInput, JSON.stringify(canonicalArchitecture(base)));
|
|
fs.writeFileSync(canonicalHeadInput, JSON.stringify(canonicalArchitecture(head)));
|
|
baseResult = renderValidatedArchitecture(canonicalBaseInput, baseCandidate, qualityArgs.quality, repoArgs.repoRoot);
|
|
headResult = renderValidatedArchitecture(canonicalHeadInput, headCandidate, qualityArgs.quality, repoArgs.repoRoot);
|
|
|
|
const semanticHash = (diagram) => createHash('sha256').update(canonicalArchitectureJson(diagram)).digest('hex');
|
|
let compareIr;
|
|
try {
|
|
compareIr = compareArchitecture(base, head, {
|
|
baseRawSha256: createHash('sha256').update(baseBuffer).digest('hex'),
|
|
headRawSha256: createHash('sha256').update(headBuffer).digest('hex'),
|
|
baseSemanticSha256: semanticHash(base),
|
|
headSemanticSha256: semanticHash(head),
|
|
baseBytes: baseBuffer.byteLength,
|
|
headBytes: headBuffer.byteLength,
|
|
baseVerified: Boolean(baseResult.sourceEvidence),
|
|
headVerified: Boolean(headResult.sourceEvidence),
|
|
});
|
|
} catch (error) {
|
|
if (!(error instanceof ArchitectureDeltaError)) throw error;
|
|
reportCompareFailure({ json: options.json, stage: 'compare', error: error.message, code: error.code, details: error.details });
|
|
return;
|
|
}
|
|
|
|
const baseSourceSvg = extractArchitectureSvg(baseResult.html);
|
|
const headSourceSvg = extractArchitectureSvg(headResult.html);
|
|
const baseSvg = annotateArchitectureSideSvg(baseSourceSvg, compareIr, 'base');
|
|
const headSvg = annotateArchitectureSideSvg(headSourceSvg, compareIr, 'head');
|
|
const deltaSvg = buildDeltaSvg(baseSourceSvg, headSourceSvg, compareIr);
|
|
// Raw input hashes and byte counts belong in the sidecar receipt, not the
|
|
// artifact. Keeping them out makes formatting-only input rewrites produce
|
|
// the exact same canonical review HTML and artifact hash.
|
|
const artifactIr = {
|
|
...compareIr,
|
|
base: Object.fromEntries(Object.entries(compareIr.base).filter(([key]) => !['rawSha256', 'bytes'].includes(key))),
|
|
head: Object.fromEntries(Object.entries(compareIr.head).filter(([key]) => !['rawSha256', 'bytes'].includes(key))),
|
|
};
|
|
const html = renderArchitectureDeltaHtml({
|
|
receipt: artifactIr,
|
|
baseSvg,
|
|
deltaSvg,
|
|
headSvg,
|
|
baseHtml: baseResult.html,
|
|
headHtml: headResult.html,
|
|
artifactCss: extractArtifactCss(headResult.html),
|
|
});
|
|
const deltaValidation = validateArchitectureDeltaHtml(html, artifactIr);
|
|
fs.writeFileSync(htmlCandidate, html);
|
|
const artifact = fs.readFileSync(htmlCandidate);
|
|
const baseChecks = baseResult.checks.checks.filter((check) => check.ok).length;
|
|
const headChecks = headResult.checks.checks.filter((check) => check.ok).length;
|
|
const finalReceipt = {
|
|
...compareIr,
|
|
artifact: { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength },
|
|
validation: {
|
|
checksPassed: baseChecks + headChecks + deltaValidation.checksPassed,
|
|
checkCount: baseResult.checks.checks.length + headResult.checks.checks.length + deltaValidation.checkCount,
|
|
baseComposition: baseResult.checks.composition.status,
|
|
headComposition: headResult.checks.composition.status,
|
|
},
|
|
};
|
|
fs.writeFileSync(receiptCandidate, `${JSON.stringify(finalReceipt, null, 2)}\n`);
|
|
|
|
try {
|
|
const currentOutput = resolveOutputPath({
|
|
requestedOutput,
|
|
defaultOutput: 'architecture-delta.html',
|
|
inputPaths: [basePath, headPath],
|
|
}).outputPath;
|
|
resolveOutputPath({
|
|
requestedOutput: options.receipt || compareReceiptPath(currentOutput),
|
|
defaultOutput: compareReceiptPath(currentOutput),
|
|
inputPaths: [basePath, headPath],
|
|
otherOutputPaths: [currentOutput],
|
|
});
|
|
} catch (error) {
|
|
const outputDiagnostic = error.archifyDiagnostics?.[0];
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'commit',
|
|
error: error.message,
|
|
code: outputDiagnostic?.code || 'output/path-resolution',
|
|
details: {
|
|
...(outputDiagnostic?.subject || {}),
|
|
...(outputDiagnostic?.evidence || {}),
|
|
supportedFixes: outputDiagnostic?.supportedFixes || ['restore safe output paths and retry'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
commitComparePair({ htmlCandidate, receiptCandidate, outputPath, receiptPath, stagingDirectory });
|
|
if (options.json) console.log(JSON.stringify(finalReceipt, null, 2));
|
|
else {
|
|
console.log(`compared architecture ${outputPath}`);
|
|
console.log(`${finalReceipt.validation.checksPassed}/${finalReceipt.validation.checkCount} checks; completeness ${finalReceipt.completeness}; ${finalReceipt.proofLevel}; sha256 ${finalReceipt.artifact.sha256.slice(0, 12)}`);
|
|
console.log(`receipt ${receiptPath}`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ArchitectureDeltaError) {
|
|
reportCompareFailure({ json: options.json, stage: 'artifact', error: error.message, code: error.code, details: error.details });
|
|
} else if (error.compareStage === 'commit') {
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: error.compareStage,
|
|
error: error.message,
|
|
code: error.compareCode,
|
|
details: error.compareDetails,
|
|
});
|
|
} else {
|
|
reportCompareFailure({ json: options.json, stage: 'internal', error: 'Architecture compare failed before commit.', code: 'delta/internal', details: { reason: error.message } });
|
|
}
|
|
} finally {
|
|
try {
|
|
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error(`Warning: could not remove compare staging directory: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function commandRender(args) {
|
|
const qualityArgs = extractQualityArgs(args);
|
|
const repoArgs = extractRepoRootArgs(qualityArgs.rest);
|
|
const [type, input, output] = repoArgs.rest;
|
|
if (!type || !input) fail(usage());
|
|
assertEvidenceType(type, repoArgs.repoRoot);
|
|
const result = runNode([rendererPath(type), input, ...(output ? [output] : [])], {
|
|
env: rendererEnv(qualityArgs.quality, repoArgs.repoRoot),
|
|
});
|
|
if (result.status !== 0) exitFrom(result);
|
|
}
|
|
|
|
function reportArtifactFailure({ command, json, stage, type, input, output, error, diagnostics = [], status = 1, checker }) {
|
|
const receipt = {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command,
|
|
stage,
|
|
type,
|
|
input,
|
|
...(output === undefined ? {} : { output }),
|
|
error,
|
|
diagnostics,
|
|
...(checker ? { checker } : {}),
|
|
};
|
|
if (json) console.log(JSON.stringify(receipt, null, 2));
|
|
else console.error(formatDiagnostics(error, diagnostics));
|
|
process.exitCode = status;
|
|
}
|
|
|
|
function reportDeliveryFailure(options) {
|
|
reportArtifactFailure({ ...options, command: 'deliver' });
|
|
}
|
|
|
|
function reportValidateFailure(options) {
|
|
reportArtifactFailure({ ...options, command: 'validate' });
|
|
}
|
|
|
|
function sourceEvidenceFromArtifact(artifact) {
|
|
const html = artifact.toString('utf8');
|
|
const match = html.match(/<script id="archify-source-evidence-data" type="application\/json">([\s\S]*?)<\/script>/);
|
|
if (!match) return null;
|
|
const evidence = JSON.parse(match[1]);
|
|
if (evidence?.verified !== true || !evidence.repository?.url || !evidence.repository?.revision || !Number.isInteger(evidence.referenceCount)) {
|
|
throw new Error('Rendered source evidence receipt is incomplete.');
|
|
}
|
|
return evidence;
|
|
}
|
|
|
|
function engineeringProfileFromArtifact(artifact) {
|
|
const match = artifact.toString('utf8').match(/<svg[^>]*\sdata-engineering-profile="([^"]+)"/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
async function commandDeliver(args) {
|
|
const { resolveOutputPath } = await import('../renderers/shared/output-path.mjs');
|
|
const qualityArgs = extractQualityArgs(args);
|
|
const repoArgs = extractRepoRootArgs(qualityArgs.rest);
|
|
const json = repoArgs.rest.includes('--json');
|
|
const open = repoArgs.rest.includes('--open');
|
|
const knownOptions = new Set(['--json', '--open']);
|
|
const unknown = repoArgs.rest.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown.length) fail(`Unknown deliver option "${unknown[0]}".`);
|
|
const positional = repoArgs.rest.filter((arg) => !knownOptions.has(arg));
|
|
const [type, input, requestedOutput] = positional;
|
|
if (!type || !input || positional.length > 3) fail(usage());
|
|
assertEvidenceType(type, repoArgs.repoRoot);
|
|
|
|
const renderer = rendererPath(type);
|
|
const inputPath = path.resolve(input);
|
|
let specification;
|
|
let diagram;
|
|
try {
|
|
specification = fs.readFileSync(inputPath);
|
|
diagram = JSON.parse(specification.toString('utf8'));
|
|
} catch (error) {
|
|
const repair = inputDiagnostic(error, inputPath);
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'input',
|
|
type,
|
|
input: inputPath,
|
|
output: path.resolve(requestedOutput || `${type}.html`),
|
|
error: `Could not read delivery input "${inputPath}": ${error.message}`,
|
|
diagnostics: [repair],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const authoredOutput = typeof diagram?.meta?.output === 'string' && diagram.meta.output
|
|
? diagram.meta.output
|
|
: undefined;
|
|
let outputPath;
|
|
try {
|
|
({ outputPath } = resolveOutputPath({
|
|
requestedOutput,
|
|
authoredOutput,
|
|
defaultOutput: `${type}.html`,
|
|
inputPaths: [inputPath],
|
|
}));
|
|
} catch (error) {
|
|
const attemptedOutput = path.resolve(requestedOutput || authoredOutput || `${type}.html`);
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'prepare',
|
|
type,
|
|
input: inputPath,
|
|
output: attemptedOutput,
|
|
error: error.message,
|
|
diagnostics: error.archifyDiagnostics || [diagnostic({
|
|
code: 'output/path-resolution',
|
|
message: error.message,
|
|
subject: { output: attemptedOutput },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
|
|
supportedFixes: ['choose a safe output path and retry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const outputDirectory = path.dirname(outputPath);
|
|
try {
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
} catch (error) {
|
|
const message = `Could not create delivery directory "${outputDirectory}": ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'prepare',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/prepare-directory',
|
|
message,
|
|
subject: { outputDirectory },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
supportedFixes: ['choose a writable output directory'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Keep the candidate beside the target so the final rename is one
|
|
// same-filesystem commit. A render or artifact-check failure never touches
|
|
// an existing trusted output.
|
|
let stagingDirectory;
|
|
try {
|
|
stagingDirectory = fs.mkdtempSync(path.join(outputDirectory, '.archify-delivery-'));
|
|
} catch (error) {
|
|
const message = `Could not create a delivery candidate beside "${outputPath}": ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'prepare',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/prepare-candidate',
|
|
message,
|
|
subject: { output: outputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
supportedFixes: ['choose a writable output directory on the target filesystem'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const candidatePath = path.join(stagingDirectory, path.basename(outputPath));
|
|
const specificationSnapshotPath = path.join(stagingDirectory, 'specification.snapshot.json');
|
|
|
|
try {
|
|
try {
|
|
fs.writeFileSync(specificationSnapshotPath, specification, { flag: 'wx' });
|
|
} catch (error) {
|
|
const message = `Could not freeze the delivery specification: ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'prepare',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/freeze-specification',
|
|
message,
|
|
subject: { input: inputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
supportedFixes: ['choose a writable output directory on the target filesystem'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const render = runNode([renderer, specificationSnapshotPath, candidatePath], {
|
|
stdio: 'pipe',
|
|
env: rendererEnv(qualityArgs.quality, repoArgs.repoRoot, true),
|
|
});
|
|
if (render.status !== 0) {
|
|
const failure = rendererFailure(render);
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'render',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: failure.error,
|
|
diagnostics: failure.diagnostics,
|
|
status: render.status ?? 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const check = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), candidatePath], {
|
|
stdio: 'pipe',
|
|
});
|
|
if (check.status !== 0) {
|
|
if (check.stderr) process.stderr.write(check.stderr);
|
|
let checker;
|
|
try {
|
|
checker = JSON.parse(check.stdout);
|
|
checker.file = outputPath;
|
|
} catch {
|
|
checker = { ok: false, file: outputPath, diagnostic: check.stdout.trim() };
|
|
}
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'check',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: 'Final artifact check failed; the previous artifact was preserved.',
|
|
diagnostics: checkerDiagnostics(checker),
|
|
status: check.status ?? 1,
|
|
checker,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = JSON.parse(check.stdout);
|
|
} catch (error) {
|
|
const message = `Could not parse the successful artifact-check receipt: ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'receipt',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/receipt-invalid',
|
|
message,
|
|
subject: { output: outputPath },
|
|
evidence: { reason: error.message },
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
let artifact;
|
|
try {
|
|
artifact = fs.readFileSync(candidatePath);
|
|
} catch (error) {
|
|
const message = `Could not read the verified delivery candidate: ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'receipt',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/candidate-unreadable',
|
|
message,
|
|
subject: { output: outputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
let sourceEvidence;
|
|
try {
|
|
sourceEvidence = sourceEvidenceFromArtifact(artifact);
|
|
} catch (error) {
|
|
const message = `Could not read the repository evidence receipt: ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'receipt',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/evidence-receipt-invalid',
|
|
message,
|
|
subject: { output: outputPath },
|
|
evidence: { reason: error.message },
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const engineeringProfile = engineeringProfileFromArtifact(artifact);
|
|
const receipt = {
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
command: 'deliver',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
specification: {
|
|
sha256: createHash('sha256').update(specification).digest('hex'),
|
|
bytes: specification.byteLength,
|
|
},
|
|
artifact: {
|
|
sha256: createHash('sha256').update(artifact).digest('hex'),
|
|
bytes: artifact.byteLength,
|
|
},
|
|
validation: {
|
|
checksPassed: result.checks.filter((checkItem) => checkItem.ok).length,
|
|
checkCount: result.checks.length,
|
|
compositionProfile: result.composition.profile,
|
|
compositionStatus: result.composition.status,
|
|
...(engineeringProfile ? { engineeringProfile } : {}),
|
|
errors: result.composition.summary.errors,
|
|
warnings: result.composition.summary.warnings,
|
|
},
|
|
...(sourceEvidence ? {
|
|
evidence: {
|
|
verified: true,
|
|
repository: sourceEvidence.repository.url,
|
|
revision: sourceEvidence.repository.revision,
|
|
references: sourceEvidence.referenceCount,
|
|
},
|
|
} : {}),
|
|
};
|
|
|
|
try {
|
|
resolveOutputPath({
|
|
requestedOutput,
|
|
authoredOutput,
|
|
defaultOutput: `${type}.html`,
|
|
inputPaths: [inputPath],
|
|
});
|
|
} catch (error) {
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'commit',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: error.message,
|
|
diagnostics: error.archifyDiagnostics || [diagnostic({
|
|
code: 'output/path-resolution',
|
|
message: error.message,
|
|
subject: { output: outputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
|
|
supportedFixes: ['restore a safe output path and retry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs.renameSync(candidatePath, outputPath);
|
|
} catch (error) {
|
|
const message = `Could not commit verified delivery "${outputPath}": ${error.message}`;
|
|
reportDeliveryFailure({
|
|
json,
|
|
stage: 'commit',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/commit',
|
|
message,
|
|
subject: { output: outputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
supportedFixes: ['choose a replaceable file target on the same writable filesystem'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (open) {
|
|
try {
|
|
const { openArtifact } = await import('./open-artifact.mjs');
|
|
receipt.open = openArtifact(outputPath);
|
|
} catch {
|
|
receipt.open = {
|
|
requested: true,
|
|
status: 'unsupported',
|
|
target: outputPath,
|
|
method: null,
|
|
};
|
|
}
|
|
if (receipt.open.status !== 'opened') {
|
|
console.error(`Could not open the verified artifact (${receipt.open.status}). Open it manually: ${outputPath}`);
|
|
}
|
|
}
|
|
|
|
if (json) {
|
|
console.log(JSON.stringify(receipt, null, 2));
|
|
} else {
|
|
console.log(`delivered ${type} ${outputPath}`);
|
|
const engineering = receipt.validation.engineeringProfile
|
|
? `; engineering ${receipt.validation.engineeringProfile}: pass`
|
|
: '';
|
|
console.log(`${receipt.validation.checksPassed}/${receipt.validation.checkCount} artifact checks; composition ${receipt.validation.compositionProfile}: ${receipt.validation.compositionStatus}${engineering}; sha256 ${receipt.artifact.sha256.slice(0, 12)}`);
|
|
if (receipt.open?.status === 'opened') console.log(`opened ${outputPath}`);
|
|
}
|
|
} finally {
|
|
try {
|
|
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error(`Warning: could not remove delivery staging directory "${stagingDirectory}": ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function commandPreview(args) {
|
|
const qualityArgs = extractQualityArgs(args);
|
|
const repoArgs = extractRepoRootArgs(qualityArgs.rest);
|
|
const noOpen = repoArgs.rest.includes('--no-open');
|
|
const knownOptions = new Set(['--no-open']);
|
|
const unknown = repoArgs.rest.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown.length) fail(`Unknown preview option "${unknown[0]}".`);
|
|
const positional = repoArgs.rest.filter((arg) => !knownOptions.has(arg));
|
|
const [type, input, output] = positional;
|
|
if (!type || !input || positional.length > 3) fail(usage());
|
|
assertEvidenceType(type, repoArgs.repoRoot);
|
|
rendererPath(type);
|
|
|
|
let runPreview;
|
|
try {
|
|
({ runPreview } = await import('./preview.mjs'));
|
|
} catch (error) {
|
|
fail(`Could not load live preview: ${error.message}`, 1);
|
|
}
|
|
try {
|
|
await runPreview({
|
|
type,
|
|
input,
|
|
output,
|
|
quality: qualityArgs.quality,
|
|
repoRoot: repoArgs.repoRoot,
|
|
open: !noOpen,
|
|
});
|
|
} catch (error) {
|
|
fail(`Could not start live preview: ${error.message}`, 1);
|
|
}
|
|
}
|
|
|
|
function commandCheck(args) {
|
|
const [html] = args;
|
|
if (!html) fail(usage());
|
|
const result = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), html]);
|
|
if (result.status !== 0) exitFrom(result);
|
|
}
|
|
|
|
async function commandVisualCheck(args) {
|
|
const json = args.includes('--json');
|
|
const knownOptions = new Set(['--json']);
|
|
const unknown = args.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown.length) fail(`Unknown visual-check option "${unknown[0]}".`, 1);
|
|
const positional = args.filter((arg) => !knownOptions.has(arg));
|
|
if (positional.length !== 1) fail(usage(), 1);
|
|
|
|
let runVisualCheck;
|
|
try {
|
|
({ runVisualCheck } = await import('./visual-check.mjs'));
|
|
} catch (error) {
|
|
fail(`Could not load visual-check: ${error.message}`, 1);
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await runVisualCheck({ artifactPath: positional[0] });
|
|
} catch (error) {
|
|
if (json) {
|
|
console.log(JSON.stringify({
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command: 'visual-check',
|
|
status: 'fail',
|
|
visualReview: 'pending',
|
|
artifact: { path: path.resolve(positional[0]) },
|
|
error: error.message,
|
|
}, null, 2));
|
|
} else {
|
|
console.error(`visual-check failed: ${error.message}`);
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (json) {
|
|
console.log(JSON.stringify(result.receipt, null, 2));
|
|
} else {
|
|
console.log(`visual-check ${result.receipt.status}: ${result.receipt.artifact.path}`);
|
|
console.log(`containment ${result.receipt.containment.status}; captures ${result.receipt.captures.status}; visual review pending`);
|
|
console.log(`receipt ${path.join(path.dirname(result.receipt.artifact.path), result.receipt.sidecars.receipt)}`);
|
|
if (result.receipt.captures.contactSheet) {
|
|
console.log(`contact sheet ${path.join(path.dirname(result.receipt.artifact.path), result.receipt.captures.contactSheet)}`);
|
|
}
|
|
if (result.receipt.error) console.error(result.receipt.error);
|
|
}
|
|
process.exitCode = result.exitCode;
|
|
}
|
|
|
|
function commandExamples() {
|
|
const result = runNode([path.join(skillRoot, 'scripts/render-examples.mjs')], { cwd: skillRoot });
|
|
if (result.status !== 0) exitFrom(result);
|
|
}
|
|
|
|
async function commandDoctor() {
|
|
const checks = [];
|
|
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
|
|
checks.push({
|
|
label: `Node.js v${process.versions.node} (requires >=18)`,
|
|
ok: nodeMajor >= 18,
|
|
missing: 0,
|
|
failureLabel: 'unsupported',
|
|
});
|
|
|
|
const template = path.join(skillRoot, 'assets/template.html');
|
|
checks.push({
|
|
label: 'Core template',
|
|
ok: fs.existsSync(template),
|
|
missing: fs.existsSync(template) ? 0 : 1,
|
|
});
|
|
|
|
const examplesRenderer = path.join(skillRoot, 'scripts/render-examples.mjs');
|
|
checks.push({
|
|
label: 'Example renderer',
|
|
ok: fs.existsSync(examplesRenderer),
|
|
missing: fs.existsSync(examplesRenderer) ? 0 : 1,
|
|
});
|
|
|
|
const previewRuntime = path.join(skillRoot, 'bin/preview.mjs');
|
|
checks.push({
|
|
label: 'Live preview runtime',
|
|
ok: fs.existsSync(previewRuntime),
|
|
missing: fs.existsSync(previewRuntime) ? 0 : 1,
|
|
});
|
|
|
|
const visualCheckRuntime = path.join(skillRoot, 'bin/visual-check.mjs');
|
|
checks.push({
|
|
label: 'Visual-check runtime',
|
|
ok: fs.existsSync(visualCheckRuntime),
|
|
missing: fs.existsSync(visualCheckRuntime) ? 0 : 1,
|
|
});
|
|
|
|
const outputPathRuntime = path.join(skillRoot, 'renderers/shared/output-path.mjs');
|
|
checks.push({
|
|
label: 'Output path safety runtime',
|
|
ok: fs.existsSync(outputPathRuntime),
|
|
missing: fs.existsSync(outputPathRuntime) ? 0 : 1,
|
|
});
|
|
|
|
const scenarioGuide = path.join(skillRoot, 'recipes/scenarios.mjs');
|
|
checks.push({
|
|
label: 'Scenario recipe guide',
|
|
ok: fs.existsSync(scenarioGuide),
|
|
missing: fs.existsSync(scenarioGuide) ? 0 : 1,
|
|
});
|
|
|
|
const authoringReferences = [
|
|
path.join(skillRoot, 'references', 'authoring-contract.md'),
|
|
path.join(skillRoot, 'references', 'viewer-runtime.md'),
|
|
path.join(skillRoot, 'references', 'delivery-contract.md'),
|
|
];
|
|
const authoringReferencesMissing = authoringReferences.filter((file) => !fs.existsSync(file)).length;
|
|
checks.push({
|
|
label: 'Progressive authoring references',
|
|
ok: authoringReferencesMissing === 0,
|
|
missing: authoringReferencesMissing,
|
|
});
|
|
|
|
const compareRuntime = path.join(skillRoot, 'delta/architecture-delta.mjs');
|
|
const compareFixtures = [
|
|
path.join(skillRoot, 'examples/checkout-platform.base.architecture.json'),
|
|
path.join(skillRoot, 'examples/checkout-platform.head.architecture.json'),
|
|
];
|
|
const compareMissing = [compareRuntime, ...compareFixtures].filter((file) => !fs.existsSync(file)).length;
|
|
checks.push({
|
|
label: 'Architecture compare runtime and proof fixtures',
|
|
ok: compareMissing === 0,
|
|
missing: compareMissing,
|
|
});
|
|
|
|
const validators = path.join(skillRoot, 'renderers/shared/generated-validators.mjs');
|
|
const validatorsExist = fs.existsSync(validators);
|
|
let validatorsValid = false;
|
|
if (validatorsExist) {
|
|
try {
|
|
const module = await import(`${pathToFileURL(validators).href}?doctor=${Date.now()}`);
|
|
validatorsValid = [...TYPES].every((type) => typeof module[type] === 'function');
|
|
} catch {
|
|
validatorsValid = false;
|
|
}
|
|
}
|
|
checks.push({
|
|
label: 'Standalone schema validators',
|
|
ok: validatorsValid,
|
|
missing: validatorsExist ? 0 : 1,
|
|
invalid: validatorsExist && !validatorsValid ? 1 : 0,
|
|
failureLabel: validatorsExist ? 'invalid' : 'missing',
|
|
});
|
|
|
|
const examples = {
|
|
architecture: 'web-app.architecture.json',
|
|
workflow: 'agent-tool-call.workflow.json',
|
|
sequence: 'cache-miss-request.sequence.json',
|
|
dataflow: 'product-analytics.dataflow.json',
|
|
lifecycle: 'agent-run.lifecycle.json',
|
|
};
|
|
|
|
for (const type of TYPES) {
|
|
const required = [
|
|
path.join(skillRoot, 'renderers', type, `render-${type}.mjs`),
|
|
path.join(skillRoot, 'schemas', `${type}.schema.json`),
|
|
path.join(skillRoot, 'examples', examples[type]),
|
|
];
|
|
const missing = required.filter((file) => !fs.existsSync(file)).length;
|
|
checks.push({
|
|
label: `${type} renderer, schema, and example`,
|
|
ok: missing === 0,
|
|
missing,
|
|
});
|
|
}
|
|
|
|
console.log('Archify doctor\n');
|
|
for (const check of checks) {
|
|
console.log(`[${check.ok ? 'ok' : (check.failureLabel || 'missing')}] ${check.label}`);
|
|
}
|
|
|
|
const nodeFailed = checks[0].ok ? 0 : 1;
|
|
const missingFiles = checks.reduce((count, check) => count + check.missing, 0);
|
|
const invalidRuntime = checks.reduce((count, check) => count + (check.invalid || 0), 0);
|
|
if (nodeFailed === 0 && missingFiles === 0 && invalidRuntime === 0) {
|
|
console.log('\nArchify is ready.');
|
|
return;
|
|
}
|
|
|
|
const problems = [];
|
|
if (nodeFailed) problems.push('Node.js 18 or newer is required');
|
|
if (missingFiles) problems.push(`${missingFiles} required file${missingFiles === 1 ? '' : 's'} missing`);
|
|
if (invalidRuntime) problems.push(`${invalidRuntime} runtime check${invalidRuntime === 1 ? '' : 's'} failed`);
|
|
console.error(`\nArchify is not ready: ${problems.join('; ')}.`);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
async function commandGuide(args) {
|
|
let lang;
|
|
let json = false;
|
|
const queryParts = [];
|
|
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--json') {
|
|
json = true;
|
|
} else if (arg === '--lang') {
|
|
const value = args[index + 1];
|
|
if (value !== 'en' && value !== 'zh') fail('--lang must be "en" or "zh".');
|
|
lang = value;
|
|
index += 1;
|
|
} else if (arg.startsWith('--lang=')) {
|
|
const value = arg.slice('--lang='.length);
|
|
if (value !== 'en' && value !== 'zh') fail('--lang must be "en" or "zh".');
|
|
lang = value;
|
|
} else if (arg.startsWith('--')) {
|
|
fail(`Unknown guide option "${arg}".`);
|
|
} else {
|
|
queryParts.push(arg);
|
|
}
|
|
}
|
|
|
|
const guidePath = path.join(skillRoot, 'recipes/scenarios.mjs');
|
|
let guide;
|
|
try {
|
|
guide = await import(pathToFileURL(guidePath).href);
|
|
} catch (error) {
|
|
fail(`Could not load the scenario recipe guide: ${error.message}`, 1);
|
|
}
|
|
|
|
const query = queryParts.join(' ').trim();
|
|
if (!query) {
|
|
const selectedLang = lang || 'en';
|
|
if (json) {
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
mode: 'list',
|
|
lang: selectedLang,
|
|
recipes: guide.listScenarioRecipes(selectedLang),
|
|
}, null, 2));
|
|
} else {
|
|
console.log(guide.formatScenarioList(selectedLang));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const result = guide.recommendScenario(query, lang ? { lang } : {});
|
|
console.log(json ? JSON.stringify(result, null, 2) : guide.formatScenarioRecommendation(result));
|
|
}
|
|
|
|
async function commandBrands(args) {
|
|
const json = args.includes('--json');
|
|
const unknown = args.filter((arg) => arg.startsWith('--') && arg !== '--json');
|
|
if (unknown.length) fail(`Unknown brands option "${unknown[0]}".`);
|
|
const positional = args.filter((arg) => arg !== '--json');
|
|
if (positional[0] === 'capture') {
|
|
if (positional.length !== 2) fail('Usage: archify brands capture <url> [--json]');
|
|
const { captureBrandReference } = await import('../renderers/shared/brand-marks.mjs');
|
|
let capture;
|
|
try {
|
|
capture = await captureBrandReference(positional[1]);
|
|
} catch (error) {
|
|
fail(error.message);
|
|
}
|
|
const result = {
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
command: 'brands capture',
|
|
brand: capture.brand,
|
|
evidence: {
|
|
status: capture.resolved.status,
|
|
source: capture.resolved.sourceUrl,
|
|
...(capture.resolved.sha256 ? { sha256: capture.resolved.sha256 } : {}),
|
|
...(capture.resolved.contentType ? { contentType: capture.resolved.contentType } : {}),
|
|
},
|
|
};
|
|
console.log(json ? JSON.stringify(result, null, 2) : JSON.stringify(result.brand));
|
|
return;
|
|
}
|
|
const query = positional.join(' ').trim();
|
|
const { listBrandMarks } = await import('../renderers/shared/brand-marks.mjs');
|
|
const marks = listBrandMarks(query);
|
|
if (json) {
|
|
console.log(JSON.stringify({
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
command: 'brands',
|
|
query,
|
|
count: marks.length,
|
|
marks,
|
|
fallback: 'Run "archify brands capture <url> --json", then use the returned digest-pinned brand value.',
|
|
}, null, 2));
|
|
return;
|
|
}
|
|
if (!marks.length) {
|
|
console.log(`No built-in brand matched "${query}". Run "archify brands capture <url> --json", then use the returned digest-pinned brand value.`);
|
|
return;
|
|
}
|
|
const grouped = Map.groupBy
|
|
? Map.groupBy(marks, (mark) => mark.category)
|
|
: marks.reduce((map, mark) => map.set(mark.category, [...(map.get(mark.category) || []), mark]), new Map());
|
|
for (const [category, entries] of grouped) {
|
|
console.log(`${category}: ${entries.map((mark) => mark.id).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
function commandDemo(args) {
|
|
if (args.length > 1) fail(usage());
|
|
|
|
const outputDirectory = path.resolve(args[0] || process.cwd());
|
|
const output = path.join(outputDirectory, 'archify-demo.html');
|
|
const input = path.join(skillRoot, 'examples/web-app.architecture.json');
|
|
|
|
try {
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
} catch (error) {
|
|
fail(`Could not create demo directory "${outputDirectory}": ${error.message}`, 1);
|
|
}
|
|
|
|
const result = runNode([rendererPath('architecture'), input, output]);
|
|
if (result.status !== 0) exitFrom(result);
|
|
|
|
console.log(`\nDemo ready: ${output}`);
|
|
console.log('Next: open the HTML in your browser, then render your own diagram:');
|
|
console.log(' archify render architecture <input.json> <output.html>');
|
|
}
|
|
|
|
function migrationPathDiagnostics(error, sourcePath, destinationPath) {
|
|
if (Array.isArray(error?.archifyDiagnostics) && error.archifyDiagnostics.length) {
|
|
return error.archifyDiagnostics.map((entry) => ({
|
|
...entry,
|
|
subject: { ...(entry.subject || {}) },
|
|
evidence: { ...(entry.evidence || {}) },
|
|
supportedFixes: [...(entry.supportedFixes || [])],
|
|
}));
|
|
}
|
|
return [diagnostic({
|
|
code: 'migration/path-preflight',
|
|
message: 'Could not verify that the workflow migration paths are distinct.',
|
|
subject: { source: sourcePath, destination: destinationPath },
|
|
evidence: {
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
reason: error?.message || String(error),
|
|
},
|
|
supportedFixes: ['remove unsafe path aliases or choose a different destination path'],
|
|
})];
|
|
}
|
|
|
|
function migrationReport({
|
|
ok,
|
|
sourcePath,
|
|
destinationPath,
|
|
sourceBytes,
|
|
destinationBytes,
|
|
fromSchemaVersion,
|
|
preExistingDiagnostics = [],
|
|
migrationDiagnostics = [],
|
|
newSchemaDiagnostics = [],
|
|
changedCoordinates = [],
|
|
oldRequiredViewBox = null,
|
|
newRequiredViewBox = null,
|
|
}) {
|
|
const report = {
|
|
ok,
|
|
command: 'migrate',
|
|
type: 'workflow',
|
|
source: {
|
|
path: sourcePath,
|
|
...(sourceBytes ? {
|
|
sha256: createHash('sha256').update(sourceBytes).digest('hex'),
|
|
bytes: sourceBytes.length,
|
|
} : {}),
|
|
},
|
|
destination: {
|
|
path: destinationPath,
|
|
...(destinationBytes ? {
|
|
sha256: createHash('sha256').update(destinationBytes).digest('hex'),
|
|
bytes: destinationBytes.length,
|
|
} : {}),
|
|
},
|
|
fromSchemaVersion: fromSchemaVersion ?? null,
|
|
toSchemaVersion: 2,
|
|
preExistingDiagnostics,
|
|
migrationDiagnostics,
|
|
newSchemaDiagnostics,
|
|
changedCoordinates,
|
|
oldRequiredViewBox,
|
|
newRequiredViewBox,
|
|
};
|
|
if (!ok) {
|
|
report.diagnostics = [
|
|
...migrationDiagnostics,
|
|
...newSchemaDiagnostics,
|
|
...preExistingDiagnostics,
|
|
];
|
|
if (!report.diagnostics.length) {
|
|
report.diagnostics.push(diagnostic({
|
|
code: 'migration/internal',
|
|
message: 'Workflow migration failed without a classified diagnostic.',
|
|
}));
|
|
}
|
|
report.error = report.diagnostics[0].message;
|
|
}
|
|
return report;
|
|
}
|
|
|
|
function extractMigrationOptions(args) {
|
|
const positional = [];
|
|
let json = false;
|
|
let toSchema;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--json') {
|
|
json = true;
|
|
continue;
|
|
}
|
|
if (arg === '--to-schema') {
|
|
toSchema = args[index + 1];
|
|
if (!toSchema || toSchema.startsWith('--')) fail('--to-schema requires a schema version.');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--to-schema=')) {
|
|
toSchema = arg.slice('--to-schema='.length);
|
|
if (!toSchema) fail('--to-schema requires a schema version.');
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--')) fail(`Unknown migrate option "${arg}".`);
|
|
positional.push(arg);
|
|
}
|
|
return { positional, json, toSchema };
|
|
}
|
|
|
|
async function commandMigrate(args) {
|
|
const options = extractMigrationOptions(args);
|
|
const [type, sourceArgument, destinationArgument] = options.positional;
|
|
if (
|
|
type !== 'workflow'
|
|
|| !sourceArgument
|
|
|| !destinationArgument
|
|
|| options.positional.length !== 3
|
|
|| options.toSchema !== '2'
|
|
) {
|
|
fail('Usage: archify migrate workflow <old.json> <new.json> --to-schema 2 [--json]');
|
|
}
|
|
|
|
const sourcePath = path.resolve(sourceArgument);
|
|
const destinationPath = path.resolve(destinationArgument);
|
|
let sourceBytes;
|
|
let sourceDocument;
|
|
const reportMigrationFailure = ({ status = 1, ...details }) => {
|
|
const report = migrationReport({
|
|
...details,
|
|
ok: false,
|
|
sourcePath,
|
|
destinationPath,
|
|
sourceBytes,
|
|
fromSchemaVersion: sourceDocument?.schema_version,
|
|
});
|
|
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
else console.error(formatDiagnostics(report.error, report.diagnostics));
|
|
process.exitCode = status;
|
|
};
|
|
try {
|
|
sourceBytes = fs.readFileSync(sourcePath);
|
|
sourceDocument = JSON.parse(sourceBytes.toString('utf8'));
|
|
} catch (error) {
|
|
reportMigrationFailure({
|
|
preExistingDiagnostics: [inputDiagnostic(error, sourcePath)],
|
|
});
|
|
return;
|
|
}
|
|
// Unlike render/validate, migrate has no --quality override. Pin every stage
|
|
// to the document's durable policy and scrub any ambient profile from the
|
|
// staged renderer by passing this value explicitly.
|
|
const activeQualityProfile = sourceDocument?.meta?.quality_profile || 'standard';
|
|
|
|
const { pathsAlias } = await import('../renderers/shared/output-path.mjs');
|
|
let sourceDestinationAlias;
|
|
try {
|
|
sourceDestinationAlias = pathsAlias(sourcePath, destinationPath);
|
|
} catch (error) {
|
|
reportMigrationFailure({
|
|
migrationDiagnostics: migrationPathDiagnostics(error, sourcePath, destinationPath),
|
|
});
|
|
return;
|
|
}
|
|
if (sourceDestinationAlias) {
|
|
reportMigrationFailure({
|
|
migrationDiagnostics: [diagnostic({
|
|
code: 'migration/source-destination',
|
|
message: 'Workflow migration source and destination must be different files.',
|
|
subject: { source: sourcePath, destination: destinationPath },
|
|
supportedFixes: ['choose a different destination path and keep the source unchanged'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { migrateWorkflowDocument, serializeMigratedWorkflow } = await import('../migrations/workflow-v2.mjs');
|
|
let migration;
|
|
try {
|
|
migration = migrateWorkflowDocument(sourceDocument);
|
|
} catch (error) {
|
|
migration = {
|
|
ok: false,
|
|
migrationDiagnostics: [diagnostic({
|
|
code: 'migration/internal',
|
|
message: 'Workflow migration failed unexpectedly.',
|
|
evidence: { reason: error.message },
|
|
supportedFixes: ['report the source workflow and this diagnostic to the Archify maintainers'],
|
|
})],
|
|
};
|
|
}
|
|
|
|
if (!migration.ok) {
|
|
reportMigrationFailure(migration);
|
|
return;
|
|
}
|
|
|
|
if (fs.existsSync(destinationPath) && !fs.lstatSync(destinationPath).isFile()) {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-type',
|
|
message: 'Workflow migration destination must be a regular file path.',
|
|
subject: { destination: destinationPath },
|
|
supportedFixes: ['choose a destination path that is absent or names a regular file'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const destinationDirectory = path.dirname(destinationPath);
|
|
let stagingDirectory;
|
|
try {
|
|
fs.mkdirSync(destinationDirectory, { recursive: true });
|
|
stagingDirectory = fs.mkdtempSync(path.join(destinationDirectory, '.archify-migration-'));
|
|
} catch (error) {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/prepare-destination',
|
|
message: 'Could not prepare the workflow migration destination.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
supportedFixes: ['choose a writable destination directory'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const candidatePath = path.join(stagingDirectory, 'candidate.workflow.json');
|
|
const artifactPath = path.join(stagingDirectory, 'migration-check.html');
|
|
const destinationBytes = Buffer.from(serializeMigratedWorkflow(migration.document));
|
|
try {
|
|
fs.writeFileSync(candidatePath, destinationBytes, { flag: 'wx' });
|
|
const render = runNode([rendererPath('workflow'), candidatePath, artifactPath], {
|
|
stdio: 'pipe',
|
|
env: rendererEnv(activeQualityProfile, undefined, true),
|
|
});
|
|
if (render.status !== 0) {
|
|
const failure = rendererFailure(render);
|
|
reportMigrationFailure({
|
|
...migration,
|
|
newSchemaDiagnostics: [...migration.newSchemaDiagnostics, ...failure.diagnostics],
|
|
status: render.status ?? 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const check = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), artifactPath], {
|
|
stdio: 'pipe',
|
|
});
|
|
if (check.status !== 0) {
|
|
let checker;
|
|
try {
|
|
checker = JSON.parse(check.stdout);
|
|
} catch {
|
|
checker = null;
|
|
}
|
|
reportMigrationFailure({
|
|
...migration,
|
|
newSchemaDiagnostics: [
|
|
...migration.newSchemaDiagnostics,
|
|
...checkerDiagnostics(checker),
|
|
],
|
|
status: check.status ?? 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (pathsAlias(sourcePath, destinationPath)) {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/source-destination',
|
|
message: 'Workflow migration source and destination resolved to the same file before commit.',
|
|
subject: { source: sourcePath, destination: destinationPath },
|
|
supportedFixes: ['choose a different destination path and retry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const currentSourceBytes = fs.readFileSync(sourcePath);
|
|
if (!currentSourceBytes.equals(sourceBytes)) {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/source-changed',
|
|
message: 'Workflow migration source changed while the destination was being verified.',
|
|
subject: { source: sourcePath },
|
|
supportedFixes: ['retry the migration from a stable workflow source file'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
fs.renameSync(candidatePath, destinationPath);
|
|
const report = migrationReport({
|
|
...migration,
|
|
sourcePath,
|
|
destinationPath,
|
|
sourceBytes,
|
|
destinationBytes,
|
|
fromSchemaVersion: sourceDocument.schema_version,
|
|
});
|
|
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
else if (sourceDocument.schema_version === 1) {
|
|
console.log(`migrated workflow schema v1→v2: ${sourcePath} → ${destinationPath}`);
|
|
} else {
|
|
console.log(`verified workflow schema v2 migration: ${sourcePath} → ${destinationPath}`);
|
|
}
|
|
} catch (error) {
|
|
const migrationDiagnostics = Array.isArray(error?.archifyDiagnostics)
|
|
? migrationPathDiagnostics(error, sourcePath, destinationPath)
|
|
: [diagnostic({
|
|
code: 'migration/commit',
|
|
message: 'Could not commit the verified workflow migration.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}), reason: error.message },
|
|
supportedFixes: ['choose a writable regular-file destination and retry'],
|
|
})];
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, ...migrationDiagnostics],
|
|
});
|
|
} finally {
|
|
try {
|
|
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
} catch (error) {
|
|
console.error(`Warning: could not remove workflow migration staging directory "${stagingDirectory}": ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function commandValidate(args) {
|
|
const qualityArgs = extractQualityArgs(args);
|
|
const repoArgs = extractRepoRootArgs(qualityArgs.rest);
|
|
args = repoArgs.rest;
|
|
const quality = qualityArgs.quality;
|
|
const repoRoot = repoArgs.repoRoot;
|
|
const knownOptions = new Set(['--json', '--layout-json']);
|
|
const unknown = args.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown.length) fail(`Unknown validate option "${unknown[0]}".`);
|
|
const json = args.includes('--json');
|
|
const layoutJson = args.includes('--layout-json');
|
|
const rest = args.filter((arg) => !knownOptions.has(arg));
|
|
const [type, input] = rest;
|
|
if (!type || !input || rest.length !== 2) fail(usage());
|
|
assertEvidenceType(type, repoRoot);
|
|
const renderer = rendererPath(type);
|
|
|
|
if (layoutJson) {
|
|
if (!['architecture', 'workflow'].includes(type)) {
|
|
fail('--layout-json is currently supported for architecture and workflow diagrams only.');
|
|
}
|
|
const result = runNode([renderer, input, '/dev/null', '--layout-json'], {
|
|
stdio: 'pipe',
|
|
env: rendererEnv(quality, repoRoot, true),
|
|
});
|
|
if (result.status !== 0) {
|
|
try {
|
|
const receipt = JSON.parse(result.stdout);
|
|
if (receipt?.contract && Array.isArray(receipt.diagnostics)) {
|
|
process.stdout.write(`${JSON.stringify(receipt, null, 2)}\n`);
|
|
process.exitCode = result.status ?? 1;
|
|
return;
|
|
}
|
|
} catch {
|
|
// Fall through to the renderer failure contract when no compiler
|
|
// receipt was produced (for example, input JSON could not be read).
|
|
}
|
|
const failure = rendererFailure(result);
|
|
reportValidateFailure({
|
|
json,
|
|
stage: failure.diagnostics.some((entry) => entry.code.startsWith('input/')) ? 'input' : 'render',
|
|
type,
|
|
input: path.resolve(input),
|
|
error: failure.error,
|
|
diagnostics: failure.diagnostics,
|
|
status: result.status ?? 1,
|
|
});
|
|
return;
|
|
}
|
|
process.stdout.write(result.stdout);
|
|
return;
|
|
}
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-validate-'));
|
|
const out = path.join(tmp, `${type}.html`);
|
|
let exitCode = 0;
|
|
|
|
try {
|
|
const render = runNode([renderer, input, out], {
|
|
stdio: 'pipe',
|
|
env: rendererEnv(quality, repoRoot, true),
|
|
});
|
|
if (render.status !== 0) {
|
|
const failure = rendererFailure(render);
|
|
reportValidateFailure({
|
|
json,
|
|
stage: failure.diagnostics.some((entry) => entry.code.startsWith('input/')) ? 'input' : 'render',
|
|
type,
|
|
input: path.resolve(input),
|
|
error: failure.error,
|
|
diagnostics: failure.diagnostics,
|
|
status: render.status ?? 1,
|
|
});
|
|
exitCode = render.status ?? 1;
|
|
} else {
|
|
const check = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), out], { stdio: 'pipe' });
|
|
if (check.status !== 0) {
|
|
let checker;
|
|
try {
|
|
checker = JSON.parse(check.stdout);
|
|
checker.file = path.resolve(input);
|
|
} catch {
|
|
checker = { ok: false, diagnostic: 'Artifact checker failed without a parseable receipt.' };
|
|
}
|
|
reportValidateFailure({
|
|
json,
|
|
stage: 'check',
|
|
type,
|
|
input: path.resolve(input),
|
|
error: 'Final artifact check failed.',
|
|
diagnostics: checkerDiagnostics(checker),
|
|
checker,
|
|
status: check.status ?? 1,
|
|
});
|
|
exitCode = check.status ?? 1;
|
|
} else {
|
|
const result = JSON.parse(check.stdout);
|
|
const engineeringProfile = engineeringProfileFromArtifact(fs.readFileSync(out));
|
|
if (json) {
|
|
console.log(JSON.stringify({
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
command: 'validate',
|
|
type,
|
|
input: path.resolve(input),
|
|
checks: result.checks,
|
|
composition: result.composition,
|
|
...(engineeringProfile ? { engineeringProfile } : {}),
|
|
}, null, 2));
|
|
} else {
|
|
const engineering = engineeringProfile
|
|
? `; engineering ${engineeringProfile}: pass`
|
|
: '';
|
|
console.log(`ok ${type} ${path.resolve(input)} (${result.checks.length} artifact checks; composition ${result.composition.profile}: ${result.composition.summary.errors} errors, ${result.composition.summary.warnings} warnings${engineering})`);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
}
|
|
|
|
const [command, ...args] = process.argv.slice(2);
|
|
|
|
switch (command) {
|
|
case undefined:
|
|
case '-h':
|
|
case '--help':
|
|
case 'help':
|
|
console.log(usage());
|
|
break;
|
|
case 'render':
|
|
commandRender(args);
|
|
break;
|
|
case 'compare':
|
|
await commandCompare(args);
|
|
break;
|
|
case 'deliver':
|
|
await commandDeliver(args);
|
|
break;
|
|
case 'preview':
|
|
await commandPreview(args);
|
|
break;
|
|
case 'validate':
|
|
commandValidate(args);
|
|
break;
|
|
case 'migrate':
|
|
await commandMigrate(args);
|
|
break;
|
|
case 'inspect':
|
|
if (args[0] !== 'architecture') {
|
|
fail('inspect is currently supported for architecture diagrams only.');
|
|
}
|
|
commandValidate([...args, '--layout-json']);
|
|
break;
|
|
case 'check':
|
|
commandCheck(args);
|
|
break;
|
|
case 'visual-check':
|
|
await commandVisualCheck(args);
|
|
break;
|
|
case 'guide':
|
|
await commandGuide(args);
|
|
break;
|
|
case 'brands':
|
|
await commandBrands(args);
|
|
break;
|
|
case 'examples':
|
|
commandExamples();
|
|
break;
|
|
case 'doctor':
|
|
await commandDoctor();
|
|
break;
|
|
case 'demo':
|
|
commandDemo(args);
|
|
break;
|
|
default:
|
|
fail(`Unknown command "${command}".\n\n${usage()}`);
|
|
}
|