Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable changes are documented here. Format loosely follows [Keep a Changelo
> Development identity: `v2.17.0-dev.1`. Not a stable release.

### Fixed
- **Readable renderer failures.** `render` and direct renderer entry points format classified input, schema, layout, and output failures without Node stacks. Filesystem failures identify the failing operation instead of labelling output errors as unreadable input; successful artifact bytes and existing validation/delivery receipt formats remain unchanged.
- **DSH plugin refresh.** Adapter 0.2.0 pins the current Archify development snapshot, includes the newer runtime and CLI fixes, and targets DSH 0.1.2-rc.1. Release metadata replaces the frozen 0.1.0 packaging source; the tarball uses the canonical clean-Skill stager and documents independent plugin upgrades.
- **Machine-readable CLI argument failures (#330).** `validate --json` and `deliver --json` now keep invalid or missing option values, unknown options and diagram types, unsupported option combinations, and usage errors inside one versioned failure receipt on stdout. These failures use the `arguments` stage, stable diagnostic codes, and exit status 2, while human-mode stderr behavior remains unchanged.
- **Complete artifact-check receipts (#311).** The checker now lets stdout drain before exiting, so large JSON receipts remain complete through pipes. Validation, delivery, and architecture comparison retain their original success/failure status without truncated-JSON errors.
Expand Down
Binary file modified archify.zip
Binary file not shown.
9 changes: 9 additions & 0 deletions archify/references/delivery-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Validate and deliver

`render` and direct renderer entry points print classified authoring failures
to stderr as readable diagnostics and exit 1. Input read/JSON parse failures
use `input/read` or `input/json-parse`; output filesystem failures use
`output/write` and identify the output path. Schema and layout failures keep
their existing rule codes. Use the advertised `validate --json` or
`deliver --json` interface for a machine receipt; `render` has no `--json` flag.
Unexpected implementation failures retain debugging information in human
mode and remain `internal/unclassified` in machine receipts.

Use `validate` after every candidate edit. CLI HTML output paths must end in `.html`, including after symbolic-link resolution.
Compare receipt paths must end in `.json`. Explicit CLI paths may be absolute or
outside the current working directory; authored `meta.output` remains confined
Expand Down
60 changes: 53 additions & 7 deletions archify/renderers/shared/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'node:path';
import { applyTemplate, renderCards, esc } from './utils.mjs';
import { validateSchema } from './validator.mjs';
import { verifyRepositoryEvidence } from './repository-evidence.mjs';
import { installRendererDiagnosticBoundary, throwDiagnosticProblems } from './diagnostics.mjs';
import { installRendererDiagnosticBoundary, throwDiagnosticError, throwDiagnosticProblems } from './diagnostics.mjs';
import { validateEngineeringProfile } from './engineering-profiles.mjs';
import { resolveOutputPath } from './output-path.mjs';
import { prepareDiagramBrandMarks } from './brand-marks.mjs';
Expand All @@ -19,7 +19,32 @@ const outputPathGuards = new Map();
export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = process.argv }) {
const skillRoot = path.resolve(rendererDir, '../..');
const inputPath = path.resolve(argv[2] || path.join(skillRoot, 'examples', defaultExample));
const diagram = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
let input;
try {
input = fs.readFileSync(inputPath, 'utf8');
} catch (error) {
if (typeof error?.code !== 'string') throw error;
const message = `Input could not be read: ${error.message}`;
throwDiagnosticError(message, [{
code: 'input/read', message,
subject: { input: inputPath },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['provide one readable JSON input file'],
}]);
}
let diagram;
try {
diagram = JSON.parse(input);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
const message = `Input JSON could not be parsed: ${error.message}`;
throwDiagnosticError(message, [{
code: 'input/json-parse', message,
subject: { input: inputPath },
evidence: { reason: error.message },
supportedFixes: ['repair the JSON syntax and run validation again'],
}]);
}
validateSchema(diagramType, diagram);
validateGuidedViews(diagramType, diagram);
validateRelationshipIds(diagramType, diagram);
Expand All @@ -33,7 +58,12 @@ export function loadDiagram({ rendererDir, diagramType, defaultExample, argv = p
inputPaths: [inputPath],
cwd: process.cwd(),
};
const { outputPath: outPath } = resolveOutputPath(outputRequest);
let outPath;
try {
({ outputPath: outPath } = resolveOutputPath(outputRequest));
} catch (error) {
throwOutputError(error, path.resolve(outputRequest.requestedOutput || outputRequest.authoredOutput || outputRequest.defaultOutput));
}
outputPathGuards.set(outPath, outputRequest);
return { diagram, template, outPath, sourceEvidence };
}
Expand All @@ -49,13 +79,22 @@ export async function loadDiagramWithBrandMarks(options) {

const START_TYPES = new Set(['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle']);

function throwOutputError(error, output) {
if (error?.archifyDiagnostics || typeof error?.code !== 'string') throw error;
const message = `Output could not be written: ${error.message}`;
throwDiagnosticError(message, [{
code: 'output/write', message,
subject: { output },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['choose a writable HTML file path and ensure its parent directories can be created'],
}]);
}

// Common CLI tail: fill the template and write the standalone HTML file.
export function writeDiagram({ outPath, template, diagramType, meta, svg, cards, sourceEvidence = null }) {
if (!START_TYPES.has(diagramType)) throw new Error(`writeDiagram: unknown diagram type ${JSON.stringify(diagramType)}`);
const outputGuard = outputPathGuards.get(outPath);
if (outputGuard) resolveOutputPath(outputGuard);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, applyTemplate(template, {
const html = applyTemplate(template, {
title: meta.title,
subtitle: meta.subtitle,
svg,
Expand All @@ -64,7 +103,14 @@ export function writeDiagram({ outPath, template, diagramType, meta, svg, cards,
visualPreset: meta.visual_preset || 'classic',
guidedViews: meta.views || [],
sourceEvidence,
}));
});
try {
if (outputGuard) resolveOutputPath(outputGuard);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, html);
} catch (error) {
throwOutputError(error, outPath);
}
outputPathGuards.delete(outPath);
console.log(outPath);
}
Expand Down
51 changes: 30 additions & 21 deletions archify/renderers/shared/diagnostics.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,26 +68,6 @@ export function throwDiagnosticProblems(prefix, problems, { code = 'layout/const

function fallbackDiagnostic(error) {
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
if (error instanceof SyntaxError) {
return normalizedDiagnostic({
code: 'input/json-parse',
severity: 'error',
message: `Input JSON could not be parsed: ${error.message}`,
subject: { input },
evidence: { reason: error.message },
supportedFixes: ['repair the JSON syntax and run validation again'],
});
}
if (error?.code === 'ENOENT' || error?.code === 'EACCES' || error?.code === 'EISDIR') {
return normalizedDiagnostic({
code: 'input/read',
severity: 'error',
message: `Input could not be read: ${error.message}`,
subject: { input },
evidence: { systemCode: error.code, reason: error.message },
supportedFixes: ['provide one readable JSON input file'],
});
}
return normalizedDiagnostic({
code: 'internal/unclassified',
severity: 'error',
Expand All @@ -111,9 +91,38 @@ function rendererFailure(error) {
};
}

// Match the public CLI's text format without making its standalone doctor
// bootstrap depend on this renderer runtime being present.
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');
}

export function installRendererDiagnosticBoundary() {
if (!DIAGNOSTIC_MODE || globalThis[boundaryKey]) return;
if (globalThis[boundaryKey]) return;
globalThis[boundaryKey] = true;
if (!DIAGNOSTIC_MODE) {
process.once('uncaughtException', (error) => {
// Only errors classified at their operation boundary are author-facing.
// Preserve Node's debugging information for unexpected implementation errors.
if (!error?.archifyDiagnostics?.length) {
// The once-listener is already removed. Rethrow outside the exception
// handler so Node retains its normal stack and exit code (not code 7).
process.nextTick(() => { throw error; });
return;
}
const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`;
process.stderr.once('error', () => process.exit(1));
process.stderr.write(payload, () => process.exit(1));
});
return;
}
process.on('uncaughtException', (error) => {
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
try {
Expand Down
129 changes: 129 additions & 0 deletions archify/test/render-failure-diagnostics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';

const skillRoot = fileURLToPath(new URL('../', import.meta.url));
const cli = path.join(skillRoot, 'bin/archify.mjs');
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',
};

function workspace(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-render-diagnostics-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return directory;
}

function run(args, cwd, json = false) {
return spawnSync(process.execPath, args, {
cwd,
encoding: 'utf8',
env: { ...process.env, ARCHIFY_DIAGNOSTIC_FORMAT: json ? 'json' : '' },
});
}

function assertHumanFailure(result, code) {
assert.equal(result.status, 1, result.stdout || result.stderr);
assert.equal(result.stdout, '');
assert.ok(result.stderr.includes(`[${code}]`), result.stderr);
assert.doesNotMatch(result.stderr, /\n\s+at\s|file:\/\/|Node\.js v/);
}

for (const [type, example] of Object.entries(examples)) {
test(`render ${type}: input read/parse and output failures name the failing operation`, t => {
const cwd = workspace(t);
const input = path.join(skillRoot, 'examples', example);
const output = path.join(cwd, 'diagram.html');
const missing = run([cli, 'render', type, path.join(cwd, 'missing.json'), output], cwd);
assertHumanFailure(missing, 'input/read');
assert.match(missing.stderr, /Fix: provide one readable JSON input file/);

const malformed = path.join(cwd, 'malformed.json');
fs.writeFileSync(malformed, '{"broken":');
assertHumanFailure(run([cli, 'render', type, malformed, output], cwd), 'input/json-parse');
assert.equal(fs.existsSync(output), false);

// Directory targets fail on both POSIX and Windows without relying on
// permission bits (which a privileged test process may bypass).
fs.mkdirSync(output);
assertHumanFailure(run([cli, 'render', type, input, output], cwd), 'output/write');
const renderer = path.join(skillRoot, 'renderers', type, `render-${type}.mjs`);
const machine = run([renderer, input, output], cwd, true);
assert.equal(machine.status, 1);
assert.equal(machine.stdout, '');
const failure = JSON.parse(machine.stderr);
assert.equal(failure.diagnostics[0].code, 'output/write');
assert.deepEqual(failure.diagnostics[0].subject, { output });
assert.ok(failure.diagnostics[0].evidence.systemCode);
assert.ok(failure.diagnostics[0].supportedFixes.length);
assert.deepEqual(fs.readdirSync(output), []);

const blockedParent = path.join(cwd, 'parent');
fs.writeFileSync(blockedParent, 'preserved');
assertHumanFailure(run([renderer, input, path.join(blockedParent, 'diagram.html')], cwd), 'output/write');
assert.equal(fs.readFileSync(blockedParent, 'utf8'), 'preserved');
});

test(`render ${type}: successful direct and public rendering retain the output contract`, t => {
const cwd = workspace(t);
const input = path.join(skillRoot, 'examples', example);
const output = path.join(cwd, 'diagram.html');
const publicResult = run([cli, 'render', type, input, output], cwd);
assert.equal(publicResult.status, 0, publicResult.stderr);
assert.equal(publicResult.stdout, `${output}\n`);
assert.equal(publicResult.stderr, '');
const bytes = fs.readFileSync(output);
const directResult = run([path.join(skillRoot, 'renderers', type, `render-${type}.mjs`), input, output], cwd);
assert.equal(directResult.status, 0, directResult.stderr);
assert.equal(directResult.stdout, `${output}\n`);
assert.equal(directResult.stderr, '');
assert.deepEqual(fs.readFileSync(output), bytes);
});
}

test('render layout rejection exposes the existing diagnostic and preserves an existing artifact', t => {
const cwd = workspace(t);
const input = path.join(cwd, 'layout.json');
const output = path.join(cwd, 'diagram.html');
fs.writeFileSync(input, JSON.stringify({
schema_version: 1,
diagram_type: 'architecture',
meta: { title: 'Wide label', quality_profile: 'standard', viewBox: [975, 395] },
components: [{ id: 'node', type: 'security', label: '字'.repeat(40), pos: [40, 40], size: [88, 71] }],
}));
fs.writeFileSync(output, 'trusted artifact');
const result = run([cli, 'render', 'architecture', input, output], cwd);
assertHumanFailure(result, 'layout/constraint');
assert.match(result.stderr, /shorten the label or widen size/);
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted artifact');
for (const command of ['validate', 'deliver']) {
const machine = run([cli, command, 'architecture', input, ...(command === 'deliver' ? [output] : []), '--json'], cwd);
assert.equal(machine.status, 1);
assert.equal(machine.stderr, '');
const failure = JSON.parse(machine.stdout);
assert.equal(failure.diagnostics[0].code, 'layout/constraint');
assert.deepEqual(failure.diagnostics[0].subject, { diagramType: 'architecture' });
}
assert.equal(fs.readFileSync(output, 'utf8'), 'trusted artifact');
});

test('unexpected renderer exceptions keep native debugging information and are not relabelled as input errors', t => {
const cwd = workspace(t);
const helper = new URL('../renderers/shared/cli.mjs', import.meta.url).href;
const script = `import ${JSON.stringify(helper)}; throw Object.assign(new SyntaxError('implementation defect'), { code: 'EACCES' });`;
const human = run(['--input-type=module', '-e', script], cwd);
assert.equal(human.status, 1);
assert.match(human.stderr, /SyntaxError: implementation defect/);
assert.match(human.stderr, /\n\s+at\s/);
const machine = run(['--input-type=module', '-e', script], cwd, true);
assert.equal(machine.status, 1);
assert.equal(JSON.parse(machine.stderr).diagnostics[0].code, 'internal/unclassified');
});