import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import assert from 'node:assert/strict';
import { startPreview } from '../bin/preview.mjs';
import { loadDiagram, writeDiagram } from '../renderers/shared/cli.mjs';
import { pathsAlias } from '../renderers/shared/output-path.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
const skillRoot = path.resolve(here, '..');
const cli = path.join(skillRoot, 'bin/archify.mjs');
const workflowFixture = path.join(skillRoot, 'examples/agent-tool-call.workflow.json');
const baseFixture = path.join(skillRoot, 'examples/checkout-platform.base.architecture.json');
const headFixture = path.join(skillRoot, 'examples/checkout-platform.head.architecture.json');
function run(args, cwd) {
return spawnSync(process.execPath, [cli, ...args], {
cwd,
encoding: 'utf8',
});
}
function copyInstalledSkill(target) {
fs.cpSync(skillRoot, target, {
recursive: true,
filter(source) {
const relative = path.relative(skillRoot, source);
return relative !== 'node_modules'
&& !relative.startsWith(`node_modules${path.sep}`)
&& relative !== 'test'
&& !relative.startsWith(`test${path.sep}`);
},
});
}
function directoryAliasesNames(directory, authoredName, lookupName) {
const authoredPath = path.join(directory, authoredName);
const lookupPath = path.join(directory, lookupName);
fs.writeFileSync(authoredPath, 'filesystem semantics probe', { flag: 'wx' });
try {
let authored;
let lookup;
try {
authored = fs.statSync(authoredPath);
lookup = fs.statSync(lookupPath);
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
return authored.dev === lookup.dev && authored.ino === lookup.ino;
} finally {
fs.unlinkSync(authoredPath);
}
}
test('future-path aliases follow the containing directory case and Unicode semantics', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-semantics-'));
const caseInsensitive = directoryAliasesNames(cwd, 'ArchifyCaseProbe', 'archifycaseprobe');
const normalizationInsensitive = directoryAliasesNames(
cwd,
'archify-norm-\u00e9-probe',
'archify-norm-e\u0301-probe',
);
assert.equal(
pathsAlias(path.join(cwd, 'Future.HTML'), path.join(cwd, 'future.html')),
caseInsensitive,
);
assert.equal(
pathsAlias(path.join(cwd, 'Caf\u00e9.html'), path.join(cwd, 'Cafe\u0301.html')),
normalizationInsensitive,
);
});
test('compare rejects case-only future targets before input work when the directory aliases case', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-case-'));
const caseInsensitive = directoryAliasesNames(cwd, 'ArchifyCaseProbe', 'archifycaseprobe');
const output = path.join(cwd, 'Future.HTML');
const receiptPath = path.join(cwd, 'future.html');
const result = run([
'compare', 'architecture',
path.join(cwd, 'missing-base.json'),
path.join(cwd, 'missing-head.json'),
output,
'--receipt', receiptPath,
'--json',
], cwd);
assert.equal(result.status, 1);
const receipt = JSON.parse(result.stdout);
assert.equal(
receipt.diagnostics[0].code,
caseInsensitive ? 'output/target-alias' : 'delta/base-input',
);
assert.equal(receipt.stage, caseInsensitive ? 'prepare' : 'input');
});
test('render reports an output symlink cycle as a structured output diagnostic', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-cycle-'));
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(cwd, 'cycle-a.html');
const otherLink = path.join(cwd, 'cycle-b.html');
fs.copyFileSync(workflowFixture, input);
fs.symlinkSync(otherLink, output, 'file');
fs.symlinkSync(output, otherLink, 'file');
const result = spawnSync(
process.execPath,
[path.join(skillRoot, 'renderers/workflow/render-workflow.mjs'), input, output],
{
cwd,
encoding: 'utf8',
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: 'json' },
},
);
assert.equal(result.status, 1);
const failure = JSON.parse(result.stderr);
assert.equal(failure.diagnostics[0].code, 'output/symlink-cycle');
assert.equal(failure.diagnostics[0].subject.output, output);
assert.ok(failure.diagnostics[0].supportedFixes.length > 0);
});
test('render rejects an output symlink that aliases its JSON input', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-'));
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(cwd, 'diagram.html');
const source = fs.readFileSync(workflowFixture);
fs.writeFileSync(input, source);
fs.symlinkSync(input, output, 'file');
const result = run(['render', 'workflow', input, output], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /output must not replace an input/i);
assert.deepEqual(fs.readFileSync(input), source);
});
test('render rejects an existing output hard link to its JSON input', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-hardlink-'));
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(cwd, 'diagram.html');
const source = fs.readFileSync(workflowFixture);
fs.writeFileSync(input, source);
fs.linkSync(input, output);
const result = run(['render', 'workflow', input, output], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /output must not replace an input/i);
assert.deepEqual(fs.readFileSync(input), source);
});
test('render rejects an absolute meta.output when no CLI output is provided', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-absolute-'));
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(cwd, 'authored.html');
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
source.meta.output = output;
fs.writeFileSync(input, JSON.stringify(source));
const result = run(['render', 'workflow', input], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /meta\.output must be a relative path/i);
assert.equal(fs.existsSync(output), false);
});
test('render rejects a relative meta.output that escapes the working directory', () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-parent-'));
const cwd = path.join(parent, 'work');
fs.mkdirSync(cwd);
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(parent, 'escaped.html');
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
source.meta.output = '../escaped.html';
fs.writeFileSync(input, JSON.stringify(source));
const result = run(['render', 'workflow', input], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /meta\.output must stay inside the current working directory/i);
assert.equal(fs.existsSync(output), false);
});
test('render rejects a meta.output that escapes through a directory symlink', () => {
const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-link-'));
const cwd = path.join(parent, 'work');
const outside = path.join(parent, 'outside');
fs.mkdirSync(cwd);
fs.mkdirSync(outside);
fs.symlinkSync(outside, path.join(cwd, 'linked'), 'dir');
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(outside, 'authored.html');
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
source.meta.output = 'linked/authored.html';
fs.writeFileSync(input, JSON.stringify(source));
const result = run(['render', 'workflow', input], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /meta\.output must stay inside the current working directory/i);
assert.equal(fs.existsSync(output), false);
});
test('render requires a meta.output target with an html extension', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-extension-'));
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(cwd, 'authored.json');
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
source.meta.output = 'authored.json';
fs.writeFileSync(input, JSON.stringify(source));
const result = run(['render', 'workflow', input], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /meta\.output must target an? \.html file/i);
assert.equal(fs.existsSync(output), false);
});
test('render rejects a meta.output symlink that resolves to a non-html target', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-meta-extension-link-'));
const input = path.join(cwd, 'diagram.workflow.json');
const target = path.join(cwd, 'authored.json');
const output = path.join(cwd, 'authored.html');
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
source.meta.output = 'authored.html';
fs.writeFileSync(input, JSON.stringify(source));
fs.writeFileSync(target, 'trusted target');
fs.symlinkSync(target, output, 'file');
const result = run(['render', 'workflow', input], cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /meta\.output must resolve to an? \.html file/i);
assert.equal(fs.readFileSync(target, 'utf8'), 'trusted target');
});
test('deliver rejects a future-path alias of its JSON input with a structured diagnostic', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-deliver-'));
const realDirectory = path.join(cwd, 'real');
const linkedDirectory = path.join(cwd, 'linked');
fs.mkdirSync(realDirectory);
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
const input = path.join(realDirectory, 'diagram.workflow.json');
const output = path.join(linkedDirectory, 'diagram.workflow.json');
const source = fs.readFileSync(workflowFixture);
fs.writeFileSync(input, source);
const result = run(['deliver', 'workflow', input, output, '--json'], cwd);
assert.equal(result.status, 1);
const receipt = JSON.parse(result.stdout);
assert.equal(receipt.stage, 'prepare');
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
assert.deepEqual(fs.readFileSync(input), source);
});
test('deliver rechecks aliases immediately before committing a verified candidate', { timeout: 10000 }, async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-deliver-race-'));
const installedRoot = path.join(cwd, 'skill');
const installedBin = path.join(installedRoot, 'bin');
const installedShared = path.join(installedRoot, 'renderers/shared');
const installedRenderer = path.join(installedRoot, 'renderers/workflow');
const installedScripts = path.join(installedRoot, 'scripts');
fs.mkdirSync(installedBin, { recursive: true });
fs.mkdirSync(installedShared, { recursive: true });
fs.mkdirSync(installedRenderer, { recursive: true });
fs.mkdirSync(installedScripts, { recursive: true });
fs.copyFileSync(cli, path.join(installedBin, 'archify.mjs'));
fs.copyFileSync(
path.join(skillRoot, 'renderers/shared/output-path.mjs'),
path.join(installedShared, 'output-path.mjs'),
);
fs.writeFileSync(path.join(installedRenderer, 'render-workflow.mjs'), `
import fs from 'node:fs';
const [, output] = process.argv.slice(2);
fs.writeFileSync(process.env.ARCHIFY_TEST_RENDER_STARTED, output);
await new Promise((resolve) => setTimeout(resolve, 500));
fs.writeFileSync(output, '
verified candidate');
`);
fs.writeFileSync(path.join(installedScripts, 'check-render-output.mjs'), `
console.log(JSON.stringify({
ok: true,
checks: [{ name: 'single_svg', ok: true }],
composition: {
profile: 'showcase',
status: 'pass',
summary: { errors: 0, warnings: 0 }
}
}));
`);
const inputDirectory = path.join(cwd, 'input');
const initialOutputDirectory = path.join(cwd, 'safe-output');
const linkedDirectory = path.join(cwd, 'linked-output');
fs.mkdirSync(inputDirectory);
fs.mkdirSync(initialOutputDirectory);
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
const input = path.join(inputDirectory, 'diagram.json');
const output = path.join(linkedDirectory, 'diagram.json');
const source = Buffer.from('{"meta":{"title":"race input"}}');
fs.writeFileSync(input, source);
const marker = path.join(cwd, 'renderer-started');
const child = spawn(process.execPath, [
path.join(installedBin, 'archify.mjs'),
'deliver', 'workflow', input, output, '--json',
], {
cwd,
encoding: 'utf8',
env: { ...process.env, ARCHIFY_TEST_RENDER_STARTED: marker },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
const started = Date.now();
while (!fs.existsSync(marker) && Date.now() - started < 3000) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(fs.existsSync(marker), true, `renderer did not start; stderr=${stderr}`);
const candidatePath = fs.readFileSync(marker, 'utf8');
const candidateRelative = path.relative(linkedDirectory, candidatePath);
fs.mkdirSync(path.dirname(path.join(inputDirectory, candidateRelative)), { recursive: true });
fs.unlinkSync(linkedDirectory);
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
const status = await new Promise((resolve) => child.once('close', resolve));
assert.equal(status, 1, stderr);
const receipt = JSON.parse(stdout);
assert.equal(receipt.stage, 'commit');
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
assert.deepEqual(fs.readFileSync(input), source);
});
test('compare rejects an artifact path that aliases either architecture input', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-'));
const realDirectory = path.join(cwd, 'real');
const linkedDirectory = path.join(cwd, 'linked');
fs.mkdirSync(realDirectory);
fs.symlinkSync(realDirectory, linkedDirectory, 'dir');
const base = path.join(realDirectory, 'review.html');
const output = path.join(linkedDirectory, 'review.html');
const baseSource = fs.readFileSync(baseFixture);
fs.writeFileSync(base, baseSource);
const result = run(['compare', 'architecture', base, headFixture, output, '--json'], cwd);
assert.equal(result.status, 1);
const receipt = JSON.parse(result.stdout);
assert.equal(receipt.stage, 'prepare');
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
assert.deepEqual(fs.readFileSync(base), baseSource);
});
test('compare rejects a receipt path that aliases either architecture input', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-receipt-'));
const base = path.join(cwd, 'base.json');
const output = path.join(cwd, 'delta.html');
const baseSource = fs.readFileSync(baseFixture);
fs.writeFileSync(base, baseSource);
const result = run([
'compare', 'architecture', base, headFixture, output,
'--receipt', base, '--json',
], cwd);
assert.equal(result.status, 1);
const receipt = JSON.parse(result.stdout);
assert.equal(receipt.stage, 'prepare');
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
assert.deepEqual(fs.readFileSync(base), baseSource);
assert.equal(fs.existsSync(output), false);
});
test('compare rejects a dangling receipt symlink to the future artifact path', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-pair-'));
const output = path.join(cwd, 'delta.html');
const receiptPath = path.join(cwd, 'delta.receipt.json');
fs.symlinkSync(output, receiptPath, 'file');
const result = run([
'compare', 'architecture', baseFixture, headFixture, output,
'--receipt', receiptPath, '--json',
], cwd);
assert.equal(result.status, 1);
const receipt = JSON.parse(result.stdout);
assert.equal(receipt.stage, 'prepare');
assert.equal(receipt.diagnostics[0].code, 'output/target-alias');
assert.equal(fs.lstatSync(receiptPath).isSymbolicLink(), true);
assert.equal(fs.existsSync(output), false);
});
test('preview applies the meta.output relative-path boundary before starting a server', async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-preview-meta-'));
const input = path.join(cwd, 'diagram.workflow.json');
const output = path.join(cwd, 'authored.html');
const source = JSON.parse(fs.readFileSync(workflowFixture, 'utf8'));
source.meta.output = output;
fs.writeFileSync(input, JSON.stringify(source));
const failure = await startPreview({
type: 'workflow',
input,
open: false,
watch: false,
cwd,
}).then(async (preview) => {
await preview.stop();
return null;
}, (error) => error);
assert.ok(failure instanceof Error);
assert.match(failure.message, /meta\.output must be a relative path/i);
assert.equal(fs.existsSync(output), false);
});
test('the shared renderer rechecks its guarded output immediately before writing', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-render-race-'));
const inputDirectory = path.join(cwd, 'input');
const initialOutputDirectory = path.join(cwd, 'safe-output');
const linkedDirectory = path.join(cwd, 'linked-output');
fs.mkdirSync(inputDirectory);
fs.mkdirSync(initialOutputDirectory);
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
const input = path.join(inputDirectory, 'diagram.workflow.json');
const output = path.join(linkedDirectory, 'diagram.workflow.json');
const source = fs.readFileSync(workflowFixture);
fs.writeFileSync(input, source);
const loaded = loadDiagram({
rendererDir: path.join(skillRoot, 'renderers/workflow'),
diagramType: 'workflow',
defaultExample: 'agent-tool-call.workflow.json',
argv: ['node', 'render-workflow.mjs', input, output],
});
fs.unlinkSync(linkedDirectory);
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
assert.throws(
() => writeDiagram({
outPath: loaded.outPath,
template: loaded.template,
diagramType: 'workflow',
meta: loaded.diagram.meta,
svg: '',
cards: [],
}),
/output must not replace an input/i,
);
assert.deepEqual(fs.readFileSync(input), source);
});
test('compare rechecks every target immediately before committing the artifact pair', { timeout: 10000 }, async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-compare-race-'));
const installedRoot = path.join(cwd, 'skill');
const installedBin = path.join(installedRoot, 'bin');
const installedShared = path.join(installedRoot, 'renderers/shared');
const installedRenderer = path.join(installedRoot, 'renderers/architecture');
const installedScripts = path.join(installedRoot, 'scripts');
const installedDelta = path.join(installedRoot, 'delta');
for (const directory of [installedBin, installedShared, installedRenderer, installedScripts, installedDelta]) {
fs.mkdirSync(directory, { recursive: true });
}
fs.copyFileSync(cli, path.join(installedBin, 'archify.mjs'));
fs.copyFileSync(
path.join(skillRoot, 'renderers/shared/output-path.mjs'),
path.join(installedShared, 'output-path.mjs'),
);
fs.writeFileSync(path.join(installedRenderer, 'render-architecture.mjs'), `
import fs from 'node:fs';
import path from 'node:path';
const [, output] = process.argv.slice(2);
if (path.basename(output) === 'head.html') {
const marker = process.env.ARCHIFY_TEST_RENDER_STARTED;
const markerCandidate = marker + '.tmp';
fs.writeFileSync(markerCandidate, output);
fs.renameSync(markerCandidate, marker);
await new Promise((resolve) => setTimeout(resolve, 500));
}
fs.writeFileSync(output, '');
`);
fs.writeFileSync(path.join(installedScripts, 'check-render-output.mjs'), `
console.log(JSON.stringify({
ok: true,
checks: [{ name: 'single_svg', ok: true }],
composition: {
profile: 'showcase',
status: 'pass',
summary: { errors: 0, warnings: 0 }
}
}));
`);
fs.writeFileSync(path.join(installedDelta, 'architecture-delta.mjs'), `
export class ArchitectureDeltaError extends Error {}
export const annotateArchitectureSideSvg = (svg) => svg;
export const buildDeltaSvg = () => '';
export const canonicalArchitecture = (value) => value;
export const canonicalArchitectureJson = (value) => JSON.stringify(value);
export const compareArchitecture = () => ({
command: 'compare',
base: {},
head: {},
completeness: 'complete',
proofLevel: 'authored'
});
export const extractArchitectureSvg = () => '';
export const extractArtifactCss = () => '';
export const renderArchitectureDeltaHtml = () => '';
export const validateArchitectureDeltaHtml = () => ({ checksPassed: 1, checkCount: 1 });
`);
const inputDirectory = path.join(cwd, 'input');
const initialOutputDirectory = path.join(cwd, 'safe-output');
const linkedDirectory = path.join(cwd, 'linked-output');
fs.mkdirSync(inputDirectory);
fs.mkdirSync(initialOutputDirectory);
fs.symlinkSync(initialOutputDirectory, linkedDirectory, 'dir');
const base = path.join(inputDirectory, 'diagram.json');
const head = path.join(cwd, 'head.json');
const output = path.join(linkedDirectory, 'diagram.json');
const source = Buffer.from('{"side":"base"}');
fs.writeFileSync(base, source);
fs.writeFileSync(head, '{"side":"head"}');
const marker = path.join(cwd, 'renderer-started');
const child = spawn(process.execPath, [
path.join(installedBin, 'archify.mjs'),
'compare', 'architecture', base, head, output, '--json',
], {
cwd,
encoding: 'utf8',
env: { ...process.env, ARCHIFY_TEST_RENDER_STARTED: marker },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
const started = Date.now();
while (!fs.existsSync(marker) && Date.now() - started < 3000) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(fs.existsSync(marker), true, `renderer did not start; stderr=${stderr}`);
const candidatePath = fs.readFileSync(marker, 'utf8');
const candidateRelative = path.relative(linkedDirectory, candidatePath);
fs.mkdirSync(path.dirname(path.join(inputDirectory, candidateRelative)), { recursive: true });
fs.unlinkSync(linkedDirectory);
fs.symlinkSync(inputDirectory, linkedDirectory, 'dir');
const status = await new Promise((resolve) => child.once('close', resolve));
assert.equal(status, 1, stderr);
const receipt = JSON.parse(stdout);
assert.equal(receipt.stage, 'commit');
assert.equal(receipt.diagnostics[0].code, 'output/input-alias');
assert.deepEqual(fs.readFileSync(base), source);
});
test('doctor reports a missing output-path safety runtime in an installed skill', () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-output-doctor-'));
const installedRoot = path.join(cwd, 'skill');
copyInstalledSkill(installedRoot);
fs.rmSync(path.join(installedRoot, 'renderers/shared/output-path.mjs'));
const result = spawnSync(process.execPath, [path.join(installedRoot, 'bin/archify.mjs'), 'doctor'], {
cwd: installedRoot,
encoding: 'utf8',
});
assert.equal(result.status, 1);
assert.match(result.stdout, /\[missing\] Output path safety runtime/);
});