Skip to content
Open
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
Binary file modified archify.zip
Binary file not shown.
166 changes: 165 additions & 1 deletion archify/bin/archify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2192,7 +2192,7 @@ function invalidProvenance(artifactPath, sidecar, reason, evidence = {}) {
const COMMAND_USAGE = {
story: ['scan <native.html> [--json]', 'draft <native.html> <brief.json> [--json]', 'attach <native.html> <brief.json> <enhanced.html> [--json] [--repo-root path]'],
render: '<type> <input.json> [output.html] [--quality standard|showcase] [--repo-root path]',
compare: 'architecture <base.json> <head.json> [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path]',
compare: 'architecture <base.json> <head.json> [output.html] [--receipt path] [--json] [--quality standard|showcase] [--repo-root path] | workflow <base.json> <head.json> [output.json] [--json]',
deliver: '<type> <input.json> [output.html] [--json] [--open] [--quality standard|showcase] [--repo-root path]',
patch: 'explainer <artifact.html> <panel-id> <replacement-file> <output.html> [--json] [--quality standard|showcase] [--repo-root path]',
finalize: '<type> <input.json> <output.html> [--json] [--browser-check] [--receipt path] [--out-dir <dir>] [--quality standard|showcase] [--repo-root path] [--candidate-sha256 hex]',
Expand Down Expand Up @@ -3674,7 +3674,171 @@ function renderValidatedArchitecture(inputPath, outputPath, quality, repoRoot, c
};
}

async function commandCompareWorkflow(args) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const options = args.filter(arg => arg.startsWith('--'));
const json = options.includes('--json');
const positional = args.filter(arg => !arg.startsWith('--'));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const [, baseInput, headInput, output] = positional;
let outputFailure;
const validateOutput = async () => {
if (!output) return;
const resolved = path.resolve(output);
if (path.extname(resolved).toLowerCase() !== '.json') {
outputFailure = { code: 'output/cli-extension', message: 'Workflow comparison output must use a .json extension.' };
return;
}
let resolvedTarget = resolved;
try {
resolvedTarget = fs.realpathSync.native(resolved);
} catch (error) {
if (error?.code === 'ENOTDIR') {
outputFailure = { code: 'output/cli-resolve', message: 'Workflow comparison output path could not be resolved.' };
return;
}
if (error?.code !== 'ENOENT') throw error;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
try {
if (!fs.statSync(path.dirname(resolved)).isDirectory()) {
outputFailure = { code: 'output/cli-resolve', message: 'Workflow comparison output path could not be resolved.' };
return;
}
} catch (parentError) {
if (parentError?.code === 'ENOTDIR') {
outputFailure = { code: 'output/cli-resolve', message: 'Workflow comparison output path could not be resolved.' };
return;
}
if (parentError?.code !== 'ENOENT') throw parentError;
}
}
if (path.extname(resolvedTarget).toLowerCase() !== '.json') {
outputFailure = { code: 'output/cli-resolved-extension', message: 'Workflow comparison output must resolve to a .json extension.' };
return;
}
const { sameLocation } = await import('../renderers/shared/path-semantics.mjs');
for (const input of [baseInput, headInput].filter(Boolean)) {
const relation = sameLocation(resolved, path.resolve(input));
if (relation.status === 'match') {
outputFailure = { code: 'output/cli-alias', message: 'Workflow comparison output must not replace either input, including through an alias.' };
return;
}
}
};
const writeReceipt = (destination, receipt) => {
if (!destination || outputFailure) return;
const resolved = path.resolve(destination);
Comment thread
outlier27-cell marked this conversation as resolved.
Resolved
const temporary = `${resolved}.tmp-${process.pid}-${randomUUID()}`;
fs.writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx' });
fs.renameSync(temporary, resolved);
};
const failWorkflow = (code, message, details = {}) => {
const diagnostics = details.diagnostics || [{ code, message }];
const receipt = { schemaVersion: 1, ok: false, command: 'compare', type: 'workflow', code, error: message, diagnostics, details };
if (json) process.stdout.write(`${JSON.stringify(receipt)}\n`);
else process.stderr.write(`${message}${diagnostics.length ? `\n${JSON.stringify(diagnostics, null, 2)}` : ''}\n`);
writeReceipt(output, receipt);
process.exitCode = 1;
};
if (options.some(option => option !== '--json') || !baseInput || !headInput || positional.length > 4) return failWorkflow('workflow/usage', 'Usage: compare workflow <base.json> <head.json> [output.json] [--json]');
await validateOutput();
if (outputFailure) return failWorkflow(outputFailure.code, outputFailure.message);
let base;
let head;
try { base = JSON.parse(fs.readFileSync(baseInput, 'utf8')); } catch (error) { return failWorkflow('workflow/base-read', `Could not read base Workflow input: ${error.message}`); }
try { head = JSON.parse(fs.readFileSync(headInput, 'utf8')); } catch (error) { return failWorkflow('workflow/head-read', `Could not read head Workflow input: ${error.message}`); }
if (!base || typeof base !== 'object' || Array.isArray(base) || !head || typeof head !== 'object' || Array.isArray(head)) return failWorkflow('workflow/shape', 'Both inputs must be JSON objects.');
if (base.diagram_type !== 'workflow' || head.diagram_type !== 'workflow') return failWorkflow('workflow/type', 'Both inputs must declare diagram_type: workflow.');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const { validateSchema } = await import('../renderers/shared/validator.mjs');
try { validateSchema('workflow', base); } catch (error) { return failWorkflow('workflow/base-invalid', 'Base Workflow input is invalid.', { diagnostics: error.archifyDiagnostics || [] }); }
try { validateSchema('workflow', head); } catch (error) { return failWorkflow('workflow/head-invalid', 'Head Workflow input is invalid.', { diagnostics: error.archifyDiagnostics || [] }); }
Comment thread
outlier27-cell marked this conversation as resolved.
Resolved
const semanticDiagnostics = (workflow) => {
const nodeIds = new Set((workflow.nodes || []).map((node) => node.id));
const edgePairs = new Set((workflow.edges || []).map((edge) => `${edge.from}\0${edge.to}`));
const diagnostics = [];
for (const [index, edge] of (workflow.edges || []).entries()) {
for (const field of ['from', 'to']) if (!nodeIds.has(edge[field])) diagnostics.push({
code: 'workflow/unknown-endpoint', severity: 'error', message: `Edge "${edge.id}" references unknown ${field} node "${edge[field]}".`,
subject: { path: `/edges/${index}/${field}`, edge: edge.id }, evidence: { nodeId: edge[field] }, supportedFixes: [],
});
}
for (const [index, nodeId] of (workflow.mainPath || []).entries()) {
if (!nodeIds.has(nodeId)) diagnostics.push({ code: 'workflow/unknown-endpoint', severity: 'error', message: `mainPath references unknown node "${nodeId}".`, subject: { path: `/mainPath/${index}`, mainPath: nodeId }, evidence: { nodeId }, supportedFixes: [] });
if (index && !edgePairs.has(`${workflow.mainPath[index - 1]}\0${nodeId}`)) diagnostics.push({ code: 'workflow/main-path-edge', severity: 'error', message: `mainPath step "${workflow.mainPath[index - 1]}" -> "${nodeId}" has no matching edge.`, subject: { path: `/mainPath/${index}` }, evidence: {}, supportedFixes: [] });
}
return diagnostics;
};
for (const [label, workflow] of [['base', base], ['head', head]]) {
const diagnostics = semanticDiagnostics(workflow);
if (diagnostics.length) return failWorkflow(`workflow/${label}-invalid`, `${label === 'base' ? 'Base' : 'Head'} Workflow input is semantically invalid.`, { diagnostics });
}
const stable = (items, field) => {
const map = new Map();
for (const item of items || []) {
if (!item.id || map.has(item.id)) return null;
map.set(item.id, item);
}
return map;
};
const baseNodes = stable(base.nodes);
const headNodes = stable(head.nodes);
const baseEdges = stable(base.edges);
const headEdges = stable(head.edges);
const baseLanes = stable(base.lanes);
const headLanes = stable(head.lanes);
const basePhases = stable(base.phases);
const headPhases = stable(head.phases);
const baseGroups = stable(base.groups);
const headGroups = stable(head.groups);
if (!baseNodes || !headNodes || !baseEdges || !headEdges || !baseLanes || !headLanes || !basePhases || !headPhases || !baseGroups || !headGroups) return failWorkflow('workflow/identity', 'Workflow nodes, edges, lanes, phases, and groups require unique stable ids.');
const presentationKeys = new Set(['row', 'col', 'pos', 'width', 'height', 'x', 'y', 'xOffset', 'yOffset', 'labelDx', 'labelDy']);
const semantic = (value, ignored = presentationKeys) => {
if (Array.isArray(value)) return value.map(entry => semantic(entry, ignored));
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).filter(key => !ignored.has(key)).sort().map(key => [key, semantic(value[key], ignored)]));
};
const changes = { lanes: [], phases: [], groups: [], nodes: [], edges: [], mainPath: [], semanticChecks: [] };
const compare = (name, beforeItems, afterItems, ignored = presentationKeys) => {
for (const id of [...new Set([...beforeItems.keys(), ...afterItems.keys()])].sort()) {
const before = beforeItems.get(id); const after = afterItems.get(id);
if (!before) changes[name].push({ id, change: 'added', head: semantic(after, ignored) });
else if (!after) changes[name].push({ id, change: 'removed', base: semantic(before, ignored) });
else if (JSON.stringify(semantic(before, ignored)) !== JSON.stringify(semantic(after, ignored))) changes[name].push({ id, change: name === 'edges' && (before.from !== after.from || before.to !== after.to) ? 'endpoint-changed' : 'modified', base: semantic(before, ignored), head: semantic(after, ignored) });
}
};
compare('lanes', baseLanes, headLanes, new Set());
compare('phases', basePhases, headPhases, new Set(['fromCol', 'toCol']));
compare('groups', baseGroups, headGroups, new Set(['fromCol', 'toCol']));
compare('nodes', baseNodes, headNodes);
compare('edges', baseEdges, headEdges);
if (JSON.stringify(base.mainPath || []) !== JSON.stringify(head.mainPath || [])) changes.mainPath.push({ change: 'modified', base: base.mainPath || [], head: head.mainPath || [] });
const normalizeSemanticChecks = (checks) => {
if (!checks) return null;
const identifiers = (values) => [...new Set(values || [])].sort();
const relations = (values) => [...new Map((values || []).map(({ from, to }) => [`${from}\0${to}`, { from, to }])).values()]
.sort((left, right) => (left.from < right.from ? -1 : left.from > right.from ? 1 : 0) || (left.to < right.to ? -1 : left.to > right.to ? 1 : 0));
const normalized = [
['allowedRoots', identifiers],
['allowedTerminals', identifiers],
['requiredEdges', relations],
['requiredPaths', relations],
].filter(([key]) => Object.hasOwn(checks, key)).map(([key, normalize]) => [key, normalize(checks[key])]);
return Object.fromEntries([
...normalized.filter(([key, value]) => !(['requiredEdges', 'requiredPaths'].includes(key) && value.length === 0)),
]);
};
const baseSemanticChecks = normalizeSemanticChecks(base.semanticChecks);
const headSemanticChecks = normalizeSemanticChecks(head.semanticChecks);
if (JSON.stringify(baseSemanticChecks) !== JSON.stringify(headSemanticChecks)) changes.semanticChecks.push({ change: 'modified', base: baseSemanticChecks, head: headSemanticChecks });
const receipt = { schemaVersion: 1, ok: true, command: 'compare', type: 'workflow', comparatorVersion: 1, diagnostics: [], changes, summary: Object.fromEntries(Object.entries(changes).map(([name, entries]) => [name, entries.length])) };
writeReceipt(output, receipt);
if (json) process.stdout.write(`${JSON.stringify(receipt)}\n`);
else {
const total = Object.values(receipt.summary).reduce((count, entries) => count + entries, 0);
const details = total ? `\n${JSON.stringify(receipt.changes, null, 2)}` : '';
process.stdout.write(`Workflow changes: ${total}${details}\n`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

async function commandCompare(args) {
if (args[0] === 'workflow') return commandCompareWorkflow(args);
await loadSidecarPathRuntime();
const {
canonicalFuturePath,
Expand Down
Binary file modified docs/assets/archify-live-proof.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions docs/assets/archify-live-proof.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"fps": 10,
"frameCount": 54,
"durationSeconds": 5.4,
"bytes": 843870,
"sha256": "b1352f9557026653aeeccac19e11d26f5d24eca8ab013e6c7058529211838c9b",
"bytes": 818426,
"sha256": "dcebe427d408d3fabe547ebe91cdd7ddcd6f5d8587dc9372c674b14cae701d27",
"scenes": [
{
"id": "signal-flow",
Expand Down
Loading
Loading