VS Code extension that previews Archify diagrams from their JSON sources (live, as you type) and opens rendered Archify HTML in a viewer tab. Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime. Adds validation diagnostics, JSON schema help, source-link navigation, export saving, render-to-file and open-in-browser commands. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6937 lines
261 KiB
JavaScript
Executable File
6937 lines
261 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createHash, randomUUID } 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']);
|
|
const DELIVERY_SIDECAR_SUFFIXES = Object.freeze([
|
|
'.delivery.json',
|
|
'.delivery-pending.json',
|
|
'.delivery-lock.json',
|
|
]);
|
|
const COMPARE_SIDECAR_SUFFIXES = Object.freeze(['.receipt.json']);
|
|
const DELIVERY_DIRECTORY_LOCK = '.archify-delivery-lock.json';
|
|
let sidecarNamespaceComponentKeyRuntime;
|
|
let boundedSidecarStem;
|
|
let isBoundedSidecarStem;
|
|
let sidecarStemFromComponent;
|
|
let sidecarStemNeedsBounding;
|
|
|
|
async function loadSidecarPathRuntime() {
|
|
({
|
|
boundedSidecarStem,
|
|
isBoundedSidecarStem,
|
|
sidecarStemFromComponent,
|
|
sidecarStemNeedsBounding,
|
|
} = await import('../renderers/shared/sidecar-path.mjs'));
|
|
}
|
|
|
|
function deliverySidecarNamespace(artifactPath) {
|
|
let artifact = path.resolve(artifactPath);
|
|
try {
|
|
artifact = fs.realpathSync.native(artifact);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
|
|
}
|
|
if (typeof sidecarNamespaceComponentKeyRuntime !== 'function') {
|
|
throw new Error('The sidecar namespace runtime is unavailable.');
|
|
}
|
|
const namespace = sidecarNamespaceComponentKeyRuntime(
|
|
path.dirname(artifact),
|
|
path.basename(artifact),
|
|
);
|
|
if (namespace.status !== 'resolved') {
|
|
const error = new Error(
|
|
`Could not establish a stable sidecar namespace (${namespace.reason?.code || 'unknown'}).`,
|
|
);
|
|
error.code = 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
|
|
error.sidecarNamespaceReason = namespace.reason;
|
|
throw error;
|
|
}
|
|
return { directory: namespace.directoryPath, ...sidecarStemFromComponent(namespace.componentKey) };
|
|
}
|
|
|
|
function deliverySidecarPath(artifactPath, suffix) {
|
|
const { directory, stem, options } = deliverySidecarNamespace(artifactPath);
|
|
return path.join(
|
|
directory,
|
|
`${boundedSidecarStem(stem, DELIVERY_SIDECAR_SUFFIXES, options)}${suffix}`,
|
|
);
|
|
}
|
|
|
|
function deliveryProvenancePath(artifactPath) {
|
|
return deliverySidecarPath(artifactPath, '.delivery.json');
|
|
}
|
|
|
|
function deliveryPendingPath(artifactPath) {
|
|
return deliverySidecarPath(artifactPath, '.delivery-pending.json');
|
|
}
|
|
|
|
function rawHeadDeliverySidecarPath(artifactPath, suffix) {
|
|
const { directory, stem, options } = deliverySidecarNamespace(artifactPath);
|
|
if (options?.force) return undefined;
|
|
if (sidecarStemNeedsBounding(stem, DELIVERY_SIDECAR_SUFFIXES)
|
|
|| !isBoundedSidecarStem(stem)) return undefined;
|
|
return path.join(directory, `${stem}${suffix}`);
|
|
}
|
|
|
|
function rawHeadDeliveryProvenancePath(artifactPath) {
|
|
return rawHeadDeliverySidecarPath(artifactPath, '.delivery.json');
|
|
}
|
|
|
|
function rawHeadDeliveryPendingPath(artifactPath) {
|
|
return rawHeadDeliverySidecarPath(artifactPath, '.delivery-pending.json');
|
|
}
|
|
|
|
function legacyExtensionDeliverySidecarPath(artifactPath, suffix) {
|
|
let artifact = path.resolve(artifactPath);
|
|
try {
|
|
artifact = fs.realpathSync.native(artifact);
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
|
|
}
|
|
const component = path.basename(artifact);
|
|
if (!/\.html$/iu.test(component) || component.endsWith('.html')) return undefined;
|
|
const legacyComponent = `${component.slice(0, -'.html'.length)}${suffix}`;
|
|
if (legacyComponent.length > 255 || Buffer.byteLength(legacyComponent, 'utf8') > 255) {
|
|
return undefined;
|
|
}
|
|
return path.join(path.dirname(artifact), legacyComponent);
|
|
}
|
|
|
|
function canonicalExistingDeliveryOutput(outputPath) {
|
|
const output = path.resolve(outputPath);
|
|
try {
|
|
return fs.realpathSync.native(output);
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return output;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function legacyDeliveryLockPath(artifactPath) {
|
|
const artifact = path.resolve(artifactPath);
|
|
return `${artifact.replace(/\.html?$/i, '')}.delivery-lock.json`;
|
|
}
|
|
|
|
function legacyDeliveryLockPaths(artifactPaths) {
|
|
const paths = [];
|
|
for (const artifactPath of artifactPaths) {
|
|
const lockPath = legacyDeliveryLockPath(artifactPath);
|
|
const name = path.basename(lockPath);
|
|
const canExist = process.platform === 'win32'
|
|
? name.length <= 255
|
|
: Buffer.byteLength(name, 'utf8') <= 255;
|
|
if (canExist && !paths.includes(lockPath)) paths.push(lockPath);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function deliveryLockPath(artifactPath) {
|
|
// A directory-wide mutex is deliberately conservative. Future 8.3, case,
|
|
// and Unicode aliases cannot be assigned a sound canonical filename before
|
|
// the artifact exists, so a per-artifact lock can split one delivery into
|
|
// multiple owners. Provenance and pending journals remain artifact-specific.
|
|
const artifact = path.resolve(artifactPath);
|
|
let directory;
|
|
try {
|
|
directory = path.dirname(fs.realpathSync.native(artifact));
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
|
|
const authoredDirectory = path.dirname(artifact);
|
|
try {
|
|
directory = fs.realpathSync.native(authoredDirectory);
|
|
} catch (directoryError) {
|
|
if (directoryError?.code !== 'ENOENT' && directoryError?.code !== 'ENOTDIR') throw directoryError;
|
|
directory = authoredDirectory;
|
|
}
|
|
}
|
|
return path.join(directory, DELIVERY_DIRECTORY_LOCK);
|
|
}
|
|
|
|
function deliveryLockInvalid(lockPath) {
|
|
return Object.assign(new Error(`Unrecognized delivery lock at "${lockPath}"; inspect the existing entry before retrying.`), {
|
|
deliveryLockCode: 'delivery/lock-invalid',
|
|
deliveryLockPath: lockPath,
|
|
});
|
|
}
|
|
|
|
function regularDeliveryLockExists(lockPath) {
|
|
try {
|
|
if (!fs.lstatSync(lockPath).isFile()) throw deliveryLockInvalid(lockPath);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.deliveryLockCode) throw error;
|
|
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
throw deliveryLockInvalid(lockPath);
|
|
}
|
|
}
|
|
|
|
function rejectExistingDeliveryLock(lockPath, output, fileBindingRuntime) {
|
|
const {
|
|
captureRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
let lock;
|
|
let captured;
|
|
try {
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
captured = captureRegularFileBinding(lockPath, {
|
|
subject: 'delivery-lock',
|
|
expectedLinks: 1,
|
|
includeContent: true,
|
|
});
|
|
if (captured.status !== 'captured') throw new Error('Not a stable regular lock file.');
|
|
lock = JSON.parse(captured.content.buffer.toString('utf8'));
|
|
if (lock?.schemaVersion !== 1 || !Number.isSafeInteger(lock.pid) || lock.pid <= 0
|
|
|| typeof lock.receiptId !== 'string'
|
|
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(lock.receiptId)) {
|
|
throw new Error('Unrecognized delivery lock.');
|
|
}
|
|
} catch {
|
|
throw deliveryLockInvalid(lockPath);
|
|
} finally {
|
|
if (captured?.binding) releaseRegularFileBinding(captured.binding);
|
|
}
|
|
if (processIsRunning(lock.pid)) {
|
|
throw Object.assign(new Error(`Another delivery attempt owns "${output}" through lock "${lockPath}".`), {
|
|
deliveryLockCode: 'delivery/concurrent-attempt',
|
|
deliveryLockPath: lockPath,
|
|
});
|
|
}
|
|
throw Object.assign(new Error(`A stale delivery lock remains at "${lockPath}"; recover it serially before retrying.`), {
|
|
deliveryLockCode: 'delivery/lock-stale',
|
|
deliveryLockPath: lockPath,
|
|
deliveryLockOwner: { pid: lock.pid, receiptId: lock.receiptId },
|
|
});
|
|
}
|
|
|
|
function processIsRunning(pid) {
|
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error.code !== 'ESRCH';
|
|
}
|
|
}
|
|
|
|
const deliveryOwnershipStates = new WeakMap();
|
|
|
|
function deliveryOwnershipFailure(state, operation, reason, evidence = {}) {
|
|
const error = new Error(`Delivery ownership was lost while attempting to ${operation}: ${reason}`);
|
|
error.deliveryOwnershipCode = 'delivery/ownership-lost';
|
|
error.deliveryOwnershipDetails = {
|
|
operation,
|
|
lock: state?.lockPath,
|
|
expectedReceiptId: state?.receiptId,
|
|
reason,
|
|
...evidence,
|
|
};
|
|
if (state && state.phase !== 'released') state.phase = 'lost';
|
|
return error;
|
|
}
|
|
|
|
function deliveryOwnershipState(ownership, operation) {
|
|
const state = deliveryOwnershipStates.get(ownership);
|
|
if (!state) throw deliveryOwnershipFailure(undefined, operation, 'the ownership capability is invalid');
|
|
if (state.phase === 'lost' || state.phase === 'released') {
|
|
throw deliveryOwnershipFailure(state, operation, `the capability is already ${state.phase}`);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function sameFileIdentity(actual, expected) {
|
|
const hasIdentity = (entry) => entry?.ino !== undefined && entry.ino !== 0 && entry.ino !== 0n;
|
|
return hasIdentity(actual)
|
|
&& hasIdentity(expected)
|
|
&& actual.dev === expected.dev
|
|
&& actual.ino === expected.ino;
|
|
}
|
|
|
|
function assertOwnedDeliveryLock(state, ownedLock, operation, { verifyReceipt = true } = {}) {
|
|
const {
|
|
captureRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = state?.fileBindingRuntime || {};
|
|
let captured;
|
|
try {
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
captured = captureRegularFileBinding(ownedLock.path, {
|
|
subject: 'owned-delivery-lock',
|
|
...(ownedLock.content ? {
|
|
expectedSha256: ownedLock.content.sha256,
|
|
expectedBytes: ownedLock.content.bytes,
|
|
} : {}),
|
|
expectedMode: Number(ownedLock.identity.mode & 0o777n),
|
|
expectedLinks: 1,
|
|
includeContent: verifyReceipt,
|
|
});
|
|
} catch (error) {
|
|
throw deliveryOwnershipFailure(state, operation, 'an owned lock entry can no longer be inspected', {
|
|
lock: ownedLock.path,
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
});
|
|
}
|
|
if (captured.status !== 'captured'
|
|
|| captured.identity.device !== ownedLock.identity.dev
|
|
|| captured.identity.inode !== ownedLock.identity.ino) {
|
|
if (captured?.binding) releaseRegularFileBinding(captured.binding);
|
|
throw deliveryOwnershipFailure(state, operation, 'an owned lock path no longer names the entry created by this attempt', {
|
|
lock: ownedLock.path,
|
|
...(captured?.reason ? { lockState: captured.reason } : {}),
|
|
});
|
|
}
|
|
try {
|
|
if (!verifyReceipt) return;
|
|
let lock;
|
|
try {
|
|
lock = JSON.parse(captured.content.buffer.toString('utf8'));
|
|
} catch (error) {
|
|
throw deliveryOwnershipFailure(state, operation, 'an owned lock receipt can no longer be verified', {
|
|
lock: ownedLock.path,
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
});
|
|
}
|
|
if (lock?.schemaVersion !== 1 || lock.receiptId !== state.receiptId || lock.pid !== state.pid) {
|
|
throw deliveryOwnershipFailure(state, operation, 'an owned lock receipt no longer matches this attempt', {
|
|
lock: ownedLock.path,
|
|
...(typeof lock?.receiptId === 'string' ? { observedReceiptId: lock.receiptId } : {}),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
throw error;
|
|
} finally {
|
|
releaseRegularFileBinding(captured.binding);
|
|
}
|
|
}
|
|
|
|
function assertDeliveryOwnership(ownership, operation, { allowInitializing = false, pending = 'ignore' } = {}) {
|
|
const state = deliveryOwnershipState(ownership, operation);
|
|
const verifyReceipt = !allowInitializing || !['acquiring', 'initializing'].includes(state.phase);
|
|
for (const ownedLock of state.ownedLocks) {
|
|
assertOwnedDeliveryLock(state, ownedLock, operation, { verifyReceipt });
|
|
}
|
|
if (pending !== 'ignore') {
|
|
const pendingPath = state.pendingPath;
|
|
if (pending === 'absent') {
|
|
try {
|
|
fs.lstatSync(pendingPath);
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return state;
|
|
throw deliveryOwnershipFailure(state, operation, 'the finalized delivery journal can no longer be inspected', {
|
|
journal: pendingPath,
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
});
|
|
}
|
|
throw deliveryOwnershipFailure(state, operation, 'the finalized delivery journal was recreated', { journal: pendingPath });
|
|
}
|
|
const {
|
|
captureRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = state.fileBindingRuntime || {};
|
|
let captured;
|
|
try {
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
captured = captureRegularFileBinding(pendingPath, {
|
|
subject: 'owned-delivery-journal',
|
|
expectedSha256: state.pendingContent?.sha256,
|
|
expectedBytes: state.pendingContent?.bytes,
|
|
expectedMode: state.pendingIdentity?.mode,
|
|
expectedLinks: 1,
|
|
includeContent: pending === 'owned',
|
|
});
|
|
} catch (error) {
|
|
throw deliveryOwnershipFailure(state, operation, 'the owned delivery journal can no longer be inspected', {
|
|
journal: pendingPath,
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
});
|
|
}
|
|
if (captured.status !== 'captured'
|
|
|| !state.pendingIdentity
|
|
|| captured.identity.device !== state.pendingIdentity.dev
|
|
|| captured.identity.inode !== state.pendingIdentity.ino) {
|
|
if (captured?.binding) releaseRegularFileBinding(captured.binding);
|
|
throw deliveryOwnershipFailure(state, operation, 'the delivery journal no longer names the entry created by this attempt', {
|
|
journal: pendingPath,
|
|
...(captured?.reason ? { journalState: captured.reason } : {}),
|
|
});
|
|
}
|
|
try {
|
|
if (pending === 'owned') {
|
|
let journal;
|
|
try {
|
|
journal = JSON.parse(captured.content.buffer.toString('utf8'));
|
|
} catch {
|
|
throw deliveryOwnershipFailure(state, operation, 'the delivery journal receipt is malformed', {
|
|
journal: pendingPath,
|
|
});
|
|
}
|
|
if (journal?.receiptId !== state.receiptId) {
|
|
throw deliveryOwnershipFailure(state, operation, 'the delivery journal receipt belongs to another attempt', {
|
|
journal: pendingPath,
|
|
...(typeof journal?.receiptId === 'string' ? { observedReceiptId: journal.receiptId } : {}),
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
releaseRegularFileBinding(captured.binding);
|
|
}
|
|
}
|
|
return state;
|
|
}
|
|
|
|
function quarantineRemoveOwnedDeliveryEntry(state, {
|
|
filePath,
|
|
identity,
|
|
content,
|
|
subject,
|
|
operation,
|
|
evidenceKey,
|
|
}) {
|
|
const {
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = state?.fileBindingRuntime || {};
|
|
let captured;
|
|
try {
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof quarantineRemoveRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
captured = captureRegularFileBinding(filePath, {
|
|
subject,
|
|
...(content ? {
|
|
expectedSha256: content.sha256,
|
|
expectedBytes: content.bytes,
|
|
} : {}),
|
|
expectedMode: typeof identity?.mode === 'bigint'
|
|
? Number(identity.mode & 0o777n)
|
|
: identity?.mode,
|
|
expectedIdentity: {
|
|
device: identity?.dev,
|
|
inode: identity?.ino,
|
|
},
|
|
expectedLinks: 1,
|
|
});
|
|
} catch (error) {
|
|
throw deliveryOwnershipFailure(state, operation, `the owned ${subject} can no longer be captured for removal`, {
|
|
[evidenceKey]: filePath,
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
});
|
|
}
|
|
if (captured.status !== 'captured') {
|
|
throw deliveryOwnershipFailure(state, operation, `the owned ${subject} changed before removal`, {
|
|
[evidenceKey]: filePath,
|
|
entryState: captured.reason,
|
|
});
|
|
}
|
|
try {
|
|
const removed = quarantineRemoveRegularFileBinding(captured.binding, filePath, {
|
|
subject,
|
|
expectedLinks: 1,
|
|
});
|
|
if (removed.status === 'removed') return;
|
|
const removalCode = removed.reason?.code;
|
|
if (removed.status === 'unknown'
|
|
&& ['removal-quarantine-create-failed', `${subject}-quarantine-move-failed`]
|
|
.includes(removalCode)) {
|
|
const detail = removed.reason?.message ? `: ${removed.reason.message}` : '';
|
|
const error = new Error(`The owned ${subject} could not be moved into its private removal quarantine (${removalCode})${detail}.`);
|
|
if (removed.reason?.systemCode) error.code = removed.reason.systemCode;
|
|
if (subject === 'delivery-lock') {
|
|
error.deliveryOwnershipCode = 'delivery/lock-release';
|
|
error.deliveryOwnershipDetails = {
|
|
operation,
|
|
lock: filePath,
|
|
reason: error.message,
|
|
entryState: removed.reason,
|
|
};
|
|
} else {
|
|
error.deliveryJournalRemovalState = removed;
|
|
}
|
|
throw error;
|
|
}
|
|
throw deliveryOwnershipFailure(
|
|
state,
|
|
operation,
|
|
removed.status === 'recovery-required'
|
|
? `a replacement ${subject} could not be restored from quarantine`
|
|
: `the public ${subject} changed during removal and was preserved`,
|
|
{
|
|
[evidenceKey]: filePath,
|
|
entryState: removed.reason,
|
|
...(removed.status === 'recovery-required' ? {
|
|
recoveryRequired: true,
|
|
recoveryDirectory: removed.recoveryDirectory,
|
|
recoveryFiles: [{
|
|
label: `preserved replacement ${subject}`,
|
|
path: removed.recoveryFile,
|
|
target: filePath,
|
|
}],
|
|
} : {}),
|
|
},
|
|
);
|
|
} finally {
|
|
releaseRegularFileBinding(captured.binding);
|
|
}
|
|
}
|
|
|
|
function releaseDeliveryOwnership(ownership, { allowInitializing = false } = {}) {
|
|
const current = deliveryOwnershipState(ownership, 'release the delivery lock');
|
|
const pending = current.phase === 'finalized'
|
|
? 'absent'
|
|
: current.pendingIdentity
|
|
? 'identity'
|
|
: 'ignore';
|
|
const state = assertDeliveryOwnership(ownership, 'release the delivery lock', { allowInitializing, pending });
|
|
const verifyReceipt = !allowInitializing || !['acquiring', 'initializing'].includes(state.phase);
|
|
for (const ownedLock of state.ownedLocks) {
|
|
assertOwnedDeliveryLock(state, ownedLock, 'release the delivery lock', { verifyReceipt });
|
|
quarantineRemoveOwnedDeliveryEntry(state, {
|
|
filePath: ownedLock.path,
|
|
identity: ownedLock.identity,
|
|
content: ownedLock.content,
|
|
subject: 'delivery-lock',
|
|
operation: 'release the delivery lock',
|
|
evidenceKey: 'lock',
|
|
});
|
|
}
|
|
state.phase = 'released';
|
|
}
|
|
|
|
function acquireDeliveryLock(
|
|
output,
|
|
receiptId,
|
|
inputPath,
|
|
pathsAlias,
|
|
recordInitializationFailure,
|
|
legacyOutputPaths = [],
|
|
fileBindingRuntime,
|
|
) {
|
|
const stableOutput = canonicalExistingDeliveryOutput(output);
|
|
const lockPath = deliveryLockPath(stableOutput);
|
|
const comparableLockPath = (candidate) => {
|
|
const absolute = path.resolve(candidate);
|
|
try {
|
|
return path.join(fs.realpathSync.native(path.dirname(absolute)), path.basename(absolute));
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
|
|
return absolute;
|
|
}
|
|
};
|
|
const lockPathsAlias = (left, right) => {
|
|
const comparableLeft = comparableLockPath(left);
|
|
const comparableRight = comparableLockPath(right);
|
|
if (!pathsAlias(path.dirname(comparableLeft), path.dirname(comparableRight))) return false;
|
|
return pathsAlias(comparableLeft, comparableRight);
|
|
};
|
|
const legacyFencePathsAlias = (left, right) => {
|
|
const comparableLeft = comparableLockPath(left);
|
|
const comparableRight = comparableLockPath(right);
|
|
if (comparableLeft === comparableRight) return true;
|
|
try {
|
|
return lockPathsAlias(comparableLeft, comparableRight);
|
|
} catch {
|
|
// An indeterminate future-name relation is not evidence of aliasing.
|
|
// Keep both candidates; an EEXIST below is accepted only when dev/ino
|
|
// proves that it names a lock already created by this attempt.
|
|
return false;
|
|
}
|
|
};
|
|
const legacyLockPaths = [];
|
|
for (const candidate of legacyDeliveryLockPaths([
|
|
...legacyOutputPaths.filter(Boolean),
|
|
output,
|
|
stableOutput,
|
|
])) {
|
|
if (!legacyLockPaths.some((existing) => legacyFencePathsAlias(candidate, existing))) {
|
|
legacyLockPaths.push(candidate);
|
|
}
|
|
}
|
|
const pendingPath = deliveryPendingPath(stableOutput);
|
|
const provenancePath = deliveryProvenancePath(stableOutput);
|
|
const rawHeadPendingPath = rawHeadDeliveryPendingPath(stableOutput);
|
|
const existingLegacyLocks = legacyLockPaths.filter((candidate) => regularDeliveryLockExists(candidate));
|
|
regularDeliveryLockExists(lockPath);
|
|
const conflictingLockPath = [lockPath, ...legacyLockPaths]
|
|
.find((candidate) => lockPathsAlias(candidate, inputPath));
|
|
if (conflictingLockPath) {
|
|
throw Object.assign(new Error('Delivery lock path aliases the input specification; choose another output path.'), {
|
|
deliveryLockCode: 'delivery/lock-path-conflict',
|
|
deliveryLockPath: conflictingLockPath,
|
|
});
|
|
}
|
|
if (existingLegacyLocks.length) {
|
|
rejectExistingDeliveryLock(existingLegacyLocks[0], output, fileBindingRuntime);
|
|
}
|
|
const openedLocks = [];
|
|
const ownership = Object.freeze({});
|
|
deliveryOwnershipStates.set(ownership, {
|
|
output: stableOutput,
|
|
lockPath,
|
|
pendingPath,
|
|
provenancePath,
|
|
receiptId,
|
|
pid: process.pid,
|
|
ownedLocks: openedLocks,
|
|
fileBindingRuntime,
|
|
phase: 'acquiring',
|
|
});
|
|
let collisionPath;
|
|
try {
|
|
const openOwnedLock = (ownedLockPath, { allowOwnedAlias = false } = {}) => {
|
|
let descriptor;
|
|
try {
|
|
descriptor = fs.openSync(ownedLockPath, 'wx', 0o600);
|
|
} catch (error) {
|
|
if (error.code === 'EEXIST' && allowOwnedAlias) {
|
|
try {
|
|
const existingEntry = fs.lstatSync(ownedLockPath, { bigint: true });
|
|
if (existingEntry.isFile()
|
|
&& openedLocks.some((ownedLock) => sameFileIdentity(existingEntry, ownedLock.identity))) {
|
|
return undefined;
|
|
}
|
|
} catch {
|
|
// Preserve the original EEXIST classification below.
|
|
}
|
|
}
|
|
if (error.code === 'EEXIST') collisionPath = ownedLockPath;
|
|
throw error;
|
|
}
|
|
let handleIdentity;
|
|
let handleIdentityError;
|
|
try {
|
|
handleIdentity = fs.fstatSync(descriptor, { bigint: true });
|
|
} catch (error) {
|
|
// A transient first inspection failure must not strand the public
|
|
// lock. Bind ownership from the still-open handle before cleanup.
|
|
try {
|
|
handleIdentity = fs.fstatSync(descriptor, { bigint: true });
|
|
} catch {
|
|
// Both handle metadata reads failed. Stamp the still-open inode with
|
|
// this attempt's unpredictable receipt before consulting the public
|
|
// path. A replacement claimant cannot be mistaken for our empty
|
|
// lock, while a successful content-bound capture gives cleanup the
|
|
// identity it needs after the original descriptor is closed.
|
|
const recoveryReceipt = `${JSON.stringify({ schemaVersion: 1, receiptId, pid: process.pid })}\n`;
|
|
const recoveryContent = artifactIdentity(Buffer.from(recoveryReceipt));
|
|
let captured;
|
|
try {
|
|
fs.writeFileSync(descriptor, recoveryReceipt);
|
|
captured = fileBindingRuntime?.captureRegularFileBinding?.(ownedLockPath, {
|
|
subject: 'owned-delivery-lock',
|
|
expectedSha256: recoveryContent.sha256,
|
|
expectedBytes: recoveryContent.bytes,
|
|
expectedLinks: 1,
|
|
});
|
|
} catch {}
|
|
if (captured?.status === 'captured') {
|
|
fileBindingRuntime.releaseRegularFileBinding(captured.binding);
|
|
openedLocks.push({
|
|
path: ownedLockPath,
|
|
identity: {
|
|
dev: captured.identity.device,
|
|
ino: captured.identity.inode,
|
|
mode: BigInt(captured.mode),
|
|
},
|
|
content: recoveryContent,
|
|
descriptor,
|
|
});
|
|
} else {
|
|
try {
|
|
fs.closeSync(descriptor);
|
|
} catch (closeError) {
|
|
error.lockCleanupError = closeError.message;
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
handleIdentityError = error;
|
|
}
|
|
const ownedLock = {
|
|
path: ownedLockPath,
|
|
identity: handleIdentity,
|
|
descriptor,
|
|
};
|
|
openedLocks.push(ownedLock);
|
|
const pathIdentity = fs.lstatSync(ownedLockPath, { bigint: true });
|
|
if (!handleIdentity.isFile()
|
|
|| handleIdentity.ino === 0n
|
|
|| !pathIdentity.isFile()
|
|
|| pathIdentity.isSymbolicLink()
|
|
|| pathIdentity.ino === 0n
|
|
|| !sameFileIdentity(pathIdentity, handleIdentity)) {
|
|
throw new Error('Delivery lock path identity could not be verified safely.');
|
|
}
|
|
if (handleIdentityError) throw handleIdentityError;
|
|
return ownedLock;
|
|
};
|
|
|
|
openOwnedLock(lockPath);
|
|
for (const legacyLockPath of legacyLockPaths) {
|
|
openOwnedLock(legacyLockPath, { allowOwnedAlias: true });
|
|
}
|
|
const state = deliveryOwnershipStates.get(ownership);
|
|
if (rawHeadPendingPath && pathEntryExists(rawHeadPendingPath)) {
|
|
throw Object.assign(new Error(`A pre-namespace delivery journal remains at "${rawHeadPendingPath}"; recover it before retrying.`), {
|
|
deliveryLockCode: 'delivery/legacy-pending',
|
|
deliveryLockPath: rawHeadPendingPath,
|
|
});
|
|
}
|
|
state.phase = 'initializing';
|
|
const lockReceipt = `${JSON.stringify({ schemaVersion: 1, receiptId, pid: process.pid })}\n`;
|
|
const lockContent = artifactIdentity(Buffer.from(lockReceipt));
|
|
for (const ownedLock of openedLocks) {
|
|
fs.writeFileSync(ownedLock.descriptor, lockReceipt);
|
|
ownedLock.content = lockContent;
|
|
}
|
|
for (const ownedLock of openedLocks) {
|
|
fs.closeSync(ownedLock.descriptor);
|
|
ownedLock.descriptor = undefined;
|
|
}
|
|
state.phase = 'owned';
|
|
return ownership;
|
|
} catch (error) {
|
|
for (const ownedLock of openedLocks) {
|
|
if (ownedLock.descriptor === undefined) continue;
|
|
try {
|
|
fs.closeSync(ownedLock.descriptor);
|
|
ownedLock.descriptor = undefined;
|
|
} catch (closeError) {
|
|
error.lockCleanupError = closeError.message;
|
|
}
|
|
}
|
|
const state = deliveryOwnershipStates.get(ownership);
|
|
let preserveOwnership = false;
|
|
try {
|
|
if (state.phase === 'initializing') {
|
|
assertDeliveryOwnership(ownership, 'record a lock initialization failure', { allowInitializing: true });
|
|
error.deliveryFailureRecord = recordInitializationFailure?.(error, ownership);
|
|
if (error.deliveryFailureRecord?.journalRecovery) {
|
|
error.deliveryJournalRecovery = error.deliveryFailureRecord.journalRecovery.deliveryJournalRecovery;
|
|
preserveOwnership = true;
|
|
}
|
|
if (error.deliveryFailureRecord?.provenanceRecovery) {
|
|
error.deliveryProvenanceRecovery = error.deliveryFailureRecord.provenanceRecovery.deliveryProvenanceRecovery;
|
|
preserveOwnership = true;
|
|
}
|
|
}
|
|
if (!preserveOwnership) {
|
|
releaseDeliveryOwnership(ownership, { allowInitializing: true });
|
|
}
|
|
} catch (cleanupError) {
|
|
if (cleanupError.deliveryOwnershipCode) {
|
|
cleanupError.deliveryOwnershipDetails = {
|
|
...(cleanupError.deliveryOwnershipDetails || {}),
|
|
initializationError: error.message,
|
|
...(error?.code ? { initializationSystemCode: error.code } : {}),
|
|
};
|
|
if (cleanupError.deliveryOwnershipCode === 'delivery/lock-release' && error.deliveryFailureRecord) {
|
|
cleanupError.deliveryFailureRecord = error.deliveryFailureRecord;
|
|
}
|
|
throw cleanupError;
|
|
}
|
|
error.lockCleanupError = cleanupError.message;
|
|
}
|
|
if (collisionPath) rejectExistingDeliveryLock(collisionPath, output, fileBindingRuntime);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function pathEntryExists(file) {
|
|
try {
|
|
fs.lstatSync(file);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function captureOwnedStagingFile(registry, filePath, fileBindingRuntime, {
|
|
subject = 'private-staging-entry',
|
|
content,
|
|
expectedLinks = 1,
|
|
} = {}) {
|
|
const { captureRegularFileBinding } = fileBindingRuntime || {};
|
|
if (typeof captureRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
let captured = captureRegularFileBinding(filePath, {
|
|
subject,
|
|
...(content ? {
|
|
expectedSha256: content.sha256,
|
|
expectedBytes: content.bytes,
|
|
} : {}),
|
|
expectedLinks,
|
|
});
|
|
if (captured.status === 'unsupported'
|
|
&& String(captured.reason?.code || '').endsWith('-hardlinked')
|
|
&& content) {
|
|
const observedLinks = Number(captured.reason?.links);
|
|
if (Number.isSafeInteger(observedLinks) && observedLinks > expectedLinks) {
|
|
captured = captureRegularFileBinding(filePath, {
|
|
subject,
|
|
expectedSha256: content.sha256,
|
|
expectedBytes: content.bytes,
|
|
expectedLinks: observedLinks,
|
|
});
|
|
expectedLinks = observedLinks;
|
|
}
|
|
}
|
|
if (captured.status !== 'captured') {
|
|
const error = new Error(`Could not bind the owned staging entry "${filePath}" (${captured.reason?.code || 'unknown state'}).`);
|
|
error.stagingCapture = captured;
|
|
throw error;
|
|
}
|
|
registry.push({ filePath, binding: captured.binding, subject, expectedLinks });
|
|
return captured;
|
|
}
|
|
|
|
function cleanupOwnedStagingDirectory(directory, identity, registry, fileBindingRuntime) {
|
|
const {
|
|
quarantineRemoveRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
if (typeof quarantineRemoveRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
const failures = [];
|
|
for (const entry of [...registry].reverse()) {
|
|
try {
|
|
const removed = quarantineRemoveRegularFileBinding(entry.binding, entry.filePath, {
|
|
subject: entry.subject,
|
|
expectedLinks: entry.expectedLinks,
|
|
});
|
|
// A missing path means the transaction already moved or finalized this
|
|
// owned inode. Every other non-removal result is deliberately preserved:
|
|
// it may be a concurrent claimant rather than an entry we created.
|
|
if (removed.status !== 'removed'
|
|
&& !['ENOENT', 'ENOTDIR'].includes(removed.reason?.systemCode)) {
|
|
failures.push(`${entry.filePath}: ${removed.reason?.code || removed.status}`);
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${entry.filePath}: ${error.message}`);
|
|
} finally {
|
|
const released = releaseRegularFileBinding(entry.binding);
|
|
if (released.status !== 'released') {
|
|
failures.push(`${entry.filePath}: ${released.reason?.code || released.status}`);
|
|
}
|
|
}
|
|
}
|
|
registry.length = 0;
|
|
try {
|
|
if (!removeOwnedEmptyStagingDirectory(directory, identity, { throwOnFailure: true })) {
|
|
failures.push(`${directory}: staging directory was retained because its identity changed`);
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${directory}: ${error.message}`);
|
|
}
|
|
if (failures.length) throw new Error(failures.join('; '));
|
|
}
|
|
|
|
function createOwnedEmptyStagingDirectory(prefix) {
|
|
// Node's Windows mkdtemp binding does not consistently promote an ordinary
|
|
// UNC prefix past MAX_PATH. Use the equivalent namespaced spelling for the
|
|
// syscall while preserving the same physical directory identity.
|
|
const directory = fs.mkdtempSync(path.toNamespacedPath(prefix));
|
|
const metadata = fs.lstatSync(directory, { bigint: true });
|
|
if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.ino === 0n) {
|
|
throw new Error(`Private staging directory identity is unavailable: ${directory}`);
|
|
}
|
|
return {
|
|
directory,
|
|
identity: { device: metadata.dev, inode: metadata.ino },
|
|
};
|
|
}
|
|
|
|
function createStagingRetirementQuarantine(parent) {
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const directory = path.join(parent, `.archify-staging-remove-${randomUUID()}`);
|
|
try {
|
|
fs.mkdirSync(directory, { mode: 0o700 });
|
|
return directory;
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST') return undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const stagingRemovalSignal = new Int32Array(new SharedArrayBuffer(4));
|
|
const stagingRemovalAttempts = 10;
|
|
|
|
function removeEmptyStagingDirectoryWithRetry(directory) {
|
|
let failure;
|
|
for (let attempt = 0; attempt < stagingRemovalAttempts; attempt += 1) {
|
|
try {
|
|
fs.rmdirSync(directory);
|
|
return;
|
|
} catch (error) {
|
|
failure = error;
|
|
// SMB can acknowledge the last file removal before the directory view
|
|
// catches up. Retrying an empty-directory removal is claimant-safe: any
|
|
// real entry keeps returning ENOTEMPTY and is never removed recursively.
|
|
if (error?.code !== 'ENOTEMPTY' || attempt === stagingRemovalAttempts - 1) break;
|
|
Atomics.wait(
|
|
stagingRemovalSignal,
|
|
0,
|
|
0,
|
|
Math.min(5 * (2 ** attempt), 250),
|
|
);
|
|
}
|
|
}
|
|
throw failure;
|
|
}
|
|
|
|
function stagingDirectoryAppearsEmpty(directory) {
|
|
for (let attempt = 0; attempt < stagingRemovalAttempts; attempt += 1) {
|
|
try {
|
|
if (fs.readdirSync(directory).length === 0) return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (attempt === stagingRemovalAttempts - 1) break;
|
|
Atomics.wait(
|
|
stagingRemovalSignal,
|
|
0,
|
|
0,
|
|
Math.min(5 * (2 ** attempt), 250),
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function removeOwnedEmptyStagingDirectory(directory, identity, { throwOnFailure = false } = {}) {
|
|
let current;
|
|
try {
|
|
current = fs.lstatSync(directory, { bigint: true });
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false;
|
|
throw error;
|
|
}
|
|
if (!current.isDirectory()
|
|
|| current.isSymbolicLink()
|
|
|| current.ino === 0n
|
|
|| current.dev !== identity.device
|
|
|| current.ino !== identity.inode) return false;
|
|
if (!stagingDirectoryAppearsEmpty(directory)) return false;
|
|
const quarantine = createStagingRetirementQuarantine(path.dirname(directory));
|
|
if (!quarantine) return false;
|
|
const movedPath = path.join(quarantine, path.basename(directory));
|
|
try {
|
|
fs.renameSync(directory, movedPath);
|
|
} catch (error) {
|
|
if (throwOnFailure) throw error;
|
|
return false;
|
|
}
|
|
let moved;
|
|
try {
|
|
moved = fs.lstatSync(movedPath, { bigint: true });
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!moved.isDirectory()
|
|
|| moved.isSymbolicLink()
|
|
|| moved.ino === 0n
|
|
|| moved.dev !== identity.device
|
|
|| moved.ino !== identity.inode) {
|
|
// A claimant won the public move boundary. Preserve it under the private
|
|
// quarantine name; directories have no portable no-clobber restore link.
|
|
return false;
|
|
}
|
|
try {
|
|
// The unpredictable 0700 quarantine closes the public replacement race.
|
|
// Never recurse: unexpected contents remain available for recovery.
|
|
removeEmptyStagingDirectoryWithRetry(movedPath);
|
|
removeEmptyStagingDirectoryWithRetry(quarantine);
|
|
return true;
|
|
} catch (error) {
|
|
if (throwOnFailure) throw error;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function releaseOwnedStagingBindings(registry, fileBindingRuntime) {
|
|
const { releaseRegularFileBinding } = fileBindingRuntime || {};
|
|
if (typeof releaseRegularFileBinding !== 'function') return;
|
|
for (const entry of registry) releaseRegularFileBinding(entry.binding);
|
|
registry.length = 0;
|
|
}
|
|
|
|
function artifactIdentity(artifact) {
|
|
return { sha256: createHash('sha256').update(artifact).digest('hex'), bytes: artifact.byteLength };
|
|
}
|
|
|
|
function beginDeliveryAttempt({ ownership, input, pathsAlias, fileBindingRuntime }) {
|
|
const state = assertDeliveryOwnership(ownership, 'begin the delivery journal', { allowInitializing: true });
|
|
const {
|
|
output, pendingPath: journal, provenancePath, receiptId,
|
|
} = state;
|
|
for (const protectedPath of [input, output, provenancePath].filter(Boolean)) {
|
|
if (pathsAlias(journal, protectedPath)) throw new Error('Delivery journal aliases an input or output.');
|
|
}
|
|
const published = writeDeliveryProvenance(journal, {
|
|
schemaVersion: 1, command: 'deliver', status: 'pending', receiptId,
|
|
input, output: path.resolve(output),
|
|
}, {
|
|
beforeReplace: () => assertDeliveryOwnership(ownership, 'create the delivery journal', { allowInitializing: true }),
|
|
fileBindingRuntime,
|
|
});
|
|
state.pendingIdentity = {
|
|
dev: published.identity.device,
|
|
ino: published.identity.inode,
|
|
mode: published.identity.mode,
|
|
};
|
|
state.pendingContent = published.content;
|
|
assertDeliveryOwnership(ownership, 'finish creating the delivery journal', { allowInitializing: true });
|
|
}
|
|
|
|
function writeDeliveryProvenance(file, value, { beforeReplace, fileBindingRuntime } = {}) {
|
|
const {
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof backupPublicRegularFileBinding !== 'function'
|
|
|| typeof quarantineRemoveRegularFileBinding !== 'function'
|
|
|| typeof verifyRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
const error = new Error('The regular-file binding runtime is unavailable.');
|
|
error.deliveryJournalState = {
|
|
status: 'unknown',
|
|
reason: { code: 'atomic-output-runtime-unavailable' },
|
|
};
|
|
throw error;
|
|
}
|
|
const staging = createOwnedEmptyStagingDirectory(
|
|
path.join(path.dirname(file), '.archify-provenance-'),
|
|
);
|
|
const stagingDirectory = staging.directory;
|
|
const temporary = path.join(stagingDirectory, path.basename(file));
|
|
const backup = path.join(stagingDirectory, '.previous-journal');
|
|
const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
const expectedContent = artifactIdentity(serialized);
|
|
let candidateCapture;
|
|
let previousCapture;
|
|
let candidatePresent = false;
|
|
let backupPresent = false;
|
|
let backupVerified = false;
|
|
let published = false;
|
|
let retainStaging = false;
|
|
const bindingError = (result, action) => {
|
|
const error = new Error(`Could not ${action} delivery journal "${file}" safely (${result.reason?.code || 'unknown target state'}).`);
|
|
error.deliveryJournalState = result;
|
|
return error;
|
|
};
|
|
try {
|
|
fs.writeFileSync(temporary, serialized, { flag: 'wx' });
|
|
candidatePresent = true;
|
|
candidateCapture = captureRegularFileBinding(temporary, {
|
|
subject: 'delivery-journal-candidate',
|
|
expectedSha256: expectedContent.sha256,
|
|
expectedBytes: expectedContent.bytes,
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidateCapture.status !== 'captured') throw bindingError(candidateCapture, 'prepare the');
|
|
|
|
let existing = false;
|
|
try {
|
|
fs.lstatSync(file);
|
|
existing = true;
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
|
|
}
|
|
if (existing) {
|
|
previousCapture = captureRegularFileBinding(file, {
|
|
subject: 'previous-delivery-journal',
|
|
expectedLinks: 1,
|
|
});
|
|
if (previousCapture.status !== 'captured') {
|
|
throw bindingError(previousCapture, 'snapshot the existing');
|
|
}
|
|
}
|
|
beforeReplace?.();
|
|
if (previousCapture) {
|
|
const moved = backupPublicRegularFileBinding(previousCapture.binding, file, backup, {
|
|
subject: 'previous-delivery-journal',
|
|
});
|
|
backupPresent = moved.backupCreated === true;
|
|
backupVerified = moved.backupVerified === true;
|
|
if (moved.status !== 'backed-up') throw bindingError(moved, 'back up the existing');
|
|
}
|
|
|
|
const ready = verifyRegularFileBinding(candidateCapture.binding, {
|
|
filePath: temporary,
|
|
expectedLinks: 1,
|
|
});
|
|
if (ready.status !== 'match') throw bindingError(ready, 'verify the staged');
|
|
try {
|
|
fs.linkSync(temporary, file);
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') {
|
|
throw bindingError({
|
|
status: 'different',
|
|
reason: { code: 'target-claimed-during-publish', phase: 'journal-publish' },
|
|
}, 'publish the');
|
|
}
|
|
throw error;
|
|
}
|
|
published = true;
|
|
for (const target of [temporary, file]) {
|
|
const linked = verifyRegularFileBinding(candidateCapture.binding, {
|
|
filePath: target,
|
|
expectedLinks: 2,
|
|
});
|
|
if (linked.status !== 'match') throw bindingError(linked, 'verify the published');
|
|
}
|
|
const retiredCandidate = quarantineRemoveRegularFileBinding(
|
|
candidateCapture.binding,
|
|
temporary,
|
|
{ subject: 'delivery-journal-candidate', expectedLinks: 2 },
|
|
);
|
|
if (retiredCandidate.status !== 'removed') {
|
|
retainStaging = true;
|
|
throw bindingError(retiredCandidate, 'retire the staged');
|
|
}
|
|
candidatePresent = false;
|
|
const finalized = verifyRegularFileBinding(candidateCapture.binding, {
|
|
filePath: file,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') throw bindingError(finalized, 'finalize the');
|
|
if (backupPresent) {
|
|
const previous = verifyRegularFileBinding(previousCapture.binding, {
|
|
filePath: backup,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previous.status !== 'match') throw bindingError(previous, 'verify the previous');
|
|
const retiredBackup = quarantineRemoveRegularFileBinding(
|
|
previousCapture.binding,
|
|
backup,
|
|
{ subject: 'previous-delivery-journal', expectedLinks: 1 },
|
|
);
|
|
if (retiredBackup.status !== 'removed') {
|
|
retainStaging = true;
|
|
throw bindingError(retiredBackup, 'retire the previous');
|
|
}
|
|
backupPresent = false;
|
|
}
|
|
return {
|
|
identity: candidateCapture.identity,
|
|
content: candidateCapture.content,
|
|
};
|
|
} catch (cause) {
|
|
const rollbackErrors = [];
|
|
const publicRecoveryFiles = [];
|
|
if (cause.deliveryJournalState?.status === 'recovery-required'
|
|
&& cause.deliveryJournalState.recoveryFile) {
|
|
publicRecoveryFiles.push({
|
|
label: 'preserved journal replacement',
|
|
path: cause.deliveryJournalState.recoveryFile,
|
|
target: file,
|
|
});
|
|
}
|
|
if (published) {
|
|
try {
|
|
const removed = quarantineRemoveRegularFileBinding(candidateCapture.binding, file, {
|
|
subject: 'published-delivery-journal',
|
|
expectedLinks: candidatePresent ? 2 : 1,
|
|
});
|
|
if (removed.status === 'removed') {
|
|
published = false;
|
|
} else {
|
|
rollbackErrors.push(`the published journal could not be removed safely (${removed.reason?.code || 'unknown target state'})`);
|
|
if (removed.status === 'recovery-required') {
|
|
publicRecoveryFiles.push({
|
|
label: 'preserved journal replacement',
|
|
path: removed.recoveryFile,
|
|
target: file,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') rollbackErrors.push(`published journal cleanup failed (${error.message})`);
|
|
}
|
|
}
|
|
if (candidatePresent) {
|
|
try {
|
|
const current = verifyRegularFileBinding(candidateCapture.binding, {
|
|
filePath: temporary,
|
|
expectedLinks: published ? 2 : 1,
|
|
});
|
|
if (current.status === 'match') {
|
|
const retiredCandidate = quarantineRemoveRegularFileBinding(
|
|
candidateCapture.binding,
|
|
temporary,
|
|
{
|
|
subject: 'delivery-journal-candidate',
|
|
expectedLinks: published ? 2 : 1,
|
|
},
|
|
);
|
|
if (retiredCandidate.status !== 'removed') {
|
|
throw bindingError(retiredCandidate, 'retire the staged');
|
|
}
|
|
candidatePresent = false;
|
|
} else {
|
|
rollbackErrors.push('the staged journal candidate was replaced before cleanup');
|
|
retainStaging = true;
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') rollbackErrors.push(`staged journal cleanup failed (${error.message})`);
|
|
}
|
|
}
|
|
if (backupPresent) {
|
|
try {
|
|
if (!backupVerified) {
|
|
throw new Error('the backup entry was never verified as the previous journal');
|
|
}
|
|
if (pathEntryExists(file)) throw new Error('a new journal claimant prevents restoring the previous journal');
|
|
const previous = verifyRegularFileBinding(previousCapture.binding, {
|
|
filePath: backup,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previous.status !== 'match') {
|
|
backupVerified = false;
|
|
throw bindingError(previous, 'verify the previous');
|
|
}
|
|
try {
|
|
fs.linkSync(backup, file);
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') {
|
|
throw new Error('a new journal claimant prevents restoring the previous journal');
|
|
}
|
|
throw error;
|
|
}
|
|
for (const target of [backup, file]) {
|
|
const restored = verifyRegularFileBinding(previousCapture.binding, {
|
|
filePath: target,
|
|
expectedLinks: 2,
|
|
});
|
|
if (restored.status !== 'match') throw bindingError(restored, 'verify the restored');
|
|
}
|
|
const retiredBackup = quarantineRemoveRegularFileBinding(
|
|
previousCapture.binding,
|
|
backup,
|
|
{ subject: 'previous-delivery-journal', expectedLinks: 2 },
|
|
);
|
|
if (retiredBackup.status !== 'removed') {
|
|
throw bindingError(retiredBackup, 'retire the restored backup');
|
|
}
|
|
backupPresent = false;
|
|
const restored = verifyRegularFileBinding(previousCapture.binding, {
|
|
filePath: file,
|
|
expectedLinks: 1,
|
|
});
|
|
if (restored.status !== 'match') throw bindingError(restored, 'finalize the restored');
|
|
} catch (error) {
|
|
rollbackErrors.push(`previous journal restore failed (${error.message})`);
|
|
retainStaging = backupPresent || candidatePresent;
|
|
}
|
|
}
|
|
const claimantConflict = cause.deliveryJournalState?.reason?.code === 'target-claimed-during-publish';
|
|
if (rollbackErrors.length || claimantConflict) {
|
|
cause.deliveryJournalRecovery = {
|
|
recoveryRequired: true,
|
|
journal: file,
|
|
...(claimantConflict ? { claimantConflict: true } : {}),
|
|
rollbackErrors,
|
|
...(retainStaging ? {
|
|
recoveryDirectory: stagingDirectory,
|
|
} : publicRecoveryFiles.length ? {
|
|
recoveryDirectory: path.dirname(publicRecoveryFiles[0].path),
|
|
} : {}),
|
|
...(backupPresent || (candidatePresent && retainStaging) || publicRecoveryFiles.length ? {
|
|
recoveryFiles: [
|
|
...(backupPresent ? [{
|
|
label: backupVerified
|
|
? 'previous delivery journal'
|
|
: 'unverified preserved journal backup entry',
|
|
path: backup,
|
|
target: file,
|
|
verifiedPrevious: backupVerified,
|
|
}] : []),
|
|
...(candidatePresent && retainStaging ? [{ label: 'preserved staged journal entry', path: temporary }] : []),
|
|
...publicRecoveryFiles,
|
|
],
|
|
} : {}),
|
|
};
|
|
}
|
|
throw cause;
|
|
} finally {
|
|
if (candidateCapture?.binding) releaseRegularFileBinding(candidateCapture.binding);
|
|
if (previousCapture?.binding) releaseRegularFileBinding(previousCapture.binding);
|
|
if (!retainStaging) removeOwnedEmptyStagingDirectory(stagingDirectory, staging.identity);
|
|
}
|
|
}
|
|
|
|
function writeCapturedDeliveryProvenance(file, value, {
|
|
beforeCommit,
|
|
capture,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
} = {}) {
|
|
const {
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
if (!capture
|
|
|| typeof verifyAtomicOutput !== 'function'
|
|
|| typeof backupPublicRegularFileBinding !== 'function'
|
|
|| typeof captureRegularFileBinding !== 'function'
|
|
|| typeof quarantineRemoveRegularFileBinding !== 'function'
|
|
|| typeof verifyRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', {
|
|
status: 'unknown',
|
|
reason: { code: 'atomic-output-runtime-unavailable' },
|
|
});
|
|
}
|
|
const staging = createOwnedEmptyStagingDirectory(
|
|
path.join(path.dirname(file), '.archify-provenance-'),
|
|
);
|
|
const stagingDirectory = staging.directory;
|
|
const candidate = path.join(stagingDirectory, path.basename(file));
|
|
const backup = path.join(stagingDirectory, '.previous-provenance');
|
|
let candidateIdentity;
|
|
let candidateBinding;
|
|
let previousBinding;
|
|
let candidatePresent = false;
|
|
let backupPresent = false;
|
|
let backupVerified = false;
|
|
let published = false;
|
|
let retainStaging = false;
|
|
try {
|
|
const serialized = Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
const expectedContent = artifactIdentity(serialized);
|
|
fs.writeFileSync(candidate, serialized, { flag: 'wx' });
|
|
candidatePresent = true;
|
|
if (capture.mode !== null) fs.chmodSync(candidate, capture.mode);
|
|
const candidateCapture = captureRegularFileBinding(candidate, {
|
|
subject: 'delivery-provenance-candidate',
|
|
expectedSha256: expectedContent.sha256,
|
|
expectedBytes: expectedContent.bytes,
|
|
...(capture.mode === null ? {} : { expectedMode: capture.mode }),
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidateCapture.status !== 'captured') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', candidateCapture);
|
|
}
|
|
candidateBinding = candidateCapture.binding;
|
|
candidateIdentity = {
|
|
dev: candidateCapture.identity.device,
|
|
ino: candidateCapture.identity.inode,
|
|
};
|
|
beforeCommit?.();
|
|
const verification = verifyAtomicOutput(capture.snapshot);
|
|
if (verification.status !== 'match') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', verification);
|
|
}
|
|
if (capture.snapshot.target.kind === 'file') {
|
|
const expected = capture.snapshot.target;
|
|
const previous = captureRegularFileBinding(capture.commitPath, {
|
|
subject: 'previous-delivery-provenance',
|
|
expectedIdentity: { device: expected.device, inode: expected.inode },
|
|
expectedMode: expected.mode,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previous.status !== 'captured') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', previous);
|
|
}
|
|
previousBinding = previous.binding;
|
|
const moved = backupPublicRegularFileBinding(
|
|
previousBinding,
|
|
capture.commitPath,
|
|
backup,
|
|
{ subject: 'previous-delivery-provenance' },
|
|
);
|
|
backupPresent = moved.backupCreated === true;
|
|
backupVerified = moved.backupVerified === true;
|
|
if (moved.status !== 'backed-up') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', moved);
|
|
}
|
|
}
|
|
const currentCandidate = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: candidate,
|
|
expectedLinks: 1,
|
|
});
|
|
if (currentCandidate.status !== 'match') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', currentCandidate);
|
|
}
|
|
try {
|
|
fs.linkSync(candidate, capture.commitPath);
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', {
|
|
status: 'different',
|
|
reason: { code: 'target-claimed-during-publish' },
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
published = true;
|
|
for (const target of [candidate, capture.commitPath]) {
|
|
const linked = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: target,
|
|
expectedLinks: 2,
|
|
});
|
|
if (linked.status !== 'match') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', linked);
|
|
}
|
|
}
|
|
const retiredCandidate = quarantineRemoveRegularFileBinding(
|
|
candidateBinding,
|
|
candidate,
|
|
{ subject: 'delivery-provenance-candidate', expectedLinks: 2 },
|
|
);
|
|
if (retiredCandidate.status !== 'removed') {
|
|
retainStaging = true;
|
|
throw deliveryTargetStateError(file, 'delivery provenance', retiredCandidate);
|
|
}
|
|
candidatePresent = false;
|
|
const finalized = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: capture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', finalized);
|
|
}
|
|
if (backupPresent) {
|
|
const currentBackup = verifyRegularFileBinding(previousBinding, {
|
|
filePath: backup,
|
|
expectedLinks: 1,
|
|
});
|
|
if (currentBackup.status !== 'match') {
|
|
backupVerified = false;
|
|
throw deliveryTargetStateError(file, 'delivery provenance', {
|
|
status: 'different',
|
|
reason: { code: 'backup-identity-changed-before-finalize' },
|
|
});
|
|
}
|
|
const retiredBackup = quarantineRemoveRegularFileBinding(
|
|
previousBinding,
|
|
backup,
|
|
{ subject: 'previous-delivery-provenance', expectedLinks: 1 },
|
|
);
|
|
if (retiredBackup.status !== 'removed') {
|
|
retainStaging = true;
|
|
throw deliveryTargetStateError(file, 'delivery provenance', retiredBackup);
|
|
}
|
|
backupPresent = false;
|
|
}
|
|
} catch (cause) {
|
|
const rollbackErrors = [];
|
|
const publicRecoveryFiles = [];
|
|
if (cause.deliveryTargetState?.status === 'recovery-required'
|
|
&& cause.deliveryTargetState.recoveryFile) {
|
|
publicRecoveryFiles.push({
|
|
label: 'preserved provenance replacement',
|
|
path: cause.deliveryTargetState.recoveryFile,
|
|
target: capture.commitPath,
|
|
});
|
|
}
|
|
if (published) {
|
|
try {
|
|
const removed = quarantineRemoveRegularFileBinding(candidateBinding, capture.commitPath, {
|
|
subject: 'published-delivery-provenance',
|
|
expectedLinks: candidatePresent ? 2 : 1,
|
|
});
|
|
if (removed.status === 'removed') {
|
|
published = false;
|
|
} else {
|
|
rollbackErrors.push(`the published provenance could not be removed safely (${removed.reason?.code || 'unknown target state'})`);
|
|
if (removed.status === 'recovery-required') {
|
|
publicRecoveryFiles.push({
|
|
label: 'preserved provenance replacement',
|
|
path: removed.recoveryFile,
|
|
target: capture.commitPath,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') rollbackErrors.push(`published provenance cleanup failed (${error.message})`);
|
|
}
|
|
}
|
|
if (candidatePresent) {
|
|
try {
|
|
const current = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: candidate,
|
|
expectedLinks: published ? 2 : 1,
|
|
});
|
|
if (current.status === 'match') {
|
|
const retiredCandidate = quarantineRemoveRegularFileBinding(
|
|
candidateBinding,
|
|
candidate,
|
|
{
|
|
subject: 'delivery-provenance-candidate',
|
|
expectedLinks: published ? 2 : 1,
|
|
},
|
|
);
|
|
if (retiredCandidate.status !== 'removed') {
|
|
throw deliveryTargetStateError(
|
|
file,
|
|
'delivery provenance',
|
|
retiredCandidate,
|
|
);
|
|
}
|
|
candidatePresent = false;
|
|
} else {
|
|
rollbackErrors.push('the staged provenance candidate was replaced before cleanup');
|
|
retainStaging = true;
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') {
|
|
rollbackErrors.push(`staged provenance cleanup failed (${error.message})`);
|
|
retainStaging = true;
|
|
}
|
|
}
|
|
}
|
|
if (backupPresent) {
|
|
try {
|
|
if (!backupVerified) {
|
|
throw new Error('the backup entry was never verified as the previous provenance');
|
|
}
|
|
if (pathEntryExists(capture.commitPath)) {
|
|
throw new Error('a new target claimant prevents restoring the previous provenance');
|
|
}
|
|
const currentBackup = verifyRegularFileBinding(previousBinding, {
|
|
filePath: backup,
|
|
expectedLinks: 1,
|
|
});
|
|
if (currentBackup.status !== 'match') {
|
|
backupVerified = false;
|
|
throw new Error('the previous provenance backup identity changed');
|
|
}
|
|
fs.linkSync(backup, capture.commitPath);
|
|
for (const target of [backup, capture.commitPath]) {
|
|
const restored = verifyRegularFileBinding(previousBinding, {
|
|
filePath: target,
|
|
expectedLinks: 2,
|
|
});
|
|
if (restored.status !== 'match') {
|
|
throw new Error('the restored provenance identity could not be verified');
|
|
}
|
|
}
|
|
const retiredBackup = quarantineRemoveRegularFileBinding(
|
|
previousBinding,
|
|
backup,
|
|
{ subject: 'previous-delivery-provenance', expectedLinks: 2 },
|
|
);
|
|
if (retiredBackup.status !== 'removed') {
|
|
throw deliveryTargetStateError(file, 'delivery provenance', retiredBackup);
|
|
}
|
|
backupPresent = false;
|
|
const restoredFinal = verifyRegularFileBinding(previousBinding, {
|
|
filePath: capture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (restoredFinal.status !== 'match') {
|
|
throw new Error('the restored provenance link count could not be verified');
|
|
}
|
|
} catch (error) {
|
|
rollbackErrors.push(`previous provenance restore failed (${error.message})`);
|
|
retainStaging = true;
|
|
}
|
|
}
|
|
const recoveryFiles = [
|
|
...(backupPresent ? [{
|
|
label: backupVerified
|
|
? 'preserved delivery provenance backup entry'
|
|
: 'unverified preserved delivery provenance backup entry',
|
|
path: backup,
|
|
target: capture.commitPath,
|
|
verifiedPrevious: backupVerified,
|
|
}] : []),
|
|
...(candidatePresent && retainStaging ? [{ label: 'preserved staged provenance entry', path: candidate }] : []),
|
|
...publicRecoveryFiles,
|
|
];
|
|
if (rollbackErrors.length) {
|
|
cause.deliveryProvenanceRecovery = {
|
|
recoveryRequired: true,
|
|
rollbackErrors,
|
|
...(recoveryFiles.length ? {
|
|
recoveryDirectory: retainStaging
|
|
? stagingDirectory
|
|
: path.dirname(recoveryFiles[0].path),
|
|
recoveryFiles,
|
|
} : {}),
|
|
};
|
|
}
|
|
if (rollbackErrors.length && cause?.archifyDiagnostics?.[0]) {
|
|
const [issue] = cause.archifyDiagnostics;
|
|
issue.evidence = {
|
|
...(issue.evidence || {}),
|
|
rollbackErrors,
|
|
...(recoveryFiles.length ? {
|
|
recoveryDirectory: retainStaging
|
|
? stagingDirectory
|
|
: path.dirname(recoveryFiles[0].path),
|
|
recoveryFile: recoveryFiles[0].path,
|
|
recoveryFiles,
|
|
} : {}),
|
|
};
|
|
}
|
|
throw cause;
|
|
} finally {
|
|
if (candidateBinding) releaseRegularFileBinding(candidateBinding);
|
|
if (previousBinding) releaseRegularFileBinding(previousBinding);
|
|
if (!retainStaging) removeOwnedEmptyStagingDirectory(stagingDirectory, staging.identity);
|
|
}
|
|
}
|
|
|
|
function recordDeliveryFailure(options) {
|
|
const {
|
|
output, stage, input, error, receiptId, pathsAlias, ownership: suppliedOwnership,
|
|
releaseOwnership: shouldReleaseSuppliedOwnership = false, legacyOutputPaths = [],
|
|
captureAtomicOutput, verifyAtomicOutput, fileBindingRuntime,
|
|
} = options;
|
|
if (!output || !/\.html?$/i.test(output)) return { ok: true, status: 'not-applicable' };
|
|
let ownership = suppliedOwnership;
|
|
let acquiredHere = false;
|
|
if (!ownership) {
|
|
try {
|
|
ownership = acquireDeliveryLock(
|
|
output,
|
|
receiptId,
|
|
input,
|
|
pathsAlias,
|
|
(lockError, initializingOwnership) => recordDeliveryFailure({
|
|
...options,
|
|
error: `Could not start delivery for "${output}": ${lockError.message}`,
|
|
ownership: initializingOwnership,
|
|
releaseOwnership: false,
|
|
}),
|
|
legacyOutputPaths,
|
|
fileBindingRuntime,
|
|
);
|
|
acquiredHere = true;
|
|
} catch (lockError) {
|
|
const reportedLockError = lockError?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE'
|
|
&& lockError.sidecarNamespaceReason?.code === 'sidecar-directory-missing'
|
|
? Object.assign(
|
|
new Error(`Delivery lock parent directory does not exist for "${output}".`),
|
|
{ code: 'ENOENT', cause: lockError },
|
|
)
|
|
: lockError;
|
|
return {
|
|
...(lockError.deliveryFailureRecord || { ok: false, status: 'unrecorded' }),
|
|
lockError: reportedLockError,
|
|
};
|
|
}
|
|
}
|
|
const state = deliveryOwnershipStates.get(ownership);
|
|
let recorded;
|
|
if (!state?.deliveryTargets) {
|
|
try {
|
|
captureDeliveryTargetState({
|
|
state,
|
|
requestedOutput: legacyOutputPaths[0] || output,
|
|
pathsAlias,
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
});
|
|
} catch (captureError) {
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
diagnostic: captureError.archifyDiagnostics?.[0] || diagnostic({
|
|
code: 'output/target-indeterminate',
|
|
message: captureError.message,
|
|
subject: { output },
|
|
evidence: { ...(captureError?.code ? { systemCode: captureError.code } : {}) },
|
|
supportedFixes: ['use ordinary absent or regular-file delivery targets, then retry'],
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
const stableOutput = state?.output || canonicalExistingDeliveryOutput(output);
|
|
// Keep independent evidence even when the artifact is unreadable or the
|
|
// provenance target is locked, aliases the input, or cannot be replaced.
|
|
let journalError;
|
|
if (!recorded && !state?.pendingIdentity) {
|
|
try {
|
|
beginDeliveryAttempt({ ownership, input, pathsAlias, fileBindingRuntime });
|
|
} catch (cause) {
|
|
if (cause.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
recorded = { ok: false, status: 'unrecorded', ownershipError: cause };
|
|
} else if (cause.deliveryJournalRecovery?.recoveryRequired) {
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
journalRecovery: cause,
|
|
diagnostic: deliveryJournalRecoveryDiagnostic(stableOutput, cause),
|
|
};
|
|
}
|
|
journalError = cause;
|
|
}
|
|
}
|
|
if (!recorded) {
|
|
let artifact;
|
|
let artifactBinding;
|
|
const {
|
|
captureRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
try {
|
|
if (typeof captureRegularFileBinding === 'function') {
|
|
const artifactCapture = captureRegularFileBinding(stableOutput, {
|
|
subject: 'failed-delivery-artifact',
|
|
expectedLinks: 1,
|
|
includeContent: true,
|
|
});
|
|
if (artifactCapture.status === 'captured') {
|
|
artifact = artifactCapture.content.buffer;
|
|
artifactBinding = artifactCapture.binding;
|
|
} else if (['ENOENT', 'ENOTDIR'].includes(artifactCapture.reason?.systemCode)) {
|
|
recorded = { ok: true, status: 'absent' };
|
|
}
|
|
// A failed marker does not need an artifact hash to invalidate old
|
|
// evidence. Non-regular or changing paths are never followed.
|
|
}
|
|
if (!recorded) {
|
|
const provenancePath = state?.provenancePath || deliveryProvenancePath(stableOutput);
|
|
let aliasesProtectedPath;
|
|
try {
|
|
aliasesProtectedPath = (input && pathsAlias(provenancePath, input))
|
|
|| pathsAlias(provenancePath, stableOutput);
|
|
} catch (aliasError) {
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
diagnostic: diagnostic({
|
|
code: 'delivery/provenance-path-resolution',
|
|
message: 'Delivery failure provenance could not resolve its target safely.',
|
|
subject: { output: stableOutput, provenance: provenancePath },
|
|
evidence: {
|
|
reason: aliasError.message,
|
|
...(aliasError?.code ? { systemCode: aliasError.code } : {}),
|
|
},
|
|
supportedFixes: ['remove the sidecar path conflict or symbolic-link cycle, then rerun deliver'],
|
|
}),
|
|
};
|
|
}
|
|
if (!recorded && aliasesProtectedPath) {
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
diagnostic: diagnostic({
|
|
code: 'delivery/provenance-target-alias',
|
|
message: 'Delivery failure provenance could not be recorded without replacing an input or the artifact.',
|
|
subject: { output: stableOutput, provenance: provenancePath },
|
|
evidence: { ...(input ? { input: path.resolve(input) } : {}) },
|
|
supportedFixes: ['choose an output whose .delivery.json sidecar is distinct from every input and the HTML artifact'],
|
|
}),
|
|
};
|
|
}
|
|
if (!recorded) {
|
|
try {
|
|
writeCapturedDeliveryProvenance(provenancePath, {
|
|
schemaVersion: 1,
|
|
receiptId,
|
|
status: 'failed',
|
|
command: 'deliver',
|
|
stage,
|
|
input,
|
|
output: stableOutput,
|
|
...(artifact ? { artifact: artifactIdentity(artifact) } : {}),
|
|
error,
|
|
}, {
|
|
beforeCommit: () => {
|
|
assertDeliveryOwnership(ownership, 'record failed delivery provenance', {
|
|
allowInitializing: true,
|
|
pending: state?.pendingIdentity ? 'owned' : 'ignore',
|
|
});
|
|
if (artifactBinding) {
|
|
const verification = verifyRegularFileBinding(artifactBinding, {
|
|
filePath: stableOutput,
|
|
expectedLinks: 1,
|
|
});
|
|
if (verification.status !== 'match') {
|
|
throw deliveryTargetStateError(
|
|
stableOutput,
|
|
'failed delivery artifact',
|
|
verification,
|
|
);
|
|
}
|
|
}
|
|
},
|
|
capture: state?.deliveryTargets?.provenance,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
});
|
|
recorded = { ok: true, status: 'failed' };
|
|
} catch (writeError) {
|
|
if (writeError.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
recorded = { ok: false, status: 'unrecorded', ownershipError: writeError };
|
|
} else if (writeError.deliveryProvenanceRecovery?.recoveryRequired) {
|
|
const recovery = writeError.deliveryProvenanceRecovery;
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
provenanceRecovery: writeError,
|
|
diagnostic: diagnostic({
|
|
code: 'delivery/provenance-recovery-required',
|
|
message: 'Failed-delivery provenance could not be rolled back safely.',
|
|
subject: { output: stableOutput, provenance: provenancePath },
|
|
evidence: {
|
|
reason: writeError.message,
|
|
recoveryRequired: true,
|
|
...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}),
|
|
...(recovery.recoveryFiles?.length ? { recoveryFiles: recovery.recoveryFiles } : {}),
|
|
...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}),
|
|
},
|
|
supportedFixes: ['leave the delivery lock and journal untouched, inspect the retained recovery files, then recover serially'],
|
|
}),
|
|
};
|
|
} else if (writeError.deliveryTargetState) {
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
diagnostic: writeError.archifyDiagnostics?.[0],
|
|
};
|
|
} else {
|
|
recorded = {
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
diagnostic: diagnostic({
|
|
code: 'delivery/provenance-write',
|
|
message: 'The failed delivery could not persist its stale-artifact marker.',
|
|
subject: { output: stableOutput, provenance: provenancePath },
|
|
evidence: {
|
|
reason: writeError.message,
|
|
...(writeError?.code ? { systemCode: writeError.code } : {}),
|
|
failureJournalRecorded: !journalError,
|
|
...(journalError ? { journalError: journalError.message } : {}),
|
|
},
|
|
supportedFixes: ['restore write access to the output directory, then rerun deliver before trusting the artifact'],
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
if (artifactBinding && typeof releaseRegularFileBinding === 'function') {
|
|
releaseRegularFileBinding(artifactBinding);
|
|
}
|
|
}
|
|
}
|
|
if (recorded?.journalRecovery || recorded?.provenanceRecovery) return recorded;
|
|
if (acquiredHere || shouldReleaseSuppliedOwnership) {
|
|
try {
|
|
releaseDeliveryOwnership(ownership, { allowInitializing: true });
|
|
} catch (lockError) {
|
|
if (lockError.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
const ownershipError = recorded.ownershipError || lockError;
|
|
if (options.recoveryDirectory) {
|
|
ownershipError.deliveryCommitDetails = {
|
|
...(ownershipError.deliveryCommitDetails || {}),
|
|
recoveryRequired: true,
|
|
recoveryDirectory: options.recoveryDirectory,
|
|
recoverableBackups: [],
|
|
};
|
|
}
|
|
return {
|
|
...recorded,
|
|
ok: false,
|
|
status: 'unrecorded',
|
|
ownershipError,
|
|
lockError,
|
|
};
|
|
}
|
|
return { ...recorded, lockError: recorded.ownershipError || lockError };
|
|
}
|
|
}
|
|
return recorded;
|
|
}
|
|
|
|
function deliverySuccessProvenance(receipt) {
|
|
return {
|
|
schemaVersion: 1,
|
|
receiptId: receipt.receiptId,
|
|
status: 'current',
|
|
command: 'deliver',
|
|
type: receipt.type,
|
|
input: receipt.input,
|
|
output: receipt.output,
|
|
specification: receipt.specification,
|
|
artifact: receipt.artifact,
|
|
};
|
|
}
|
|
|
|
function inspectDeliveryProvenance(artifactPath, artifact, {
|
|
requireProvenance = false,
|
|
pathIdentityRuntime,
|
|
expectedSidecar,
|
|
inspection,
|
|
} = {}) {
|
|
let sidecar = deliveryProvenancePath(artifactPath);
|
|
const rawHeadSidecar = rawHeadDeliveryProvenancePath(artifactPath);
|
|
const pending = deliveryPendingPath(artifactPath);
|
|
const rawHeadPending = rawHeadDeliveryPendingPath(artifactPath);
|
|
const legacyExtensionPending = legacyExtensionDeliverySidecarPath(
|
|
artifactPath,
|
|
'.delivery-pending.json',
|
|
);
|
|
const legacyExtensionSidecar = legacyExtensionDeliverySidecarPath(
|
|
artifactPath,
|
|
'.delivery.json',
|
|
);
|
|
const stableOutput = canonicalExistingDeliveryOutput(artifactPath);
|
|
const locks = [
|
|
deliveryLockPath(stableOutput),
|
|
...legacyDeliveryLockPaths([artifactPath, stableOutput]),
|
|
];
|
|
let foundPending;
|
|
try {
|
|
for (const candidate of [...new Set([
|
|
pending,
|
|
rawHeadPending,
|
|
legacyExtensionPending,
|
|
].filter(Boolean))]) {
|
|
if (pathEntryExists(candidate)) {
|
|
foundPending = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (foundPending) {
|
|
return {
|
|
ok: false, status: 'failed',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-failed',
|
|
message: 'A delivery attempt is unfinished or failed; complete a successful deliver before trusting this artifact.',
|
|
subject: { artifact: path.resolve(artifactPath), provenance: sidecar },
|
|
evidence: { pendingJournal: foundPending, found: true },
|
|
supportedFixes: ['finish any active delivery, then rerun deliver successfully; retain the pending journal until recovery succeeds'],
|
|
})],
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return invalidProvenance(artifactPath, foundPending || pending, error.message);
|
|
}
|
|
for (const lock of locks) {
|
|
try {
|
|
if (pathEntryExists(lock)) {
|
|
return {
|
|
ok: false, status: 'locked',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-locked',
|
|
message: 'Delivery ownership is unresolved while the output lock remains; complete or recover delivery before trusting this artifact.',
|
|
subject: { artifact: path.resolve(artifactPath), lock },
|
|
evidence: { deliveryLock: lock, found: true },
|
|
supportedFixes: ['finish the active delivery or inspect and recover the preserved lock, then rerun deliver successfully'],
|
|
})],
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return invalidProvenance(artifactPath, lock, error.message);
|
|
}
|
|
}
|
|
let found;
|
|
try {
|
|
found = pathEntryExists(sidecar);
|
|
} catch (error) {
|
|
return invalidProvenance(artifactPath, sidecar, error.message);
|
|
}
|
|
if (!found && rawHeadSidecar) {
|
|
try {
|
|
found = pathEntryExists(rawHeadSidecar);
|
|
if (found) sidecar = rawHeadSidecar;
|
|
} catch (error) {
|
|
return invalidProvenance(artifactPath, rawHeadSidecar, error.message);
|
|
}
|
|
}
|
|
if (!found && legacyExtensionSidecar) {
|
|
try {
|
|
found = pathEntryExists(legacyExtensionSidecar);
|
|
if (found) sidecar = legacyExtensionSidecar;
|
|
} catch (error) {
|
|
return invalidProvenance(artifactPath, legacyExtensionSidecar, error.message);
|
|
}
|
|
}
|
|
if (!found) {
|
|
if (!requireProvenance) return { ok: true, status: 'unknown' };
|
|
const message = 'Delivery provenance is required, but the artifact has no .delivery.json sidecar.';
|
|
return {
|
|
ok: false,
|
|
status: 'unknown',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-required',
|
|
message,
|
|
subject: { artifact: path.resolve(artifactPath), provenance: sidecar },
|
|
evidence: { required: true, found: false },
|
|
supportedFixes: ['rerun deliver for the current specification, then retry with --require-provenance'],
|
|
})],
|
|
};
|
|
}
|
|
let receipt;
|
|
let sidecarCapture;
|
|
try {
|
|
const {
|
|
captureRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = pathIdentityRuntime || {};
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
sidecarCapture = captureRegularFileBinding(sidecar, {
|
|
subject: 'delivery-provenance',
|
|
...(expectedSidecar ? {
|
|
expectedIdentity: expectedSidecar.identity,
|
|
expectedSha256: expectedSidecar.content.sha256,
|
|
expectedBytes: expectedSidecar.content.bytes,
|
|
expectedMode: expectedSidecar.mode,
|
|
} : {}),
|
|
expectedLinks: 1,
|
|
includeContent: true,
|
|
});
|
|
if (sidecarCapture.status !== 'captured') {
|
|
if (sidecarCapture.reason?.code === 'delivery-provenance-hardlinked') {
|
|
return {
|
|
ok: false,
|
|
status: 'unsupported',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-hardlink-unsupported',
|
|
message: 'Delivery provenance is hard-linked and cannot prove that every linked name was updated atomically.',
|
|
subject: { artifact: path.resolve(artifactPath), provenance: sidecar },
|
|
evidence: { links: sidecarCapture.reason.links },
|
|
supportedFixes: ['remove extra hard links to the provenance sidecar, then rerun deliver'],
|
|
})],
|
|
};
|
|
}
|
|
throw new Error(`Delivery provenance is not a stable regular file (${sidecarCapture.reason?.code || 'unknown target state'}).`);
|
|
}
|
|
receipt = JSON.parse(sidecarCapture.content.buffer.toString('utf8'));
|
|
const identityValid = (value) => value && typeof value.sha256 === 'string' && /^[0-9a-f]{64}$/.test(value.sha256)
|
|
&& Number.isSafeInteger(value.bytes) && value.bytes >= 0;
|
|
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
|
|
|| receipt.schemaVersion !== 1 || receipt.command !== 'deliver'
|
|
|| typeof receipt.receiptId !== 'string'
|
|
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(receipt.receiptId)
|
|
|| typeof receipt.output !== 'string' || !path.isAbsolute(receipt.output)
|
|
|| typeof receipt.input !== 'string' || !path.isAbsolute(receipt.input)
|
|
|| !['current', 'failed'].includes(receipt.status)
|
|
|| (receipt.status === 'current' && (!TYPES.has(receipt.type)
|
|
|| !identityValid(receipt.specification) || !identityValid(receipt.artifact)))
|
|
|| (receipt.status === 'failed' && typeof receipt.stage !== 'string')) {
|
|
throw new Error('Delivery provenance does not conform to the supported receipt schema.');
|
|
}
|
|
if (inspection) {
|
|
inspection.sidecar = {
|
|
path: sidecar,
|
|
identity: {
|
|
device: sidecarCapture.identity.device,
|
|
inode: sidecarCapture.identity.inode,
|
|
},
|
|
content: {
|
|
sha256: sidecarCapture.content.sha256,
|
|
bytes: sidecarCapture.content.bytes,
|
|
},
|
|
mode: sidecarCapture.mode,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
const message = `Could not read delivery provenance: ${error.message}`;
|
|
return {
|
|
ok: false,
|
|
status: 'invalid',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-invalid',
|
|
message,
|
|
subject: { artifact: path.resolve(artifactPath), provenance: sidecar },
|
|
evidence: { reason: error.message },
|
|
supportedFixes: ['rerun deliver to replace the invalid provenance sidecar'],
|
|
})],
|
|
};
|
|
} finally {
|
|
if (sidecarCapture?.binding) {
|
|
pathIdentityRuntime.releaseRegularFileBinding(sidecarCapture.binding);
|
|
}
|
|
}
|
|
let outputIdentity;
|
|
if (typeof pathIdentityRuntime?.sameEntry !== 'function') {
|
|
const error = pathIdentityRuntime?.error;
|
|
outputIdentity = {
|
|
status: 'unknown',
|
|
reason: {
|
|
code: 'path-semantics-runtime-unavailable',
|
|
...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
|
|
},
|
|
};
|
|
} else {
|
|
try {
|
|
outputIdentity = pathIdentityRuntime.sameEntry(receipt.output, artifactPath);
|
|
} catch (error) {
|
|
outputIdentity = {
|
|
status: 'unknown',
|
|
reason: {
|
|
code: 'path-identity-check-failed',
|
|
...(typeof error?.code === 'string' ? { systemCode: error.code } : {}),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
if (outputIdentity.status === 'unknown') {
|
|
return {
|
|
ok: false,
|
|
status: 'invalid',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-output-indeterminate',
|
|
message: 'Delivery provenance could not determine whether its recorded output identifies the inspected artifact.',
|
|
subject: {
|
|
artifact: path.resolve(artifactPath),
|
|
provenance: sidecar,
|
|
recordedOutput: receipt.output,
|
|
},
|
|
evidence: { pathIdentity: outputIdentity.reason },
|
|
supportedFixes: ['restore access to both the artifact and its recorded output path, then rerun deliver successfully'],
|
|
})],
|
|
};
|
|
}
|
|
if (outputIdentity.status !== 'match') {
|
|
return invalidProvenance(
|
|
artifactPath,
|
|
sidecar,
|
|
'The recorded delivery output does not identify this artifact.',
|
|
{ recordedOutput: receipt.output, pathIdentity: outputIdentity.reason },
|
|
);
|
|
}
|
|
const actualSha256 = createHash('sha256').update(artifact).digest('hex');
|
|
if (receipt.status === 'failed') {
|
|
const message = 'The artifact is stale because the latest delivery attempt failed.';
|
|
return {
|
|
ok: false,
|
|
status: 'failed',
|
|
receiptId: receipt.receiptId,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-failed',
|
|
message,
|
|
subject: { artifact: path.resolve(artifactPath), provenance: sidecar },
|
|
evidence: { stage: receipt.stage, deliveryReceiptId: receipt.receiptId },
|
|
supportedFixes: ['repair the specification and complete a successful deliver before checking this artifact'],
|
|
})],
|
|
};
|
|
}
|
|
if (receipt.artifact?.sha256 !== actualSha256 || receipt.artifact?.bytes !== artifact.byteLength) {
|
|
const message = 'The artifact bytes do not match their delivery provenance.';
|
|
return {
|
|
ok: false,
|
|
status: 'mismatch',
|
|
receiptId: receipt.receiptId,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-mismatch',
|
|
message,
|
|
subject: { artifact: path.resolve(artifactPath), provenance: sidecar },
|
|
evidence: {
|
|
expectedSha256: receipt.artifact?.sha256,
|
|
actualSha256,
|
|
expectedBytes: receipt.artifact?.bytes,
|
|
actualBytes: artifact.byteLength,
|
|
},
|
|
supportedFixes: ['restore the delivered artifact or rerun deliver to create a matching artifact and provenance pair'],
|
|
})],
|
|
};
|
|
}
|
|
return { ok: true, status: 'current', receiptId: receipt.receiptId, artifact: receipt.artifact };
|
|
}
|
|
|
|
function invalidProvenance(artifactPath, sidecar, reason, evidence = {}) {
|
|
return {
|
|
ok: false, status: 'invalid',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-invalid', message: 'Delivery provenance could not be verified.',
|
|
subject: { artifact: artifactPath, provenance: sidecar }, evidence: { reason, ...evidence },
|
|
supportedFixes: ['restore access to the delivery records, then rerun deliver successfully'],
|
|
})],
|
|
};
|
|
}
|
|
|
|
function usage() {
|
|
return `Usage:
|
|
archify render <type> <input.json> [output.html] [--quality standard|showcase] [--repo-root path]
|
|
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]
|
|
archify finalize <type> <input.json> <output.html> [--json] [--receipt path] [--out-dir <dir>] [--quality standard|showcase] [--repo-root path] [--candidate-sha256 hex]
|
|
archify preview <type> <input.json> [output.html] [--no-open] [--quality standard|showcase] [--repo-root path]
|
|
archify validate <type> <input.json> [--json] [--layout-json] [--quality standard|showcase] [--repo-root path]
|
|
archify migrate workflow <old.json> <new.json> --to-schema 2 [--output portable.html] [--json] [--repo-root path]
|
|
archify inspect <type> <input.json>
|
|
archify check <output.html> [--json] [--require-provenance]
|
|
archify browser-check <output.html> [--json|--summary] [--require-provenance] [--out-dir <dir>]
|
|
archify visual-check <output.html> [--json|--summary] [--require-provenance] [--out-dir <dir>]
|
|
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 rejectCliArgument(message, details = {}) {
|
|
const error = new Error(message);
|
|
error.archifyArgument = {
|
|
code: details.code || 'cli/invalid-arguments',
|
|
subject: details.subject || {},
|
|
evidence: details.evidence || {},
|
|
supportedFixes: details.supportedFixes || ['correct the command arguments and retry'],
|
|
};
|
|
throw error;
|
|
}
|
|
|
|
function rendererPath(type) {
|
|
if (!TYPES.has(type)) {
|
|
rejectCliArgument(`Unknown diagram type "${type}". Expected one of: ${[...TYPES].join(', ')}`, {
|
|
code: 'cli/unknown-diagram-type',
|
|
subject: { type },
|
|
evidence: { supportedTypes: [...TYPES] },
|
|
supportedFixes: [`use 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('--')) rejectCliArgument('--quality requires standard or showcase.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--quality' },
|
|
supportedFixes: ['provide --quality standard or --quality showcase'],
|
|
});
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--quality=')) {
|
|
quality = arg.slice('--quality='.length);
|
|
if (!quality) rejectCliArgument('--quality requires standard or showcase.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--quality' },
|
|
supportedFixes: ['provide --quality standard or --quality showcase'],
|
|
});
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
if (quality !== undefined && !['standard', 'showcase'].includes(quality)) {
|
|
rejectCliArgument(`Unknown quality profile "${quality}". Expected standard or showcase.`, {
|
|
code: 'cli/invalid-option-value',
|
|
subject: { option: '--quality' },
|
|
evidence: { value: quality, supportedValues: ['standard', 'showcase'] },
|
|
supportedFixes: ['use --quality standard or --quality 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('--')) rejectCliArgument('--repo-root requires a repository path.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--repo-root' },
|
|
supportedFixes: ['provide one repository path after --repo-root'],
|
|
});
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--repo-root=')) {
|
|
repoRoot = arg.slice('--repo-root='.length);
|
|
if (!repoRoot) rejectCliArgument('--repo-root requires a repository path.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--repo-root' },
|
|
supportedFixes: ['provide one repository path after --repo-root'],
|
|
});
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
return { rest, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined };
|
|
}
|
|
|
|
function extractOutDirArgs(args) {
|
|
const rest = [];
|
|
let outDir;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--out-dir') {
|
|
outDir = args[index + 1];
|
|
if (!outDir || outDir.startsWith('--')) fail('--out-dir requires a directory path.');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--out-dir=')) {
|
|
outDir = arg.slice('--out-dir='.length);
|
|
if (!outDir) fail('--out-dir requires a directory path.');
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
return { rest, outDir };
|
|
}
|
|
|
|
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 deliveryLockFailureDiagnostic(output, error) {
|
|
const code = error.deliveryOwnershipCode || error.deliveryLockCode || 'delivery/lock-acquire';
|
|
if (code === 'delivery/legacy-pending') {
|
|
return diagnostic({
|
|
code,
|
|
message: `A pre-namespace delivery journal blocks delivery for "${output}".`,
|
|
subject: { output, journal: error.deliveryLockPath },
|
|
evidence: { pendingJournal: error.deliveryLockPath, found: true },
|
|
supportedFixes: ['recover the unfinished delivery and remove only its journal after recovery succeeds'],
|
|
});
|
|
}
|
|
const lock = error.deliveryOwnershipDetails?.lock
|
|
|| error.deliveryLockPath
|
|
|| deliveryLockPath(output);
|
|
return diagnostic({
|
|
code,
|
|
message: code === 'delivery/lock-release'
|
|
? `Delivery lock could not be released for "${output}": ${error.message}`
|
|
: code === 'delivery/ownership-lost'
|
|
? `Delivery ownership was lost for "${output}": ${error.message}`
|
|
: `Could not start delivery for "${output}": ${error.message}`,
|
|
subject: { output, lock },
|
|
evidence: {
|
|
reason: error.message,
|
|
...(error.code ? { systemCode: error.code } : {}),
|
|
...(error.lockCleanupError ? { cleanupError: error.lockCleanupError } : {}),
|
|
...(error.deliveryLockOwner || {}),
|
|
...(error.deliveryOwnershipDetails || {}),
|
|
...(error.deliveryCommitDetails || {}),
|
|
},
|
|
supportedFixes: code === 'delivery/concurrent-attempt'
|
|
? ['wait for the active delivery to finish, then retry']
|
|
: code === 'delivery/lock-stale'
|
|
? [
|
|
'confirm no delivery attempt is active for this output',
|
|
`remove only the preserved lock "${lock}", then rerun deliver successfully`,
|
|
]
|
|
: code === 'delivery/lock-path-conflict'
|
|
? ['choose an output whose lock path is distinct from the input specification']
|
|
: code === 'delivery/lock-invalid'
|
|
? ['inspect and preserve the unrecognized lock entry, or choose another output path']
|
|
: code === 'delivery/ownership-lost'
|
|
? ['leave the replacement lock and journal untouched, inspect the reported owner, then retry only after that attempt finishes or is recovered']
|
|
: code === 'delivery/lock-release'
|
|
? ['preserve the reported lock, restore permission to remove that exact entry, then recover serially before retrying']
|
|
: ['resolve the reported filesystem error and any lock cleanup error, then retry'],
|
|
});
|
|
}
|
|
|
|
function deliveryJournalRecoveryDiagnostic(output, error) {
|
|
const recovery = error.deliveryJournalRecovery || {};
|
|
const location = recovery.recoveryDirectory
|
|
? ` Recovery material was retained at "${recovery.recoveryDirectory}".`
|
|
: '';
|
|
return diagnostic({
|
|
code: 'delivery/journal-recovery-required',
|
|
message: `Delivery journal publication could not be rolled back safely.${location}`,
|
|
subject: { output, journal: recovery.journal || deliveryPendingPath(output) },
|
|
evidence: {
|
|
reason: error.message,
|
|
recoveryRequired: true,
|
|
...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}),
|
|
...(recovery.recoveryFiles?.length ? { recoveryFiles: recovery.recoveryFiles } : {}),
|
|
...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}),
|
|
},
|
|
supportedFixes: recovery.recoveryDirectory
|
|
? [
|
|
'leave the delivery lock and current journal claimant untouched',
|
|
`inspect every recovery file in ${JSON.stringify(recovery.recoveryDirectory)} and restore the previous journal only after the claimant is resolved`,
|
|
]
|
|
: ['leave the delivery lock and journal untouched, inspect their current owners, then recover serially'],
|
|
});
|
|
}
|
|
|
|
function deliveryCommitRecoveryDiagnostic(output, error) {
|
|
const recovery = error.deliveryCommitDetails || {};
|
|
return diagnostic({
|
|
code: 'delivery/commit-recovery-required',
|
|
message: `Delivery rollback could not safely restore the previous output pair for "${output}".`,
|
|
subject: { output },
|
|
evidence: {
|
|
reason: recovery.reason || error.message,
|
|
recoveryRequired: true,
|
|
...(recovery.recoveryDirectory ? { recoveryDirectory: recovery.recoveryDirectory } : {}),
|
|
...(recovery.recoverableBackups?.length
|
|
? { recoverableBackups: recovery.recoverableBackups }
|
|
: {}),
|
|
...(recovery.rollbackErrors?.length ? { rollbackErrors: recovery.rollbackErrors } : {}),
|
|
},
|
|
supportedFixes: [
|
|
'leave the delivery lock, journal, current claimants, and retained backups untouched',
|
|
'inspect the reported files and restore the previous pair only after resolving every claimant',
|
|
],
|
|
});
|
|
}
|
|
|
|
function deliveryTargetStateDiagnostic(output, role, result) {
|
|
const reason = result?.reason || { code: 'target-state-unavailable' };
|
|
const hardlinked = typeof reason.code === 'string' && reason.code.endsWith('-hardlinked');
|
|
const provenanceTarget = /provenance/iu.test(role);
|
|
const code = hardlinked
|
|
? provenanceTarget ? 'delivery/provenance-hardlink-unsupported' : 'output/target-hardlinked'
|
|
: ['target-not-regular-file', 'requested-entry-symbolic-link', 'requested-entry-not-regular-file'].includes(reason.code)
|
|
? 'output/target-not-regular-file'
|
|
: result?.status === 'different'
|
|
? 'output/target-changed'
|
|
: 'output/target-indeterminate';
|
|
const message = hardlinked
|
|
? `The ${role} target is hard-linked and cannot be replaced atomically.`
|
|
: code === 'output/target-not-regular-file'
|
|
? `The ${role} target is not a regular file.`
|
|
: code === 'output/target-changed'
|
|
? `The ${role} target changed while the verified delivery pair was being prepared.`
|
|
: `The ${role} target identity could not be determined safely.`;
|
|
return diagnostic({
|
|
code,
|
|
message,
|
|
subject: { output: path.resolve(output), role },
|
|
evidence: { targetState: reason },
|
|
supportedFixes: hardlinked
|
|
? ['remove extra hard links or choose a new output path, then rerun deliver']
|
|
: code === 'output/target-not-regular-file'
|
|
? ['choose an absent or regular-file target, then rerun deliver']
|
|
: code === 'output/target-changed'
|
|
? ['retry only after other processes stop creating, replacing, retargeting, or changing the output pair']
|
|
: ['use an ordinary local filesystem path with stable file identity, then retry'],
|
|
});
|
|
}
|
|
|
|
function deliveryTargetStateError(output, role, result) {
|
|
const issue = deliveryTargetStateDiagnostic(output, role, result);
|
|
const error = new Error(issue.message);
|
|
error.archifyDiagnostics = [issue];
|
|
error.deliveryTargetState = result;
|
|
return error;
|
|
}
|
|
|
|
function captureDeliveryTargetState({
|
|
state,
|
|
requestedOutput,
|
|
pathsAlias,
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
preparedTargets,
|
|
}) {
|
|
if (typeof captureAtomicOutput !== 'function' || typeof verifyAtomicOutput !== 'function') {
|
|
throw deliveryTargetStateError(requestedOutput, 'delivery pair', {
|
|
status: 'unknown',
|
|
reason: { code: 'atomic-output-runtime-unavailable' },
|
|
});
|
|
}
|
|
const artifactTarget = preparedTargets?.artifact || {
|
|
requestedPath: requestedOutput,
|
|
...captureAtomicOutput(requestedOutput),
|
|
};
|
|
if (artifactTarget.status !== 'captured') {
|
|
throw deliveryTargetStateError(requestedOutput, 'HTML artifact', artifactTarget);
|
|
}
|
|
if (!pathsAlias(artifactTarget.commitPath, state.output)) {
|
|
throw deliveryTargetStateError(requestedOutput, 'HTML artifact', {
|
|
status: 'different',
|
|
reason: {
|
|
code: 'write-slot-changed-before-snapshot',
|
|
plannedCommitPath: state.output,
|
|
currentCommitPath: artifactTarget.commitPath,
|
|
},
|
|
});
|
|
}
|
|
const requestedProvenancePath = preparedTargets?.provenance?.requestedPath
|
|
|| deliveryProvenancePath(artifactTarget.commitPath);
|
|
const provenanceTarget = preparedTargets?.provenance || {
|
|
requestedPath: requestedProvenancePath,
|
|
...captureAtomicOutput(requestedProvenancePath, {
|
|
requestedEntryPolicy: 'regular-or-absent',
|
|
}),
|
|
};
|
|
if (provenanceTarget.status !== 'captured') {
|
|
throw deliveryTargetStateError(requestedProvenancePath, 'delivery provenance', provenanceTarget);
|
|
}
|
|
for (const [role, target] of [
|
|
['HTML artifact', artifactTarget],
|
|
['delivery provenance', provenanceTarget],
|
|
]) {
|
|
const verification = verifyAtomicOutput(target.snapshot);
|
|
if (verification.status !== 'match') {
|
|
throw deliveryTargetStateError(target.requestedPath, role, verification);
|
|
}
|
|
}
|
|
state.output = artifactTarget.commitPath;
|
|
state.provenancePath = provenanceTarget.commitPath;
|
|
state.pendingPath = deliveryPendingPath(artifactTarget.commitPath);
|
|
state.verifyAtomicOutput = verifyAtomicOutput;
|
|
state.deliveryTargets = {
|
|
artifact: artifactTarget,
|
|
provenance: provenanceTarget,
|
|
};
|
|
return state.deliveryTargets;
|
|
}
|
|
|
|
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': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust route/via or channel coordinates so unrelated relationships use separate corridors'],
|
|
'composition/ambiguous-corridor': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust route/via or channel coordinates so unrelated relationships do not visually merge'],
|
|
'composition/container-border-run': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and route across the frame perpendicularly through a clear opening'],
|
|
'composition/label-route-clearance': ['if authored labelAt/labelDx/labelDy/labelSegment controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust labelAt, labelDx, labelDy, labelSegment, message y, or the other relationship route'],
|
|
'composition/label-canvas-containment': ['if authored labelAt/labelDx/labelDy/labelSegment controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and adjust labelAt, labelDx, labelDy, or labelSegment so the label rect stays inside the viewBox, or enlarge meta.viewBox'],
|
|
'composition/micro-segment': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and move the route/channel/via point so every visible segment is at least 8px'],
|
|
'composition/short-interior-segment': ['if authored via/route/channelX/channelY controls exist and are not required by the user, remove them to let the renderer re-plan; otherwise preserve that intent and 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, nodeId, ...evidence } = issue;
|
|
diagnostics.push(diagnostic({
|
|
code,
|
|
severity,
|
|
message: `Final artifact failed ${code}.`,
|
|
subject: relationship ? { relationship } : { check: 'composition', ...(nodeId ? { nodeId } : {}) },
|
|
evidence,
|
|
supportedFixes: compositionFixes(issue),
|
|
}));
|
|
}
|
|
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 compositionFixes(issue) {
|
|
if (issue.code === 'composition/viewport-height') return [String(issue.detail || '').replace(/^\[[^\]]+\]\s*/, '')];
|
|
if (issue.code !== 'composition/desktop-readability') return COMPOSITION_FIXES[issue.code] || [];
|
|
const sourceFontPx = Number(issue.sourceFontPx);
|
|
const actualBudgetPx = Number(issue.availableDiagramWidth);
|
|
const hardFloorPx = Number(issue.minimumProjectedFontPx);
|
|
const viewBoxWidth = Number(issue.viewBoxWidth);
|
|
const preserveIntent = 'Preserve the semantic text and any supplied coordinates, routes, sides, channels, and labels.';
|
|
const readerCap = issue.budgetBasis === 'recognized-declared-wide'
|
|
? ` The declared Reader reports a ${issue.budgetLimit} limit and ${actualBudgetPx}px actual diagram budget; do not assume an uncapped viewport.`
|
|
: '';
|
|
if (![sourceFontPx, actualBudgetPx, hardFloorPx, viewBoxWidth].every(Number.isFinite)
|
|
|| actualBudgetPx <= 0 || hardFloorPx <= 0 || viewBoxWidth <= 0) {
|
|
return [`${preserveIntent} Repair the measured source font, desktop budget, or complete viewBox width; position-only label controls do not change projected text size.`];
|
|
}
|
|
if (sourceFontPx < hardFloorPx) {
|
|
return [`${preserveIntent} The diagnosed ${sourceFontPx}px source text is below the ${hardFloorPx}px hard floor even at scale 1, so use a renderer-supported semantic text-size setting or renderer-level fix. Position-only label controls cannot repair its projection.${readerCap}`];
|
|
}
|
|
const maximumViewBoxWidth = Math.floor((sourceFontPx * actualBudgetPx) / hardFloorPx);
|
|
return [`${preserveIntent} Compactly reflow automatic spacing and empty corridors so the complete viewBox width is at most ${maximumViewBoxWidth}px (current ${viewBoxWidth}px; ${sourceFontPx}px source text at ${actualBudgetPx}px desktop budget). If supplied geometry fixes that width, use a renderer-supported semantic text-size setting or renderer-level fix instead. Position-only label controls cannot repair its projection.${readerCap}`];
|
|
}
|
|
|
|
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 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 { directory, stem, options } = deliverySidecarNamespace(outputPath);
|
|
return path.join(
|
|
directory,
|
|
`${boundedSidecarStem(stem, COMPARE_SIDECAR_SUFFIXES, options)}.receipt.json`,
|
|
);
|
|
}
|
|
|
|
function compareCommitError(message, code, details = {}) {
|
|
const error = new Error(message);
|
|
error.compareStage = 'commit';
|
|
error.compareCode = code;
|
|
error.compareDetails = details;
|
|
return error;
|
|
}
|
|
|
|
function compareAtomicOutputFailure(result, label, requestedPath, stage) {
|
|
const reason = result.reason || { code: 'atomic-output-unknown' };
|
|
let code = 'output/target-indeterminate';
|
|
let message = `The compare ${label} target identity could not be verified safely.`;
|
|
let supportedFixes = ['use an ordinary local filesystem path with stable file identity, then retry'];
|
|
if (typeof reason.code === 'string' && reason.code.endsWith('-hardlinked')) {
|
|
code = 'output/target-hardlinked';
|
|
message = `The compare ${label} target has multiple hard links and cannot be replaced atomically.`;
|
|
supportedFixes = [`choose a ${label} path that is absent or names a regular file with one link`];
|
|
} else if (reason.code === 'target-not-regular-file') {
|
|
code = 'output/target-not-regular-file';
|
|
message = `The compare ${label} target is not a regular file.`;
|
|
supportedFixes = [`choose an absent or regular-file path for the ${label}`];
|
|
} else if (result.status === 'different') {
|
|
code = 'output/target-changed';
|
|
message = `The compare ${label} target changed while the artifact pair was being prepared.`;
|
|
supportedFixes = ['retry after other processes stop creating, replacing, or redirecting compare outputs'];
|
|
}
|
|
return {
|
|
stage,
|
|
code,
|
|
message,
|
|
details: {
|
|
side: label,
|
|
path: requestedPath,
|
|
atomicOutput: reason,
|
|
supportedFixes,
|
|
},
|
|
};
|
|
}
|
|
|
|
function commitComparePair({
|
|
htmlCandidate,
|
|
htmlContent,
|
|
receiptCandidate,
|
|
receiptContent,
|
|
outputCapture,
|
|
receiptCapture,
|
|
stagingDirectory,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
}) {
|
|
const {
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
const targets = [
|
|
{
|
|
label: 'HTML artifact', capture: outputCapture, candidate: htmlCandidate, content: htmlContent, backup: path.join(stagingDirectory, '.previous-output'),
|
|
},
|
|
{
|
|
label: 'receipt', capture: receiptCapture, candidate: receiptCandidate, content: receiptContent, backup: path.join(stagingDirectory, '.previous-receipt'),
|
|
},
|
|
];
|
|
const targetChanged = (item, result) => {
|
|
const normalized = typeof result === 'string'
|
|
? { status: 'different', reason: { code: result } }
|
|
: result;
|
|
const failure = compareAtomicOutputFailure(
|
|
normalized,
|
|
item.label,
|
|
item.capture.snapshot.requestedPath,
|
|
'commit',
|
|
);
|
|
const error = compareCommitError(failure.message, failure.code, failure.details);
|
|
error.compareFailure = failure;
|
|
return error;
|
|
};
|
|
|
|
const backedUp = [];
|
|
const committed = [];
|
|
let retainSuccessfulBackupBindings = false;
|
|
try {
|
|
if (typeof backupPublicRegularFileBinding !== 'function'
|
|
|| typeof captureRegularFileBinding !== 'function'
|
|
|| typeof quarantineRemoveRegularFileBinding !== 'function'
|
|
|| typeof verifyRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw targetChanged(targets[0], {
|
|
status: 'unknown',
|
|
reason: { code: 'atomic-output-runtime-unavailable' },
|
|
});
|
|
}
|
|
// Re-resolve and bind both requested write slots and candidate inodes
|
|
// before moving either target. The held handles keep bytes, mode and
|
|
// identity authoritative across every publish/finalize transition.
|
|
for (const item of targets) {
|
|
const verification = verifyAtomicOutput(item.capture.snapshot);
|
|
if (verification.status !== 'match') throw targetChanged(item, verification);
|
|
if (item.capture.mode !== null) fs.chmodSync(item.candidate, item.capture.mode);
|
|
const candidate = captureRegularFileBinding(item.candidate, {
|
|
subject: 'compare-candidate',
|
|
expectedSha256: item.content.sha256,
|
|
expectedBytes: item.content.bytes,
|
|
...(item.capture.mode === null ? {} : { expectedMode: item.capture.mode }),
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidate.status !== 'captured') throw targetChanged(item, candidate);
|
|
item.candidateBinding = candidate.binding;
|
|
item.candidatePresent = true;
|
|
item.candidateIdentity = {
|
|
dev: candidate.identity.device,
|
|
ino: candidate.identity.inode,
|
|
};
|
|
item.candidateMode = candidate.mode;
|
|
if (item.capture.snapshot.target.kind === 'file') {
|
|
const expected = item.capture.snapshot.target;
|
|
const previous = captureRegularFileBinding(item.capture.commitPath, {
|
|
subject: 'previous-compare-target',
|
|
expectedIdentity: { device: expected.device, inode: expected.inode },
|
|
expectedMode: expected.mode,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previous.status !== 'captured') throw targetChanged(item, previous);
|
|
item.previousBinding = previous.binding;
|
|
}
|
|
}
|
|
for (const item of targets) {
|
|
if (item.capture.snapshot.target.kind !== 'file') continue;
|
|
const moved = backupPublicRegularFileBinding(
|
|
item.previousBinding,
|
|
item.capture.commitPath,
|
|
item.backup,
|
|
{ subject: 'previous-compare-target' },
|
|
);
|
|
item.backupPresent = moved.backupCreated === true;
|
|
item.backupVerified = moved.backupVerified === true;
|
|
if (item.backupPresent) backedUp.push(item);
|
|
if (moved.status === 'recovery-required' && moved.recoveryFile) {
|
|
item.backupRecovery = moved.recoveryFile;
|
|
}
|
|
if (moved.status !== 'backed-up') throw targetChanged(item, moved);
|
|
}
|
|
for (const item of targets) {
|
|
const candidate = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.candidate,
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidate.status !== 'match') throw targetChanged(item, candidate);
|
|
try {
|
|
// linkSync is the no-clobber publish primitive: unlike renameSync, it
|
|
// fails with EEXIST if another writer claims an absent slot after the
|
|
// final snapshot verification.
|
|
fs.linkSync(item.candidate, item.capture.commitPath);
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') {
|
|
throw targetChanged(
|
|
item,
|
|
'target-claimed-during-publish',
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
item.committedIdentity = item.candidateIdentity;
|
|
committed.push(item);
|
|
const linked = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.capture.commitPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (linked.status !== 'match') throw targetChanged(item, linked);
|
|
}
|
|
for (const item of committed) {
|
|
const current = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.capture.commitPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (current.status !== 'match') throw targetChanged(item, current);
|
|
}
|
|
// The target remains the durable link. Removing the staging links inside
|
|
// the transaction keeps successful outputs at nlink=1 even if the outer
|
|
// best-effort staging-directory cleanup later fails.
|
|
for (const item of committed) {
|
|
const staged = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.candidate,
|
|
expectedLinks: 2,
|
|
});
|
|
if (staged.status !== 'match') throw targetChanged(item, staged);
|
|
const retired = quarantineRemoveRegularFileBinding(
|
|
item.candidateBinding,
|
|
item.candidate,
|
|
{ subject: 'compare-candidate', expectedLinks: 2 },
|
|
);
|
|
if (retired.status !== 'removed') throw targetChanged(item, retired);
|
|
item.candidatePresent = false;
|
|
}
|
|
for (const item of committed) {
|
|
const finalized = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.capture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') throw targetChanged(item, finalized);
|
|
}
|
|
retainSuccessfulBackupBindings = true;
|
|
return {
|
|
ownedBackupEntries: backedUp
|
|
.filter((item) => item.backupPresent)
|
|
.map((item) => ({
|
|
filePath: item.backup,
|
|
binding: item.previousBinding,
|
|
subject: 'previous-compare-backup',
|
|
expectedLinks: 1,
|
|
})),
|
|
};
|
|
} catch (cause) {
|
|
const rollbackErrors = [];
|
|
const recoveryFiles = targets
|
|
.filter((item) => item.backupRecovery)
|
|
.map((item) => ({
|
|
backup: item.backupRecovery,
|
|
target: item.capture.commitPath,
|
|
verifiedPrevious: false,
|
|
}));
|
|
for (const item of [...committed].reverse()) {
|
|
try {
|
|
const removed = quarantineRemoveRegularFileBinding(
|
|
item.candidateBinding,
|
|
item.capture.commitPath,
|
|
{
|
|
subject: 'published-compare-target',
|
|
expectedLinks: item.candidatePresent ? 2 : 1,
|
|
},
|
|
);
|
|
if (removed.status !== 'removed') {
|
|
if (removed.status === 'recovery-required') {
|
|
recoveryFiles.push({
|
|
backup: removed.recoveryFile,
|
|
target: item.capture.commitPath,
|
|
});
|
|
}
|
|
throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`);
|
|
}
|
|
} catch (error) {
|
|
rollbackErrors.push(`${item.label}: remove failed (${error.message})`);
|
|
}
|
|
}
|
|
for (const item of [...backedUp].reverse()) {
|
|
try {
|
|
if (pathEntryExists(item.capture.commitPath)) {
|
|
const current = item.candidateBinding
|
|
? verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.capture.commitPath,
|
|
expectedLinks: item.candidatePresent ? 2 : 1,
|
|
})
|
|
: { status: 'different' };
|
|
if (current.status !== 'match') {
|
|
throw new Error(`a changed target claimant prevents restoring the previous file (${current.reason?.code || 'unknown target state'})`);
|
|
}
|
|
const removed = quarantineRemoveRegularFileBinding(
|
|
item.candidateBinding,
|
|
item.capture.commitPath,
|
|
{
|
|
subject: 'published-compare-target',
|
|
expectedLinks: item.candidatePresent ? 2 : 1,
|
|
},
|
|
);
|
|
if (removed.status !== 'removed') {
|
|
if (removed.status === 'recovery-required') {
|
|
recoveryFiles.push({
|
|
backup: removed.recoveryFile,
|
|
target: item.capture.commitPath,
|
|
});
|
|
}
|
|
throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`);
|
|
}
|
|
}
|
|
if (item.backupVerified !== true) {
|
|
throw new Error('the backup entry was never verified as the previous file');
|
|
}
|
|
const currentBackup = verifyRegularFileBinding(item.previousBinding, {
|
|
filePath: item.backup,
|
|
expectedLinks: 1,
|
|
});
|
|
if (currentBackup.status !== 'match') {
|
|
item.backupVerified = false;
|
|
throw new Error('the recoverable backup was replaced before rollback');
|
|
}
|
|
// Publish the previous file without clobbering a claimant that appears
|
|
// after the absence check. The backup remains the recovery source until
|
|
// the restored hard link has been identity-verified.
|
|
fs.linkSync(item.backup, item.capture.commitPath);
|
|
for (const restoredPath of [item.capture.commitPath, item.backup]) {
|
|
const restored = verifyRegularFileBinding(item.previousBinding, {
|
|
filePath: restoredPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (restored.status !== 'match') {
|
|
throw new Error('the restored target identity could not be verified');
|
|
}
|
|
}
|
|
const retired = quarantineRemoveRegularFileBinding(
|
|
item.previousBinding,
|
|
item.backup,
|
|
{ subject: 'previous-compare-target', expectedLinks: 2 },
|
|
);
|
|
if (retired.status !== 'removed') {
|
|
throw new Error(`the restored backup could not be retired safely (${retired.reason?.code || 'unknown target state'})`);
|
|
}
|
|
item.backupPresent = false;
|
|
const finalized = verifyRegularFileBinding(item.previousBinding, {
|
|
filePath: item.capture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') {
|
|
throw new Error('the restored target changed before rollback finalized');
|
|
}
|
|
} catch (error) {
|
|
rollbackErrors.push(`${item.label}: restore failed (${error.message})`);
|
|
// Track failed restoration rather than probing existence: a permission
|
|
// error must not make cleanup discard a potentially recoverable backup.
|
|
if (item.backupPresent !== false) {
|
|
recoveryFiles.push({
|
|
backup: item.backup,
|
|
target: item.capture.commitPath,
|
|
verifiedPrevious: item.backupVerified === true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (!rollbackErrors.length && cause.compareFailure) {
|
|
throw compareCommitError(
|
|
cause.message,
|
|
cause.compareFailure.code,
|
|
{
|
|
...cause.compareFailure.details,
|
|
reason: cause.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.')
|
|
+ (recoveryFiles.length
|
|
? ` Recovery directory retained at ${path.dirname(recoveryFiles[0].backup)}.`
|
|
: ''),
|
|
rollbackErrors.length ? 'delta/commit-rollback-failed' : 'delta/commit-failed',
|
|
{
|
|
reason: cause.message,
|
|
...(rollbackErrors.length ? { rollbackErrors } : {}),
|
|
...(recoveryFiles.length ? {
|
|
recoveryDirectory: path.dirname(recoveryFiles[0].backup),
|
|
recoveryDirectories: [...new Set(recoveryFiles.map(({ backup }) => path.dirname(backup)))],
|
|
recoveryFiles,
|
|
} : {}),
|
|
supportedFixes: recoveryFiles.length
|
|
? [
|
|
...recoveryFiles.map(({ backup, target, verifiedPrevious }) => verifiedPrevious
|
|
? `resolve the filesystem error, inspect the current target, then restore ${JSON.stringify(backup)} to ${JSON.stringify(target)} before retrying`
|
|
: `preserve and inspect the unverified entry at ${JSON.stringify(backup)}; do not treat it as the previous file for ${JSON.stringify(target)}`),
|
|
'remove the recovery directory only after the previous files have been recovered and verified',
|
|
]
|
|
: ['check that both output paths are writable regular files, then retry'],
|
|
},
|
|
);
|
|
} finally {
|
|
for (const item of targets) {
|
|
if (item.candidateBinding) releaseRegularFileBinding(item.candidateBinding);
|
|
if (item.previousBinding
|
|
&& !(retainSuccessfulBackupBindings && item.backupPresent)) {
|
|
releaseRegularFileBinding(item.previousBinding);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function commitDeliveryPair({
|
|
htmlCandidate,
|
|
htmlContent,
|
|
provenanceCandidate,
|
|
provenanceContent,
|
|
stagingDirectory,
|
|
ownership,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
}) {
|
|
const {
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = fileBindingRuntime || {};
|
|
const state = assertDeliveryOwnership(ownership, 'start the delivery pair commit', { pending: 'owned' });
|
|
const snapshots = state.deliveryTargets;
|
|
if (!snapshots?.artifact || !snapshots?.provenance) {
|
|
throw deliveryTargetStateError(state.output, 'delivery pair', {
|
|
status: 'unknown',
|
|
reason: { code: 'target-snapshot-missing' },
|
|
});
|
|
}
|
|
const targets = [
|
|
{
|
|
label: 'HTML artifact',
|
|
target: snapshots.artifact.commitPath,
|
|
candidate: htmlCandidate,
|
|
content: htmlContent,
|
|
backup: path.join(stagingDirectory, '.previous-output'),
|
|
capture: snapshots.artifact,
|
|
},
|
|
{
|
|
label: 'delivery provenance',
|
|
target: snapshots.provenance.commitPath,
|
|
candidate: provenanceCandidate,
|
|
content: provenanceContent,
|
|
backup: path.join(stagingDirectory, '.previous-provenance'),
|
|
capture: snapshots.provenance,
|
|
},
|
|
];
|
|
const backedUp = [];
|
|
const retainedBackups = new Set();
|
|
const publicRecoveryBackups = [];
|
|
let retainSuccessfulBackupBindings = false;
|
|
const committed = [];
|
|
try {
|
|
if (typeof backupPublicRegularFileBinding !== 'function'
|
|
|| typeof captureRegularFileBinding !== 'function'
|
|
|| typeof quarantineRemoveRegularFileBinding !== 'function'
|
|
|| typeof verifyRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw deliveryTargetStateError(state.output, 'delivery pair', {
|
|
status: 'unknown',
|
|
reason: { code: 'atomic-output-runtime-unavailable' },
|
|
});
|
|
}
|
|
for (const item of targets) {
|
|
const verification = verifyAtomicOutput(item.capture.snapshot);
|
|
if (verification.status !== 'match') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, verification);
|
|
}
|
|
if (item.capture.mode !== null) fs.chmodSync(item.candidate, item.capture.mode);
|
|
const candidate = captureRegularFileBinding(item.candidate, {
|
|
subject: 'delivery-candidate',
|
|
expectedSha256: item.content.sha256,
|
|
expectedBytes: item.content.bytes,
|
|
...(item.capture.mode === null ? {} : { expectedMode: item.capture.mode }),
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidate.status !== 'captured') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, candidate);
|
|
}
|
|
item.candidateBinding = candidate.binding;
|
|
item.candidatePresent = true;
|
|
item.candidateIdentity = {
|
|
dev: candidate.identity.device,
|
|
ino: candidate.identity.inode,
|
|
};
|
|
item.candidateMode = candidate.mode;
|
|
if (item.capture.snapshot.target.kind === 'file') {
|
|
const expected = item.capture.snapshot.target;
|
|
const previous = captureRegularFileBinding(item.target, {
|
|
subject: 'previous-delivery-target',
|
|
expectedIdentity: { device: expected.device, inode: expected.inode },
|
|
expectedMode: expected.mode,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previous.status !== 'captured') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, previous);
|
|
}
|
|
item.previousBinding = previous.binding;
|
|
}
|
|
}
|
|
for (const item of targets) {
|
|
if (item.capture.snapshot.target.kind !== 'file') continue;
|
|
assertDeliveryOwnership(ownership, `back up the ${item.label}`, { pending: 'owned' });
|
|
const moved = backupPublicRegularFileBinding(
|
|
item.previousBinding,
|
|
item.target,
|
|
item.backup,
|
|
{ subject: 'previous-delivery-target' },
|
|
);
|
|
item.backupPresent = moved.backupCreated === true;
|
|
item.backupVerified = moved.backupVerified === true;
|
|
if (item.backupPresent) backedUp.push(item);
|
|
if (moved.status === 'recovery-required' && moved.recoveryFile) {
|
|
publicRecoveryBackups.push({
|
|
label: `preserved replacement for ${item.label}`,
|
|
path: moved.recoveryFile,
|
|
target: item.target,
|
|
});
|
|
}
|
|
if (moved.status !== 'backed-up') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, moved);
|
|
}
|
|
retainedBackups.add(item);
|
|
}
|
|
for (const item of targets) {
|
|
assertDeliveryOwnership(ownership, `commit the ${item.label}`, { pending: 'owned' });
|
|
const candidate = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.candidate,
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidate.status !== 'match') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, candidate);
|
|
}
|
|
try {
|
|
fs.linkSync(item.candidate, item.target);
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, {
|
|
status: 'different',
|
|
reason: { code: 'target-claimed-during-publish' },
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
item.committedIdentity = item.candidateIdentity;
|
|
committed.push(item);
|
|
const linked = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.target,
|
|
expectedLinks: 2,
|
|
});
|
|
if (linked.status !== 'match') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, linked);
|
|
}
|
|
}
|
|
for (const item of committed) {
|
|
assertDeliveryOwnership(ownership, `verify the committed ${item.label}`, { pending: 'owned' });
|
|
const current = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.target,
|
|
expectedLinks: 2,
|
|
});
|
|
if (current.status !== 'match') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, current);
|
|
}
|
|
}
|
|
for (const item of committed) {
|
|
const staged = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.candidate,
|
|
expectedLinks: 2,
|
|
});
|
|
if (staged.status !== 'match') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, staged);
|
|
}
|
|
const retired = quarantineRemoveRegularFileBinding(
|
|
item.candidateBinding,
|
|
item.candidate,
|
|
{ subject: 'delivery-candidate', expectedLinks: 2 },
|
|
);
|
|
if (retired.status !== 'removed') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, retired);
|
|
}
|
|
item.candidatePresent = false;
|
|
}
|
|
for (const item of committed) {
|
|
const finalized = verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.target,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') {
|
|
throw deliveryTargetStateError(item.capture.requestedPath, item.label, finalized);
|
|
}
|
|
}
|
|
// Finalization is part of the commit: keep the old files recoverable until
|
|
// the journal has been removed and checkers can accept the new artifact.
|
|
assertDeliveryOwnership(ownership, 'finalize the delivery journal', { pending: 'owned' });
|
|
quarantineRemoveOwnedDeliveryEntry(state, {
|
|
filePath: state.pendingPath,
|
|
identity: state.pendingIdentity,
|
|
content: state.pendingContent,
|
|
subject: 'delivery-journal',
|
|
operation: 'finalize the delivery journal',
|
|
evidenceKey: 'journal',
|
|
});
|
|
assertDeliveryOwnership(ownership, 'finish finalizing the delivery journal', { pending: 'absent' });
|
|
state.pendingIdentity = undefined;
|
|
state.pendingContent = undefined;
|
|
state.phase = 'finalized';
|
|
retainSuccessfulBackupBindings = true;
|
|
return {
|
|
recoverableBackups: [
|
|
...[...retainedBackups].map((item) => ({ label: item.label, path: item.backup })),
|
|
...publicRecoveryBackups,
|
|
],
|
|
ownedBackupEntries: backedUp
|
|
.filter((item) => item.backupPresent)
|
|
.map((item) => ({
|
|
filePath: item.backup,
|
|
binding: item.previousBinding,
|
|
subject: 'previous-delivery-backup',
|
|
expectedLinks: 1,
|
|
})),
|
|
};
|
|
} catch (cause) {
|
|
const recoveryDetails = () => ({
|
|
recoveryRequired: true,
|
|
recoveryDirectory: stagingDirectory,
|
|
recoverableBackups: [
|
|
...[...retainedBackups].map((item) => ({ label: item.label, path: item.backup })),
|
|
...publicRecoveryBackups,
|
|
],
|
|
});
|
|
if (cause.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
cause.deliveryCommitDetails = { ...(cause.deliveryCommitDetails || {}), ...recoveryDetails() };
|
|
throw cause;
|
|
}
|
|
const rollbackErrors = [];
|
|
const recoverableBackups = [];
|
|
try {
|
|
for (const item of [...committed].reverse()) {
|
|
try {
|
|
assertDeliveryOwnership(ownership, `roll back the ${item.label}`, { pending: 'identity' });
|
|
const removed = quarantineRemoveRegularFileBinding(item.candidateBinding, item.target, {
|
|
subject: 'published-delivery-target',
|
|
expectedLinks: item.candidatePresent ? 2 : 1,
|
|
});
|
|
if (removed.status !== 'removed') {
|
|
if (removed.status === 'recovery-required') {
|
|
publicRecoveryBackups.push({
|
|
label: `preserved replacement for ${item.label}`,
|
|
path: removed.recoveryFile,
|
|
target: item.target,
|
|
});
|
|
}
|
|
throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`);
|
|
}
|
|
} catch (error) {
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost') throw error;
|
|
rollbackErrors.push(`${item.label}: remove failed (${error.message})`);
|
|
}
|
|
}
|
|
for (const item of [...backedUp].reverse()) {
|
|
try {
|
|
assertDeliveryOwnership(ownership, `restore the previous ${item.label}`, { pending: 'identity' });
|
|
if (pathEntryExists(item.target)) {
|
|
const current = item.candidateBinding
|
|
? verifyRegularFileBinding(item.candidateBinding, {
|
|
filePath: item.target,
|
|
expectedLinks: item.candidatePresent ? 2 : 1,
|
|
})
|
|
: { status: 'different' };
|
|
if (current.status !== 'match') {
|
|
throw new Error(`a changed target claimant prevents restoring the previous file (${current.reason?.code || 'unknown target state'})`);
|
|
}
|
|
const removed = quarantineRemoveRegularFileBinding(item.candidateBinding, item.target, {
|
|
subject: 'published-delivery-target',
|
|
expectedLinks: item.candidatePresent ? 2 : 1,
|
|
});
|
|
if (removed.status !== 'removed') {
|
|
if (removed.status === 'recovery-required') {
|
|
publicRecoveryBackups.push({
|
|
label: `preserved replacement for ${item.label}`,
|
|
path: removed.recoveryFile,
|
|
target: item.target,
|
|
});
|
|
}
|
|
throw new Error(`the committed target could not be removed safely (${removed.reason?.code || 'unknown target state'})`);
|
|
}
|
|
}
|
|
assertDeliveryOwnership(ownership, `restore the previous ${item.label}`, { pending: 'identity' });
|
|
if (item.backupVerified !== true) {
|
|
throw new Error('the backup entry was never verified as the previous delivery file');
|
|
}
|
|
const currentBackup = verifyRegularFileBinding(item.previousBinding, {
|
|
filePath: item.backup,
|
|
expectedLinks: 1,
|
|
});
|
|
if (currentBackup.status !== 'match') {
|
|
item.backupVerified = false;
|
|
retainedBackups.delete(item);
|
|
throw new Error('the previous delivery backup identity changed');
|
|
}
|
|
try {
|
|
fs.linkSync(item.backup, item.target);
|
|
} catch (error) {
|
|
if (error?.code === 'EEXIST') {
|
|
throw new Error('a new target claimant prevents restoring the previous file');
|
|
}
|
|
throw error;
|
|
}
|
|
for (const restoredPath of [item.target, item.backup]) {
|
|
const restored = verifyRegularFileBinding(item.previousBinding, {
|
|
filePath: restoredPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (restored.status !== 'match') {
|
|
throw new Error('the restored delivery target identity could not be verified');
|
|
}
|
|
}
|
|
const retired = quarantineRemoveRegularFileBinding(
|
|
item.previousBinding,
|
|
item.backup,
|
|
{ subject: 'previous-delivery-target', expectedLinks: 2 },
|
|
);
|
|
if (retired.status !== 'removed') {
|
|
throw new Error(`the restored delivery backup could not be retired safely (${retired.reason?.code || 'unknown target state'})`);
|
|
}
|
|
item.backupPresent = false;
|
|
const restoredFinal = verifyRegularFileBinding(item.previousBinding, {
|
|
filePath: item.target,
|
|
expectedLinks: 1,
|
|
});
|
|
if (restoredFinal.status !== 'match') {
|
|
throw new Error('the restored delivery target link count could not be verified');
|
|
}
|
|
retainedBackups.delete(item);
|
|
} catch (error) {
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost') throw error;
|
|
rollbackErrors.push(`${item.label}: restore failed (${error.message})`);
|
|
// A failed restoration leaves this exact backup at its source path.
|
|
// Record it without probing the failing recovery path again.
|
|
if (item.backupPresent !== false) {
|
|
recoverableBackups.push({
|
|
label: item.backupVerified === true
|
|
? item.label
|
|
: `unverified preserved backup entry for ${item.label}`,
|
|
path: item.backup,
|
|
target: item.target,
|
|
verifiedPrevious: item.backupVerified === true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
assertDeliveryOwnership(ownership, 'finish the delivery rollback', { pending: 'identity' });
|
|
} catch (rollbackError) {
|
|
if (rollbackError.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
rollbackError.deliveryCommitDetails = {
|
|
...(rollbackError.deliveryCommitDetails || {}),
|
|
commitError: {
|
|
reason: cause.message,
|
|
...(cause?.code ? { systemCode: cause.code } : {}),
|
|
},
|
|
...recoveryDetails(),
|
|
};
|
|
throw rollbackError;
|
|
}
|
|
rollbackErrors.push(rollbackError.message);
|
|
}
|
|
const error = new Error(rollbackErrors.length
|
|
? 'Delivery pair commit failed and its previous files could not be fully restored.'
|
|
: 'Delivery pair commit failed; the previous files were restored.');
|
|
error.deliveryCommitDetails = {
|
|
reason: cause.message,
|
|
...(rollbackErrors.length ? {
|
|
rollbackErrors,
|
|
recoveryRequired: true,
|
|
recoveryDirectory: stagingDirectory,
|
|
recoverableBackups: [...recoverableBackups, ...publicRecoveryBackups],
|
|
} : {}),
|
|
};
|
|
if (cause.deliveryTargetState) {
|
|
error.deliveryTargetState = cause.deliveryTargetState;
|
|
error.archifyDiagnostics = cause.archifyDiagnostics;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
for (const item of targets) {
|
|
if (item.candidateBinding) releaseRegularFileBinding(item.candidateBinding);
|
|
if (item.previousBinding
|
|
&& !(retainSuccessfulBackupBindings && item.backupPresent)) {
|
|
releaseRegularFileBinding(item.previousBinding);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderValidatedArchitecture(inputPath, outputPath, quality, repoRoot, captureStagingFile) {
|
|
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;
|
|
}
|
|
let artifact;
|
|
if (captureStagingFile) {
|
|
artifact = fs.readFileSync(outputPath);
|
|
captureStagingFile(outputPath, artifactIdentity(artifact));
|
|
}
|
|
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;
|
|
}
|
|
artifact ||= fs.readFileSync(outputPath);
|
|
return {
|
|
artifact,
|
|
html: artifact.toString('utf8'),
|
|
checks: JSON.parse(check.stdout),
|
|
sourceEvidence: sourceEvidenceFromArtifact(artifact),
|
|
};
|
|
}
|
|
|
|
async function commandCompare(args) {
|
|
await loadSidecarPathRuntime();
|
|
const {
|
|
canonicalFuturePath,
|
|
resolveOutputPath,
|
|
validateAuthoredOutputPath,
|
|
} = await import('../renderers/shared/output-path.mjs');
|
|
const {
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = await import('../renderers/shared/atomic-output.mjs');
|
|
const fileBindingRuntime = {
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
};
|
|
const {
|
|
sameParent,
|
|
sidecarNamespaceComponentKey,
|
|
} = await import('../renderers/shared/path-semantics.mjs');
|
|
sidecarNamespaceComponentKeyRuntime = sidecarNamespaceComponentKey;
|
|
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);
|
|
const explicitReceiptTarget = options.receipt ? path.resolve(options.receipt) : undefined;
|
|
const rawOutputTarget = requestedOutput || 'architecture-delta.html';
|
|
const rawOutputExtension = path.extname(rawOutputTarget);
|
|
// Preserve the established alias-first diagnostic for an already-invalid
|
|
// CLI extension without letting this legacy spelling select the real
|
|
// sidecar namespace for any valid compare output.
|
|
const invalidOutputAliasPreflight = !explicitReceiptTarget
|
|
&& rawOutputExtension.toLowerCase() !== '.html'
|
|
? path.resolve(rawOutputExtension
|
|
? `${rawOutputTarget.slice(0, -rawOutputExtension.length)}.receipt.json`
|
|
: `${rawOutputTarget}.receipt.json`)
|
|
: undefined;
|
|
let outputPath;
|
|
try {
|
|
({ outputPath } = resolveOutputPath({
|
|
requestedOutput,
|
|
defaultOutput: 'architecture-delta.html',
|
|
inputPaths: [basePath, headPath],
|
|
otherOutputPaths: explicitReceiptTarget
|
|
? [explicitReceiptTarget]
|
|
: (invalidOutputAliasPreflight ? [invalidOutputAliasPreflight] : []),
|
|
}));
|
|
} 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;
|
|
}
|
|
const requestedOutputPath = outputPath;
|
|
let receiptPath;
|
|
let requestedReceiptPath;
|
|
if (explicitReceiptTarget) {
|
|
try {
|
|
({ outputPath: receiptPath } = resolveOutputPath({
|
|
requestedOutput: options.receipt,
|
|
defaultOutput: options.receipt,
|
|
requiredExtension: '.json',
|
|
inputPaths: [basePath, headPath],
|
|
otherOutputPaths: [outputPath],
|
|
}));
|
|
requestedReceiptPath = receiptPath;
|
|
} 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;
|
|
}
|
|
}
|
|
try {
|
|
outputPath = canonicalFuturePath(requestedOutputPath);
|
|
if (requestedReceiptPath) receiptPath = canonicalFuturePath(requestedReceiptPath);
|
|
} 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 safe compare output paths 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;
|
|
}
|
|
|
|
for (const [side, document] of [['base', base], ['head', head]]) {
|
|
try {
|
|
validateAuthoredOutputPath(document?.meta?.output);
|
|
} catch (error) {
|
|
const diagnosticEntry = error.archifyDiagnostics?.[0];
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'input',
|
|
error: `${side === 'base' ? 'Base' : 'Head'} snapshot failed validation: ${error.message}`,
|
|
code: diagnosticEntry?.code || `delta/${side}-validation`,
|
|
details: {
|
|
side,
|
|
...(diagnosticEntry?.subject?.path ? { path: diagnosticEntry.subject.path } : {}),
|
|
...(diagnosticEntry?.evidence || {}),
|
|
supportedFixes: diagnosticEntry?.supportedFixes || [],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
let outputDirectory = path.dirname(outputPath);
|
|
if (receiptPath) {
|
|
const explicitReceiptDirectoryIdentity = sameParent(outputPath, receiptPath);
|
|
if (explicitReceiptDirectoryIdentity.status === 'unknown') {
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: 'The compare receipt directory identity could not be verified.',
|
|
code: 'delta/receipt-directory-identity-indeterminate',
|
|
details: {
|
|
artifactDirectory: outputDirectory,
|
|
receiptDirectory: path.dirname(receiptPath),
|
|
pathIdentity: explicitReceiptDirectoryIdentity.reason,
|
|
supportedFixes: ['restore access to both output directories or choose paths in one verifiable directory'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (explicitReceiptDirectoryIdentity.status !== 'match') {
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: 'The compare receipt must be written beside the HTML artifact.',
|
|
code: 'delta/receipt-directory',
|
|
details: {
|
|
artifactDirectory: outputDirectory,
|
|
receiptDirectory: path.dirname(receiptPath),
|
|
pathIdentity: explicitReceiptDirectoryIdentity.reason,
|
|
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;
|
|
}
|
|
|
|
if (!receiptPath) {
|
|
try {
|
|
const defaultReceiptPath = compareReceiptPath(outputPath);
|
|
({ outputPath: receiptPath } = resolveOutputPath({
|
|
requestedOutput: defaultReceiptPath,
|
|
defaultOutput: defaultReceiptPath,
|
|
requiredExtension: '.json',
|
|
inputPaths: [basePath, headPath],
|
|
otherOutputPaths: [outputPath],
|
|
}));
|
|
requestedReceiptPath = receiptPath;
|
|
receiptPath = canonicalFuturePath(requestedReceiptPath);
|
|
} catch (error) {
|
|
const outputDiagnostic = error.archifyDiagnostics?.[0];
|
|
const namespaceIndeterminate = error?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE';
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: error.message,
|
|
code: namespaceIndeterminate
|
|
? 'delta/receipt-namespace-indeterminate'
|
|
: outputDiagnostic?.code || 'output/path-resolution',
|
|
details: {
|
|
...(outputDiagnostic?.subject || {}),
|
|
...(outputDiagnostic?.evidence || {}),
|
|
...(namespaceIndeterminate ? { pathIdentity: error.sidecarNamespaceReason } : {}),
|
|
supportedFixes: outputDiagnostic?.supportedFixes
|
|
|| ['choose a safe receipt path beside the compare artifact and retry'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const receiptDirectoryIdentity = sameParent(outputPath, receiptPath);
|
|
if (receiptDirectoryIdentity.status === 'unknown') {
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: 'The compare receipt directory identity could not be verified.',
|
|
code: 'delta/receipt-directory-identity-indeterminate',
|
|
details: {
|
|
artifactDirectory: outputDirectory,
|
|
receiptDirectory: path.dirname(receiptPath),
|
|
pathIdentity: receiptDirectoryIdentity.reason,
|
|
supportedFixes: ['restore access to both output directories or choose paths in one verifiable directory'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (receiptDirectoryIdentity.status !== 'match') {
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: 'The compare receipt must be written beside the HTML artifact.',
|
|
code: 'delta/receipt-directory',
|
|
details: {
|
|
artifactDirectory: outputDirectory,
|
|
receiptDirectory: path.dirname(receiptPath),
|
|
pathIdentity: receiptDirectoryIdentity.reason,
|
|
supportedFixes: ['choose a --receipt path in the same directory as output.html'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const outputCapture = captureAtomicOutput(requestedOutputPath);
|
|
const receiptCapture = captureAtomicOutput(requestedReceiptPath);
|
|
for (const [capture, label, requestedPath] of [
|
|
[outputCapture, 'HTML artifact', requestedOutputPath],
|
|
[receiptCapture, 'receipt', requestedReceiptPath],
|
|
]) {
|
|
if (capture.status === 'captured') continue;
|
|
const failure = compareAtomicOutputFailure(capture, label, requestedPath, 'prepare');
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: failure.stage,
|
|
error: failure.message,
|
|
code: failure.code,
|
|
details: failure.details,
|
|
});
|
|
return;
|
|
}
|
|
const outputParent = outputCapture.snapshot.slot;
|
|
const receiptParent = receiptCapture.snapshot.slot;
|
|
if (outputParent.parentDevice !== receiptParent.parentDevice
|
|
|| outputParent.parentInode !== receiptParent.parentInode) {
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: 'The compare receipt must be written beside the HTML artifact.',
|
|
code: 'delta/receipt-directory',
|
|
details: {
|
|
artifactDirectory: outputParent.parentPath,
|
|
receiptDirectory: receiptParent.parentPath,
|
|
supportedFixes: ['choose a --receipt path in the same physical directory as output.html'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
outputPath = outputCapture.commitPath;
|
|
receiptPath = receiptCapture.commitPath;
|
|
outputDirectory = path.dirname(outputPath);
|
|
|
|
let stagingDirectory;
|
|
let stagingIdentity;
|
|
try {
|
|
const staging = createOwnedEmptyStagingDirectory(
|
|
path.join(outputDirectory, '.archify-compare-'),
|
|
);
|
|
stagingDirectory = staging.directory;
|
|
stagingIdentity = staging.identity;
|
|
} 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 rawBaseInput = path.join(stagingDirectory, 'base.snapshot.json');
|
|
const rawHeadInput = path.join(stagingDirectory, 'head.snapshot.json');
|
|
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));
|
|
let preserveRecoveryDirectory = false;
|
|
const stagingOwnership = [];
|
|
const captureCompareStagingFile = (filePath, content) => captureOwnedStagingFile(
|
|
stagingOwnership,
|
|
filePath,
|
|
fileBindingRuntime,
|
|
{ subject: 'compare-staging-entry', content },
|
|
);
|
|
|
|
try {
|
|
let baseResult;
|
|
let headResult;
|
|
for (const { side, snapshotPath, buffer } of [
|
|
{ side: 'base', snapshotPath: rawBaseInput, buffer: baseBuffer },
|
|
{ side: 'head', snapshotPath: rawHeadInput, buffer: headBuffer },
|
|
]) {
|
|
try {
|
|
fs.writeFileSync(snapshotPath, buffer, { flag: 'wx' });
|
|
captureCompareStagingFile(snapshotPath, artifactIdentity(buffer));
|
|
} catch (error) {
|
|
const message = `Could not freeze ${side} compare snapshot: ${error.message}`;
|
|
reportCompareFailure({
|
|
json: options.json,
|
|
stage: 'prepare',
|
|
error: message,
|
|
code: 'delta/freeze-snapshot',
|
|
details: {
|
|
side,
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
reason: error.message,
|
|
supportedFixes: ['choose a writable compare output directory on the target filesystem'],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
renderValidatedArchitecture(
|
|
rawBaseInput,
|
|
rawBaseCandidate,
|
|
qualityArgs.quality,
|
|
repoArgs.repoRoot,
|
|
captureCompareStagingFile,
|
|
);
|
|
} 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(
|
|
rawHeadInput,
|
|
rawHeadCandidate,
|
|
qualityArgs.quality,
|
|
repoArgs.repoRoot,
|
|
captureCompareStagingFile,
|
|
);
|
|
} 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.
|
|
const canonicalBase = canonicalArchitecture(base);
|
|
const canonicalHead = canonicalArchitecture(head);
|
|
canonicalBase.meta.output = base.meta.output;
|
|
canonicalHead.meta.output = head.meta.output;
|
|
const canonicalBaseBytes = Buffer.from(JSON.stringify(canonicalBase));
|
|
const canonicalHeadBytes = Buffer.from(JSON.stringify(canonicalHead));
|
|
fs.writeFileSync(canonicalBaseInput, canonicalBaseBytes);
|
|
captureCompareStagingFile(canonicalBaseInput, artifactIdentity(canonicalBaseBytes));
|
|
fs.writeFileSync(canonicalHeadInput, canonicalHeadBytes);
|
|
captureCompareStagingFile(canonicalHeadInput, artifactIdentity(canonicalHeadBytes));
|
|
baseResult = renderValidatedArchitecture(
|
|
canonicalBaseInput,
|
|
baseCandidate,
|
|
qualityArgs.quality,
|
|
repoArgs.repoRoot,
|
|
captureCompareStagingFile,
|
|
);
|
|
headResult = renderValidatedArchitecture(
|
|
canonicalHeadInput,
|
|
headCandidate,
|
|
qualityArgs.quality,
|
|
repoArgs.repoRoot,
|
|
captureCompareStagingFile,
|
|
);
|
|
|
|
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);
|
|
if (outputCapture.mode !== null) fs.chmodSync(htmlCandidate, outputCapture.mode);
|
|
const artifact = fs.readFileSync(htmlCandidate);
|
|
captureCompareStagingFile(htmlCandidate, artifactIdentity(artifact));
|
|
const baseChecks = baseResult.checks.checks.filter((check) => check.ok).length;
|
|
const headChecks = headResult.checks.checks.filter((check) => check.ok).length;
|
|
const finalReceipt = {
|
|
...compareIr,
|
|
artifact: artifactIdentity(artifact),
|
|
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,
|
|
},
|
|
};
|
|
const receiptBytes = Buffer.from(`${JSON.stringify(finalReceipt, null, 2)}\n`);
|
|
fs.writeFileSync(receiptCandidate, receiptBytes);
|
|
if (receiptCapture.mode !== null) fs.chmodSync(receiptCandidate, receiptCapture.mode);
|
|
captureCompareStagingFile(receiptCandidate, artifactIdentity(receiptBytes));
|
|
|
|
try {
|
|
const currentOutput = resolveOutputPath({
|
|
requestedOutput,
|
|
defaultOutput: 'architecture-delta.html',
|
|
inputPaths: [basePath, headPath],
|
|
otherOutputPaths: [requestedReceiptPath],
|
|
}).outputPath;
|
|
resolveOutputPath({
|
|
requestedOutput: options.receipt || compareReceiptPath(currentOutput),
|
|
defaultOutput: compareReceiptPath(currentOutput),
|
|
requiredExtension: '.json',
|
|
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;
|
|
}
|
|
|
|
const committed = commitComparePair({
|
|
htmlCandidate,
|
|
htmlContent: finalReceipt.artifact,
|
|
receiptCandidate,
|
|
receiptContent: artifactIdentity(receiptBytes),
|
|
outputCapture,
|
|
receiptCapture,
|
|
stagingDirectory,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
});
|
|
stagingOwnership.push(...committed.ownedBackupEntries);
|
|
if (options.json) console.log(JSON.stringify(finalReceipt, null, 2));
|
|
else {
|
|
console.log(`compared architecture ${requestedOutputPath}`);
|
|
console.log(`${finalReceipt.validation.checksPassed}/${finalReceipt.validation.checkCount} checks; completeness ${finalReceipt.completeness}; ${finalReceipt.proofLevel}; sha256 ${finalReceipt.artifact.sha256.slice(0, 12)}`);
|
|
console.log(`receipt ${requestedReceiptPath}`);
|
|
}
|
|
} 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') {
|
|
preserveRecoveryDirectory = Boolean(error.compareDetails?.recoveryFiles?.length);
|
|
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 {
|
|
if (!preserveRecoveryDirectory) {
|
|
cleanupOwnedStagingDirectory(
|
|
stagingDirectory,
|
|
stagingIdentity,
|
|
stagingOwnership,
|
|
fileBindingRuntime,
|
|
);
|
|
} else {
|
|
releaseOwnedStagingBindings(stagingOwnership, fileBindingRuntime);
|
|
}
|
|
} 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);
|
|
// render takes no options of its own once --quality and --repo-root are
|
|
// stripped, so anything left starting with -- is a typo. Without this a
|
|
// mistyped flag was taken as the output path: `render architecture spec.json
|
|
// --json out.html` wrote a file literally named `--json` and never wrote
|
|
// out.html, exiting 0. Every sibling subcommand already guards this.
|
|
const unknown = repoArgs.rest.filter((arg) => arg.startsWith('--'));
|
|
if (unknown.length) fail(`Unknown render option "${unknown[0]}".`);
|
|
const [type, input, output] = repoArgs.rest;
|
|
if (!type || !input || repoArgs.rest.length > 3) fail(usage());
|
|
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, receiptId, provenance, update }) {
|
|
const receipt = {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command,
|
|
...(receiptId ? { receiptId } : {}),
|
|
stage,
|
|
type,
|
|
input,
|
|
...(output === undefined ? {} : { output }),
|
|
error,
|
|
diagnostics,
|
|
...(provenance ? { provenance } : {}),
|
|
...(checker ? { checker } : {}),
|
|
...(update ? { update } : {}),
|
|
};
|
|
if (json) console.log(JSON.stringify(receipt, null, 2));
|
|
else {
|
|
console.error(formatDiagnostics(error, diagnostics));
|
|
if (update?.noticeRequired) console.log(update.noticeText);
|
|
}
|
|
process.exitCode = status;
|
|
}
|
|
|
|
function writeDeliveryFailureReceipt(options) {
|
|
const receiptId = options.receiptId || randomUUID();
|
|
const recorded = recordDeliveryFailure({ ...options, receiptId });
|
|
const ownershipOrLockError = recorded.ownershipError || recorded.lockError;
|
|
if (ownershipOrLockError) {
|
|
const failureWasRecorded = recorded.status && recorded.status !== 'unrecorded';
|
|
const lockOrOwnershipFailure = Boolean(
|
|
ownershipOrLockError.deliveryLockCode
|
|
|| ownershipOrLockError.deliveryOwnershipCode,
|
|
);
|
|
reportArtifactFailure({
|
|
...options,
|
|
command: 'deliver',
|
|
receiptId,
|
|
status: 1,
|
|
error: lockOrOwnershipFailure
|
|
? `Could not safely continue delivery for "${options.output}": ${ownershipOrLockError.message}`
|
|
: options.error,
|
|
...(failureWasRecorded ? { provenance: recorded.status } : {}),
|
|
diagnostics: lockOrOwnershipFailure
|
|
? [
|
|
deliveryLockFailureDiagnostic(options.output, ownershipOrLockError),
|
|
...(options.diagnostics || []),
|
|
...(recorded.diagnostic ? [recorded.diagnostic] : []),
|
|
]
|
|
: [
|
|
...(options.diagnostics || []),
|
|
deliveryLockFailureDiagnostic(options.output, ownershipOrLockError),
|
|
...(recorded.diagnostic ? [recorded.diagnostic] : []),
|
|
],
|
|
});
|
|
return recorded;
|
|
}
|
|
const recoveryDiagnosticFirst = Boolean(recorded.journalRecovery || recorded.provenanceRecovery);
|
|
const diagnostics = recoveryDiagnosticFirst
|
|
? [
|
|
...(recorded.diagnostic ? [recorded.diagnostic] : []),
|
|
...(options.diagnostics || []),
|
|
]
|
|
: [
|
|
...(options.diagnostics || []),
|
|
...(recorded.diagnostic ? [recorded.diagnostic] : []),
|
|
];
|
|
reportArtifactFailure({
|
|
...options,
|
|
command: 'deliver',
|
|
receiptId,
|
|
provenance: recorded.status,
|
|
diagnostics,
|
|
});
|
|
return recorded;
|
|
}
|
|
|
|
function reportValidateFailure(options) {
|
|
reportArtifactFailure({ ...options, command: 'validate' });
|
|
}
|
|
|
|
function reportArtifactArgumentFailure(command, error) {
|
|
const details = error.archifyArgument || {};
|
|
reportArtifactFailure({
|
|
command,
|
|
json: true,
|
|
stage: 'arguments',
|
|
error: error.message,
|
|
diagnostics: [diagnostic({
|
|
code: details.code || 'cli/invalid-arguments',
|
|
message: error.message,
|
|
subject: { command, ...(details.subject || {}) },
|
|
evidence: details.evidence || {},
|
|
supportedFixes: details.supportedFixes || ['correct the command arguments and retry'],
|
|
})],
|
|
status: 2,
|
|
});
|
|
}
|
|
|
|
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) {
|
|
await loadSidecarPathRuntime();
|
|
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) rejectCliArgument(`Unknown deliver option "${unknown[0]}".`, {
|
|
code: 'cli/unknown-option',
|
|
subject: { option: unknown[0] },
|
|
supportedFixes: ['remove the unknown option and retry'],
|
|
});
|
|
const positional = repoArgs.rest.filter((arg) => !knownOptions.has(arg));
|
|
const [type, input, requestedOutput] = positional;
|
|
if (!type || !input || positional.length > 3) rejectCliArgument(usage(), {
|
|
code: 'cli/usage',
|
|
supportedFixes: ['use: archify deliver <type> <input.json> [output.html] [options]'],
|
|
});
|
|
const renderer = rendererPath(type);
|
|
const {
|
|
canonicalFuturePath,
|
|
pathsAlias,
|
|
resolveOutputPath,
|
|
validateAuthoredOutputPath,
|
|
} = await import('../renderers/shared/output-path.mjs');
|
|
const {
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = await import('../renderers/shared/atomic-output.mjs');
|
|
({ sidecarNamespaceComponentKey: sidecarNamespaceComponentKeyRuntime }
|
|
= await import('../renderers/shared/path-semantics.mjs'));
|
|
const fileBindingRuntime = {
|
|
backupPublicRegularFileBinding,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
};
|
|
const receiptId = randomUUID();
|
|
let updateCheck;
|
|
let deliveryOwnership;
|
|
let recoveryRequired = false;
|
|
let stagingDirectory;
|
|
let stagingIdentity;
|
|
let deliveryAliasOutput;
|
|
const reportDeliveryFailure = async (options) => {
|
|
const ownership = deliveryOwnership;
|
|
deliveryOwnership = undefined;
|
|
const recorded = writeDeliveryFailureReceipt({
|
|
...options,
|
|
...(updateCheck ? { update: await updateCheck } : {}),
|
|
pathsAlias,
|
|
receiptId,
|
|
ownership,
|
|
releaseOwnership: Boolean(ownership),
|
|
recoveryDirectory: stagingDirectory,
|
|
legacyOutputPaths: deliveryAliasOutput ? [deliveryAliasOutput] : [],
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
});
|
|
if (ownership && (recorded?.ownershipError || recorded?.lockError)?.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
recoveryRequired = true;
|
|
}
|
|
};
|
|
const reportPreparedArtifactFailure = async (options) => reportArtifactFailure({
|
|
...options,
|
|
...(updateCheck ? { update: await updateCheck } : {}),
|
|
});
|
|
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);
|
|
await 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 = diagram?.meta?.output;
|
|
let outputPath;
|
|
let provenancePath;
|
|
let preparedDeliveryTargets;
|
|
try {
|
|
validateAuthoredOutputPath(authoredOutput);
|
|
({ outputPath } = resolveOutputPath({
|
|
requestedOutput,
|
|
authoredOutput,
|
|
defaultOutput: `${type}.html`,
|
|
inputPaths: [inputPath],
|
|
}));
|
|
deliveryAliasOutput = outputPath;
|
|
// Redelivery through a case, normalization, 8.3, or file-symlink alias
|
|
// must replace the existing physical artifact without changing its stored
|
|
// spelling. That keeps its realpath-derived sidecars discoverable.
|
|
outputPath = canonicalFuturePath(outputPath);
|
|
} catch (error) {
|
|
const attemptedOutput = path.resolve(
|
|
requestedOutput
|
|
|| (typeof authoredOutput === 'string' && authoredOutput)
|
|
|| `${type}.html`,
|
|
);
|
|
const failure = {
|
|
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'],
|
|
})],
|
|
};
|
|
if (outputPath) {
|
|
await reportDeliveryFailure(failure);
|
|
} else {
|
|
await reportPreparedArtifactFailure({ ...failure, command: 'deliver', receiptId });
|
|
}
|
|
return;
|
|
}
|
|
let outputDirectory = path.dirname(outputPath);
|
|
try {
|
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
|
} catch (error) {
|
|
const message = `Could not create delivery directory "${outputDirectory}": ${error.message}`;
|
|
await 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;
|
|
}
|
|
|
|
// Name semantics such as NTFS per-directory case sensitivity cannot be
|
|
// projected through an unmaterialized directory. Creating the requested
|
|
// physical parent is directory preparation, not artifact/sidecar
|
|
// publication; all input, portable-path and native-resolution contracts
|
|
// above have already passed. Only now may sidecar naming probe the actual
|
|
// directory. A later target snapshot still rejects any authored alias that
|
|
// was retargeted across this boundary.
|
|
try {
|
|
const defaultProvenancePath = deliveryProvenancePath(outputPath);
|
|
({ outputPath: provenancePath } = resolveOutputPath({
|
|
requestedOutput: defaultProvenancePath,
|
|
defaultOutput: defaultProvenancePath,
|
|
requiredExtension: '.json',
|
|
inputPaths: [inputPath],
|
|
inputDescription: 'the delivery input',
|
|
otherOutputPaths: [outputPath],
|
|
}));
|
|
} catch (error) {
|
|
const attemptedOutput = path.resolve(requestedOutput || authoredOutput || `${type}.html`);
|
|
const failure = {
|
|
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'],
|
|
})],
|
|
};
|
|
if (error?.code === 'ARCHIFY_SIDECAR_NAMESPACE_INDETERMINATE') {
|
|
await reportPreparedArtifactFailure({ ...failure, command: 'deliver', receiptId });
|
|
} else {
|
|
await reportDeliveryFailure(failure);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const plannedOutputPath = outputPath;
|
|
const artifactCapture = {
|
|
requestedPath: deliveryAliasOutput || outputPath,
|
|
...captureAtomicOutput(deliveryAliasOutput || outputPath),
|
|
};
|
|
if (artifactCapture.status !== 'captured') {
|
|
throw deliveryTargetStateError(artifactCapture.requestedPath, 'HTML artifact', artifactCapture);
|
|
}
|
|
if (!pathsAlias(artifactCapture.commitPath, plannedOutputPath)) {
|
|
throw deliveryTargetStateError(artifactCapture.requestedPath, 'HTML artifact', {
|
|
status: 'different',
|
|
reason: {
|
|
code: 'write-slot-changed-before-snapshot',
|
|
plannedCommitPath: plannedOutputPath,
|
|
currentCommitPath: artifactCapture.commitPath,
|
|
},
|
|
});
|
|
}
|
|
const requestedProvenancePath = deliveryProvenancePath(artifactCapture.commitPath);
|
|
const provenanceCapture = {
|
|
requestedPath: requestedProvenancePath,
|
|
...captureAtomicOutput(requestedProvenancePath, {
|
|
requestedEntryPolicy: 'regular-or-absent',
|
|
}),
|
|
};
|
|
if (provenanceCapture.status !== 'captured') {
|
|
throw deliveryTargetStateError(requestedProvenancePath, 'delivery provenance', provenanceCapture);
|
|
}
|
|
preparedDeliveryTargets = { artifact: artifactCapture, provenance: provenanceCapture };
|
|
outputPath = artifactCapture.commitPath;
|
|
provenancePath = provenanceCapture.commitPath;
|
|
outputDirectory = path.dirname(outputPath);
|
|
const { startDeliveryUpdateCheck } = await import('./delivery-update.mjs');
|
|
updateCheck = startDeliveryUpdateCheck();
|
|
} catch (error) {
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'prepare', type, input: inputPath, output: outputPath, receiptId,
|
|
error: error.message,
|
|
diagnostics: error.archifyDiagnostics || [diagnostic({
|
|
code: 'output/target-indeterminate',
|
|
message: error.message,
|
|
subject: { output: outputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
|
|
supportedFixes: ['retry only after the output path and its parent directories stop changing'],
|
|
})],
|
|
});
|
|
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.
|
|
try {
|
|
const staging = createOwnedEmptyStagingDirectory(
|
|
path.join(outputDirectory, '.archify-delivery-'),
|
|
);
|
|
stagingDirectory = staging.directory;
|
|
stagingIdentity = staging.identity;
|
|
} catch (error) {
|
|
const message = `Could not create a delivery candidate beside "${outputPath}": ${error.message}`;
|
|
await 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');
|
|
const provenanceCandidatePath = path.join(stagingDirectory, 'delivery-provenance.json');
|
|
let commitRecoveryBackups = [];
|
|
const stagingOwnership = [];
|
|
const captureDeliveryStagingFile = (filePath, content) => captureOwnedStagingFile(
|
|
stagingOwnership,
|
|
filePath,
|
|
fileBindingRuntime,
|
|
{ subject: 'delivery-staging-entry', content },
|
|
);
|
|
|
|
try {
|
|
try {
|
|
deliveryOwnership = acquireDeliveryLock(
|
|
outputPath,
|
|
receiptId,
|
|
inputPath,
|
|
pathsAlias,
|
|
(lockError, initializingOwnership) => recordDeliveryFailure({
|
|
output: outputPath,
|
|
stage: 'prepare',
|
|
input: inputPath,
|
|
error: `Could not start delivery for "${outputPath}": ${lockError.message}`,
|
|
receiptId,
|
|
pathsAlias,
|
|
ownership: initializingOwnership,
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
}),
|
|
deliveryAliasOutput ? [deliveryAliasOutput] : [],
|
|
fileBindingRuntime,
|
|
);
|
|
} catch (error) {
|
|
const message = `Could not start delivery for "${outputPath}": ${error.message}`;
|
|
const recorded = error.deliveryFailureRecord;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'prepare', type, input: inputPath, output: outputPath, receiptId,
|
|
error: message,
|
|
...(recorded ? { provenance: recorded.status } : {}),
|
|
diagnostics: error.deliveryJournalRecovery?.recoveryRequired
|
|
? [deliveryJournalRecoveryDiagnostic(outputPath, error)]
|
|
: error.deliveryProvenanceRecovery?.recoveryRequired
|
|
? [
|
|
...(recorded?.diagnostic ? [recorded.diagnostic] : []),
|
|
deliveryLockFailureDiagnostic(outputPath, error),
|
|
]
|
|
: [
|
|
deliveryLockFailureDiagnostic(outputPath, error),
|
|
...(recorded?.diagnostic ? [recorded.diagnostic] : []),
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const state = deliveryOwnershipStates.get(deliveryOwnership);
|
|
const targets = captureDeliveryTargetState({
|
|
state,
|
|
requestedOutput: deliveryAliasOutput || outputPath,
|
|
pathsAlias,
|
|
captureAtomicOutput,
|
|
verifyAtomicOutput,
|
|
preparedTargets: preparedDeliveryTargets,
|
|
});
|
|
outputPath = targets.artifact.commitPath;
|
|
provenancePath = targets.provenance.commitPath;
|
|
} catch (error) {
|
|
let releaseError;
|
|
try {
|
|
releaseDeliveryOwnership(deliveryOwnership);
|
|
} catch (cause) {
|
|
releaseError = cause;
|
|
}
|
|
deliveryOwnership = undefined;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'prepare', type, input: inputPath, output: outputPath, receiptId,
|
|
error: error.message,
|
|
diagnostics: [
|
|
...(releaseError ? [deliveryLockFailureDiagnostic(outputPath, releaseError)] : []),
|
|
...(error.archifyDiagnostics || [diagnostic({
|
|
code: 'output/target-indeterminate',
|
|
message: error.message,
|
|
subject: { output: outputPath },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
|
|
})]),
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
beginDeliveryAttempt({
|
|
ownership: deliveryOwnership,
|
|
input: inputPath,
|
|
pathsAlias,
|
|
fileBindingRuntime,
|
|
});
|
|
} catch (error) {
|
|
if (error.deliveryJournalRecovery?.recoveryRequired) {
|
|
// The lock is the remaining fail-closed barrier. Do not write failure
|
|
// provenance or release it after journal rollback itself became unsafe.
|
|
deliveryOwnership = undefined;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'prepare', type, input: inputPath, output: outputPath, receiptId,
|
|
error: `Could not persist the delivery attempt before rendering: ${error.message}`,
|
|
diagnostics: [deliveryJournalRecoveryDiagnostic(outputPath, error)],
|
|
});
|
|
return;
|
|
}
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
deliveryOwnership = undefined;
|
|
recoveryRequired = true;
|
|
error.deliveryCommitDetails = {
|
|
recoveryRequired: true,
|
|
recoveryDirectory: stagingDirectory,
|
|
recoverableBackups: [],
|
|
};
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'prepare', type, input: inputPath, output: outputPath, receiptId,
|
|
error: `Could not persist the delivery attempt before rendering: ${error.message}`,
|
|
diagnostics: [deliveryLockFailureDiagnostic(outputPath, error)],
|
|
});
|
|
return;
|
|
}
|
|
await reportDeliveryFailure({
|
|
json, stage: 'prepare', type, input: inputPath, output: outputPath,
|
|
error: 'Could not persist the delivery attempt before rendering.',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/journal-write', message: 'Could not persist the delivery attempt before rendering.',
|
|
subject: {
|
|
output: outputPath,
|
|
journal: deliveryOwnershipStates.get(deliveryOwnership)?.pendingPath
|
|
|| deliveryPendingPath(outputPath),
|
|
},
|
|
evidence: { reason: error.message },
|
|
supportedFixes: ['choose a writable output directory with a journal path distinct from the input'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
fs.writeFileSync(specificationSnapshotPath, specification, { flag: 'wx' });
|
|
captureDeliveryStagingFile(
|
|
specificationSnapshotPath,
|
|
artifactIdentity(Buffer.from(specification)),
|
|
);
|
|
} catch (error) {
|
|
const message = `Could not freeze the delivery specification: ${error.message}`;
|
|
await 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);
|
|
await reportDeliveryFailure({
|
|
json,
|
|
stage: 'render',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: failure.error,
|
|
diagnostics: failure.diagnostics,
|
|
status: render.status ?? 1,
|
|
});
|
|
return;
|
|
}
|
|
if (render.stderr) process.stderr.write(render.stderr);
|
|
try {
|
|
const renderedCandidate = fs.readFileSync(candidatePath);
|
|
if (preparedDeliveryTargets.artifact.mode !== null) {
|
|
fs.chmodSync(candidatePath, preparedDeliveryTargets.artifact.mode);
|
|
}
|
|
captureDeliveryStagingFile(candidatePath, artifactIdentity(renderedCandidate));
|
|
} catch {
|
|
// The commit path performs the authoritative candidate classification.
|
|
// Leave an unbound entry untouched during private-directory cleanup.
|
|
}
|
|
|
|
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() };
|
|
}
|
|
await 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}`;
|
|
await 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}`;
|
|
await 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}`;
|
|
await 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,
|
|
receiptId,
|
|
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,
|
|
...(result.composition.summary.warnings ? {
|
|
compositionIssues: result.composition.issues.filter((issue) => issue.severity === 'warning'),
|
|
} : {}),
|
|
},
|
|
...(sourceEvidence ? {
|
|
evidence: {
|
|
verified: true,
|
|
repository: sourceEvidence.repository.url,
|
|
revision: sourceEvidence.repository.revision,
|
|
references: sourceEvidence.referenceCount,
|
|
...(sourceEvidence.repository.linkMode ? { linkMode: sourceEvidence.repository.linkMode } : {}),
|
|
},
|
|
} : {}),
|
|
};
|
|
|
|
const provenanceBytes = Buffer.from(`${JSON.stringify(deliverySuccessProvenance(receipt), null, 2)}\n`);
|
|
try {
|
|
fs.writeFileSync(provenanceCandidatePath, provenanceBytes, { flag: 'wx' });
|
|
if (preparedDeliveryTargets.provenance.mode !== null) {
|
|
fs.chmodSync(provenanceCandidatePath, preparedDeliveryTargets.provenance.mode);
|
|
}
|
|
try {
|
|
captureDeliveryStagingFile(
|
|
provenanceCandidatePath,
|
|
artifactIdentity(provenanceBytes),
|
|
);
|
|
} catch {
|
|
// commitDeliveryPair classifies this path against the expected bytes;
|
|
// cleanup must not claim a candidate that could not be bound here.
|
|
}
|
|
} catch (error) {
|
|
await reportDeliveryFailure({
|
|
json,
|
|
stage: 'commit',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: `Could not prepare delivery provenance for "${outputPath}": ${error.message}`,
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-prepare',
|
|
message: 'Could not prepare the artifact-bound delivery provenance.',
|
|
subject: { output: outputPath },
|
|
evidence: { reason: error.message },
|
|
supportedFixes: ['choose a writable output directory and rerun deliver'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentOutputPath = canonicalFuturePath(resolveOutputPath({
|
|
requestedOutput,
|
|
authoredOutput,
|
|
defaultOutput: `${type}.html`,
|
|
inputPaths: [inputPath],
|
|
otherOutputPaths: [provenancePath],
|
|
}).outputPath);
|
|
resolveOutputPath({
|
|
requestedOutput: deliveryProvenancePath(currentOutputPath),
|
|
defaultOutput: deliveryProvenancePath(currentOutputPath),
|
|
requiredExtension: '.json',
|
|
inputPaths: [inputPath],
|
|
inputDescription: 'the delivery input',
|
|
otherOutputPaths: [currentOutputPath],
|
|
});
|
|
} catch (error) {
|
|
await 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 {
|
|
const committed = commitDeliveryPair({
|
|
htmlCandidate: candidatePath,
|
|
htmlContent: receipt.artifact,
|
|
provenanceCandidate: provenanceCandidatePath,
|
|
provenanceContent: artifactIdentity(provenanceBytes),
|
|
stagingDirectory,
|
|
ownership: deliveryOwnership,
|
|
verifyAtomicOutput,
|
|
fileBindingRuntime,
|
|
});
|
|
commitRecoveryBackups = committed.recoverableBackups;
|
|
stagingOwnership.push(...committed.ownedBackupEntries);
|
|
} catch (error) {
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost' && !error.deliveryCommitDetails) {
|
|
error.deliveryCommitDetails = {
|
|
recoveryRequired: true,
|
|
recoveryDirectory: stagingDirectory,
|
|
recoverableBackups: [],
|
|
};
|
|
}
|
|
recoveryRequired = error.deliveryCommitDetails?.recoveryRequired === true;
|
|
const message = `Could not commit verified delivery "${outputPath}": ${error.message}`;
|
|
if (recoveryRequired) {
|
|
// The owned lock and pending journal are the remaining fail-closed
|
|
// barrier around retained backups and any claimant we did not create.
|
|
// Do not write failure provenance or release ownership here.
|
|
deliveryOwnership = undefined;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'commit', type, input: inputPath, output: outputPath, receiptId,
|
|
error: message,
|
|
diagnostics: [
|
|
...(error.deliveryOwnershipCode === 'delivery/ownership-lost'
|
|
? [deliveryLockFailureDiagnostic(outputPath, error)]
|
|
: []),
|
|
...(error.archifyDiagnostics || []),
|
|
deliveryCommitRecoveryDiagnostic(outputPath, error),
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
if (error.deliveryTargetState) {
|
|
let releaseError;
|
|
try {
|
|
releaseDeliveryOwnership(deliveryOwnership);
|
|
} catch (cause) {
|
|
releaseError = cause;
|
|
}
|
|
deliveryOwnership = undefined;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'commit', type, input: inputPath, output: outputPath, receiptId,
|
|
error: message,
|
|
diagnostics: [
|
|
...(releaseError ? [deliveryLockFailureDiagnostic(outputPath, releaseError)] : []),
|
|
...(error.archifyDiagnostics || []),
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
deliveryOwnership = undefined;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'commit', type, input: inputPath, output: outputPath, receiptId,
|
|
error: message,
|
|
diagnostics: [deliveryLockFailureDiagnostic(outputPath, error)],
|
|
});
|
|
return;
|
|
}
|
|
await reportDeliveryFailure({
|
|
json,
|
|
stage: 'commit',
|
|
type,
|
|
input: inputPath,
|
|
output: outputPath,
|
|
error: message,
|
|
diagnostics: error.archifyDiagnostics || [diagnostic({
|
|
code: 'delivery/commit',
|
|
message,
|
|
subject: { output: outputPath },
|
|
evidence: {
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
reason: error.message,
|
|
...(error.deliveryCommitDetails || {}),
|
|
},
|
|
supportedFixes: ['choose replaceable regular-file artifact and provenance targets on the same writable filesystem'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
releaseDeliveryOwnership(deliveryOwnership);
|
|
deliveryOwnership = undefined;
|
|
} catch (error) {
|
|
deliveryOwnership = undefined;
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
recoveryRequired = true;
|
|
error.deliveryCommitDetails = {
|
|
recoveryRequired: true,
|
|
recoveryDirectory: stagingDirectory,
|
|
recoverableBackups: commitRecoveryBackups,
|
|
};
|
|
}
|
|
const message = `Delivery committed, but its lock could not be released for "${outputPath}": ${error.message}`;
|
|
await reportPreparedArtifactFailure({
|
|
command: 'deliver', json, stage: 'release', type, input: inputPath, output: outputPath, receiptId,
|
|
error: message,
|
|
diagnostics: [deliveryLockFailureDiagnostic(outputPath, error)],
|
|
});
|
|
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}). ${receipt.open.failure?.reason || 'Open it manually.'} Target: ${outputPath}`);
|
|
}
|
|
}
|
|
|
|
receipt.update = await updateCheck;
|
|
|
|
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}`);
|
|
if (receipt.update.noticeRequired) console.log(receipt.update.noticeText);
|
|
}
|
|
} finally {
|
|
if (deliveryOwnership) {
|
|
try {
|
|
releaseDeliveryOwnership(deliveryOwnership, { allowInitializing: true });
|
|
} catch (error) {
|
|
if (error.deliveryOwnershipCode === 'delivery/ownership-lost') {
|
|
recoveryRequired = true;
|
|
error.deliveryCommitDetails = {
|
|
...(error.deliveryCommitDetails || {}),
|
|
recoveryRequired: true,
|
|
recoveryDirectory: stagingDirectory,
|
|
recoverableBackups: commitRecoveryBackups,
|
|
};
|
|
}
|
|
console.error(formatDiagnostics(
|
|
`Delivery cleanup could not release its lock for "${outputPath}".`,
|
|
[deliveryLockFailureDiagnostic(outputPath, error)],
|
|
));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
if (recoveryRequired) {
|
|
releaseOwnedStagingBindings(stagingOwnership, fileBindingRuntime);
|
|
console.error(`Recovery required: delivery backups were retained at "${stagingDirectory}".`);
|
|
} else {
|
|
try {
|
|
cleanupOwnedStagingDirectory(
|
|
stagingDirectory,
|
|
stagingIdentity,
|
|
stagingOwnership,
|
|
fileBindingRuntime,
|
|
);
|
|
} 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());
|
|
rendererPath(type);
|
|
|
|
try {
|
|
const source = JSON.parse(fs.readFileSync(path.resolve(input), 'utf8'));
|
|
const { validateAuthoredOutputPath } = await import('../renderers/shared/output-path.mjs');
|
|
validateAuthoredOutputPath(source?.meta?.output);
|
|
} catch (error) {
|
|
// Preserve live repair for unreadable or malformed JSON. A parsed document,
|
|
// however, must already carry the durable output contract before preview
|
|
// allocates a server, watcher, or staging directory.
|
|
if (error?.archifyDiagnostics) fail(`Could not start live preview: ${error.message}`, 1);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function loadPathIdentityRuntime() {
|
|
try {
|
|
await loadSidecarPathRuntime();
|
|
const {
|
|
sameEntry,
|
|
sidecarNamespaceComponentKey,
|
|
} = await import('../renderers/shared/path-semantics.mjs');
|
|
sidecarNamespaceComponentKeyRuntime = sidecarNamespaceComponentKey;
|
|
const {
|
|
captureRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = await import('../renderers/shared/atomic-output.mjs');
|
|
return {
|
|
sameEntry,
|
|
captureRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
};
|
|
} catch (error) {
|
|
return { error };
|
|
}
|
|
}
|
|
|
|
function inspectArtifactDeliveryProvenance(
|
|
artifactPath,
|
|
requireProvenance,
|
|
pathIdentityRuntime,
|
|
expectedBindings,
|
|
) {
|
|
const {
|
|
captureRegularFileBinding,
|
|
verifyRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
} = pathIdentityRuntime || {};
|
|
const stableArtifactPath = canonicalExistingDeliveryOutput(artifactPath);
|
|
let artifactCapture;
|
|
try {
|
|
if (typeof captureRegularFileBinding !== 'function'
|
|
|| typeof verifyRegularFileBinding !== 'function'
|
|
|| typeof releaseRegularFileBinding !== 'function') {
|
|
throw new Error('The regular-file binding runtime is unavailable.');
|
|
}
|
|
artifactCapture = captureRegularFileBinding(stableArtifactPath, {
|
|
subject: 'delivery-artifact',
|
|
...(expectedBindings?.artifact ? {
|
|
expectedIdentity: expectedBindings.artifact.identity,
|
|
expectedSha256: expectedBindings.artifact.content.sha256,
|
|
expectedBytes: expectedBindings.artifact.content.bytes,
|
|
expectedMode: expectedBindings.artifact.mode,
|
|
} : {}),
|
|
expectedLinks: 1,
|
|
includeContent: true,
|
|
});
|
|
if (artifactCapture.status !== 'captured') {
|
|
if (artifactCapture.reason?.code === 'delivery-artifact-hardlinked') {
|
|
const message = 'Delivery provenance is unsupported for a hard-linked artifact because one receipt cannot prove every linked name was updated atomically.';
|
|
return {
|
|
ok: false,
|
|
status: 'unsupported',
|
|
diagnostics: [diagnostic({
|
|
code: 'output/target-hardlinked',
|
|
message,
|
|
subject: { artifact: path.resolve(artifactPath) },
|
|
evidence: { links: artifactCapture.reason.links },
|
|
supportedFixes: ['remove the extra hard links, then rerun deliver and strict check on the remaining artifact path'],
|
|
})],
|
|
};
|
|
}
|
|
throw new Error(`The artifact is not a stable regular file (${artifactCapture.reason?.code || 'unknown target state'}).`);
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
ok: false, status: 'unknown',
|
|
diagnostics: [diagnostic({
|
|
code: 'input/artifact-unreadable', message: `Could not read the HTML artifact: ${error.message}`,
|
|
subject: { artifact: artifactPath },
|
|
evidence: { reason: error.message, ...(error.code ? { systemCode: error.code } : {}) },
|
|
supportedFixes: ['provide an existing readable .html artifact, then rerun the checker'],
|
|
})],
|
|
};
|
|
}
|
|
try {
|
|
const inspection = {};
|
|
const provenance = inspectDeliveryProvenance(
|
|
artifactPath,
|
|
artifactCapture.content.buffer,
|
|
{
|
|
requireProvenance,
|
|
pathIdentityRuntime,
|
|
expectedSidecar: expectedBindings?.sidecar,
|
|
inspection,
|
|
},
|
|
);
|
|
const verification = verifyRegularFileBinding(artifactCapture.binding, {
|
|
filePath: stableArtifactPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (verification.status !== 'match') {
|
|
return {
|
|
ok: false,
|
|
status: 'mismatch',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-mismatch',
|
|
message: 'The artifact changed while delivery provenance was being inspected.',
|
|
subject: { artifact: artifactPath },
|
|
evidence: { artifactState: verification.reason },
|
|
supportedFixes: ['finish delivery and other writes before rerunning the checker'],
|
|
})],
|
|
};
|
|
}
|
|
const result = {
|
|
...provenance,
|
|
artifact: {
|
|
sha256: artifactCapture.content.sha256,
|
|
bytes: artifactCapture.content.bytes,
|
|
},
|
|
};
|
|
Object.defineProperty(result, 'fileBindings', {
|
|
enumerable: false,
|
|
value: {
|
|
artifact: {
|
|
identity: {
|
|
device: artifactCapture.identity.device,
|
|
inode: artifactCapture.identity.inode,
|
|
},
|
|
content: {
|
|
sha256: artifactCapture.content.sha256,
|
|
bytes: artifactCapture.content.bytes,
|
|
},
|
|
mode: artifactCapture.mode,
|
|
},
|
|
...(inspection.sidecar ? { sidecar: inspection.sidecar } : {}),
|
|
},
|
|
});
|
|
return result;
|
|
} finally {
|
|
releaseRegularFileBinding(artifactCapture.binding);
|
|
}
|
|
}
|
|
|
|
function verifyDeliveryUnchanged(artifactPath, expected, checkedArtifact, pathIdentityRuntime) {
|
|
const current = inspectArtifactDeliveryProvenance(
|
|
artifactPath,
|
|
false,
|
|
pathIdentityRuntime,
|
|
expected?.fileBindings,
|
|
);
|
|
if (current && !current.ok) return current;
|
|
if (!current || !expected || current.status !== expected.status || current.receiptId !== expected.receiptId
|
|
|| current.artifact.sha256 !== expected.artifact.sha256 || current.artifact.bytes !== expected.artifact.bytes
|
|
|| checkedArtifact.sha256 !== expected.artifact.sha256 || checkedArtifact.bytes !== expected.artifact.bytes) {
|
|
return {
|
|
ok: false, status: 'mismatch',
|
|
diagnostics: [diagnostic({
|
|
code: 'delivery/provenance-mismatch', message: 'The artifact or delivery record changed during inspection.',
|
|
subject: { artifact: artifactPath },
|
|
evidence: { expected: expected?.artifact, checked: checkedArtifact, current: current?.artifact },
|
|
supportedFixes: ['finish delivery and other writes before rerunning the checker'],
|
|
})],
|
|
};
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function provenanceFailureReceipt({ command, artifactPath, provenance }) {
|
|
const error = provenance.diagnostics[0].message;
|
|
return {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command,
|
|
...(['visual-check', 'browser-check'].includes(command) ? {
|
|
evidenceKind: 'automated-browser',
|
|
status: 'fail',
|
|
visualReview: command === 'visual-check' ? 'pending' : 'not-requested',
|
|
} : {}),
|
|
provenance: provenance.status,
|
|
...(provenance.receiptId ? { deliveryReceiptId: provenance.receiptId } : {}),
|
|
artifact: { path: artifactPath },
|
|
error,
|
|
...(command === 'check' ? { file: artifactPath, diagnostic: error } : {}),
|
|
diagnostics: provenance.diagnostics,
|
|
};
|
|
}
|
|
|
|
async function commandCheck(args) {
|
|
const knownOptions = new Set(['--json', '--require-provenance']);
|
|
const unknown = args.find((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown) fail(`Unknown check option "${unknown}".`);
|
|
const requireProvenance = args.includes('--require-provenance');
|
|
const positional = args.filter((arg) => !knownOptions.has(arg));
|
|
const [html] = positional;
|
|
if (!html || positional.length !== 1) fail(usage());
|
|
const artifactPath = path.resolve(html);
|
|
const pathIdentityRuntime = await loadPathIdentityRuntime();
|
|
const provenance = inspectArtifactDeliveryProvenance(
|
|
artifactPath,
|
|
requireProvenance,
|
|
pathIdentityRuntime,
|
|
);
|
|
if (provenance && !provenance.ok) {
|
|
console.log(JSON.stringify(provenanceFailureReceipt({ command: 'check', artifactPath, provenance }), null, 2));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const result = runNode([path.join(skillRoot, 'scripts/check-render-output.mjs'), html], { stdio: 'pipe' });
|
|
if (result.error) {
|
|
console.error(result.error.message);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (result.stdout) {
|
|
let receipt;
|
|
try {
|
|
receipt = JSON.parse(result.stdout);
|
|
} catch {
|
|
process.stdout.write(result.stdout);
|
|
}
|
|
if (receipt) {
|
|
if (provenance) {
|
|
const verified = verifyDeliveryUnchanged(
|
|
artifactPath,
|
|
provenance,
|
|
receipt.artifact || {},
|
|
pathIdentityRuntime,
|
|
);
|
|
if (!verified.ok) {
|
|
console.log(JSON.stringify(provenanceFailureReceipt({ command: 'check', artifactPath, provenance: verified }), null, 2));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
receipt.provenance = provenance.status;
|
|
if (provenance.receiptId) receipt.deliveryReceiptId = provenance.receiptId;
|
|
}
|
|
console.log(JSON.stringify(receipt, null, 2));
|
|
}
|
|
}
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
if (result.status !== 0) process.exitCode = result.status ?? 1;
|
|
}
|
|
|
|
async function executeBrowserEvidence({
|
|
artifactPath, outDir, requireProvenance, command, capture, ...browserOptions
|
|
}) {
|
|
const pathIdentityRuntime = await loadPathIdentityRuntime();
|
|
const provenance = inspectArtifactDeliveryProvenance(artifactPath, requireProvenance, pathIdentityRuntime);
|
|
let runEvidence;
|
|
let persistFailure;
|
|
try {
|
|
const evidence = await import('./visual-check.mjs');
|
|
runEvidence = command === 'browser-check' ? evidence.runBrowserCheck : evidence.runVisualCheck;
|
|
persistFailure = command === 'browser-check'
|
|
? evidence.persistBrowserCheckFailure
|
|
: evidence.persistVisualCheckFailure;
|
|
} catch (error) {
|
|
fail(`Could not load ${command}: ${error.message}`, 1);
|
|
}
|
|
if (provenance && !provenance.ok) {
|
|
const receipt = persistFailure(artifactPath,
|
|
provenanceFailureReceipt({ command, artifactPath, provenance }),
|
|
{ outDir });
|
|
return { exitCode: 1, receipt, inputFailure: true };
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
result = await runEvidence({
|
|
...browserOptions,
|
|
artifactPath,
|
|
outDir,
|
|
...(provenance ? { deliveryProvenance: provenance } : {}),
|
|
verifyArtifact: (bytes) => {
|
|
const verified = verifyDeliveryUnchanged(
|
|
artifactPath,
|
|
provenance,
|
|
artifactIdentity(bytes),
|
|
pathIdentityRuntime,
|
|
);
|
|
if (!verified.ok) {
|
|
const error = new Error(verified.diagnostics[0].message);
|
|
error.deliveryProvenance = verified;
|
|
error.archifyDiagnostics = verified.diagnostics;
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const failure = persistFailure(artifactPath, {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command,
|
|
evidenceKind: 'automated-browser',
|
|
status: 'fail',
|
|
visualReview: capture ? 'pending' : 'not-requested',
|
|
...(provenance ? {
|
|
provenance: provenance.status,
|
|
...(provenance.receiptId ? { deliveryReceiptId: provenance.receiptId } : {}),
|
|
} : {}),
|
|
artifact: { path: artifactPath },
|
|
error: error.message,
|
|
diagnostics: [diagnostic({
|
|
code: `viewer/${command}-input`,
|
|
message: `${command} could not read a valid HTML input or prepare its evidence files.`,
|
|
subject: { artifact: artifactPath },
|
|
evidence: { reason: error.message, ...(error.code ? { systemCode: error.code } : {}) },
|
|
supportedFixes: ['provide an existing readable .html artifact and a writable directory for evidence files'],
|
|
})],
|
|
}, { outDir });
|
|
return { exitCode: 1, receipt: failure, inputFailure: true };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function commandBrowserEvidence(rawArgs, { command, capture }) {
|
|
const { rest: args, outDir: rawOutDir } = extractOutDirArgs(rawArgs);
|
|
const summary = args.includes('--summary');
|
|
const json = args.includes('--json') || summary;
|
|
const printJson = async (receipt) => {
|
|
const value = summary
|
|
? (await import('./visual-check.mjs')).summarizeBrowserEvidence(receipt)
|
|
: receipt;
|
|
console.log(JSON.stringify(value, null, summary ? undefined : 2));
|
|
};
|
|
const requireProvenance = args.includes('--require-provenance');
|
|
const knownOptions = new Set(['--json', '--summary', '--require-provenance']);
|
|
const unknown = args.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown.length) fail(`Unknown ${command} option "${unknown[0]}".`, 1);
|
|
const positional = args.filter((arg) => !knownOptions.has(arg));
|
|
if (positional.length !== 1) fail(usage(), 1);
|
|
|
|
const artifactPath = path.resolve(positional[0]);
|
|
let outDir;
|
|
if (rawOutDir !== undefined) {
|
|
const { resolveNativeOutputDirectory } = await import('../renderers/shared/output-path.mjs');
|
|
try {
|
|
outDir = resolveNativeOutputDirectory(rawOutDir);
|
|
} catch (error) {
|
|
const outputDiagnostic = error.archifyDiagnostics?.[0] || diagnostic({
|
|
code: 'output/native-path-syntax',
|
|
message: error.message,
|
|
subject: { output: rawOutDir },
|
|
evidence: { ...(error?.code ? { systemCode: error.code } : {}) },
|
|
supportedFixes: ['choose an ordinary native filesystem directory and retry'],
|
|
});
|
|
const failure = {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command,
|
|
evidenceKind: 'automated-browser',
|
|
status: 'fail',
|
|
visualReview: capture ? 'pending' : 'not-requested',
|
|
artifact: { path: artifactPath },
|
|
error: error.message,
|
|
diagnostics: [outputDiagnostic],
|
|
};
|
|
if (json) await printJson(failure);
|
|
else {
|
|
console.error(formatDiagnostics(`automated browser evidence failed: ${failure.error}`, failure.diagnostics));
|
|
console.error('perceptual visual review pending');
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
const result = await executeBrowserEvidence({ artifactPath, outDir, requireProvenance, command, capture });
|
|
|
|
if (result.inputFailure) {
|
|
if (json) await printJson(result.receipt);
|
|
else {
|
|
console.error(formatDiagnostics(`automated browser evidence failed: ${result.receipt.error}`, result.receipt.diagnostics));
|
|
console.error(capture ? 'perceptual visual review pending' : 'perceptual visual review not requested');
|
|
}
|
|
process.exitCode = result.exitCode;
|
|
return;
|
|
}
|
|
|
|
if (json) {
|
|
await printJson(result.receipt);
|
|
} else {
|
|
const sidecarDirectory = outDir || path.dirname(result.receipt.artifact.path);
|
|
console.log(`automated browser evidence ${result.receipt.status}: ${result.receipt.artifact.path}`);
|
|
console.log(`${command} containment ${result.receipt.containment.status}; captures ${result.receipt.captures.status}; perceptual visual review ${result.receipt.visualReview}`);
|
|
console.log(`receipt ${path.join(sidecarDirectory, result.receipt.sidecars.receipt)}`);
|
|
if (result.receipt.captures.contactSheet) {
|
|
console.log(`contact sheet ${path.join(sidecarDirectory, result.receipt.captures.contactSheet)}`);
|
|
}
|
|
if (result.receipt.error) console.error(result.receipt.error);
|
|
}
|
|
process.exitCode = result.exitCode;
|
|
}
|
|
|
|
async function commandVisualCheck(rawArgs) {
|
|
return commandBrowserEvidence(rawArgs, { command: 'visual-check', capture: true });
|
|
}
|
|
|
|
async function commandBrowserCheck(rawArgs) {
|
|
return commandBrowserEvidence(rawArgs, { command: 'browser-check', capture: false });
|
|
}
|
|
|
|
function extractFinalizeReceiptArgs(args) {
|
|
const rest = [];
|
|
let receiptPath;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--receipt') {
|
|
receiptPath = args[index + 1];
|
|
if (!receiptPath || receiptPath.startsWith('--')) rejectCliArgument('--receipt requires a JSON output path.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--receipt' },
|
|
supportedFixes: ['provide one .json path after --receipt'],
|
|
});
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--receipt=')) {
|
|
receiptPath = arg.slice('--receipt='.length);
|
|
if (!receiptPath) rejectCliArgument('--receipt requires a JSON output path.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--receipt' },
|
|
supportedFixes: ['provide one .json path after --receipt'],
|
|
});
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
return { rest, receiptPath };
|
|
}
|
|
|
|
function extractCandidateSha256Args(args) {
|
|
const rest = [];
|
|
let candidateSha256;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === '--candidate-sha256') {
|
|
candidateSha256 = args[index + 1];
|
|
if (!candidateSha256 || candidateSha256.startsWith('--')) rejectCliArgument('--candidate-sha256 requires a SHA-256 digest.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--candidate-sha256' },
|
|
supportedFixes: ['provide the candidate sha256 from the passing validate receipt'],
|
|
});
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--candidate-sha256=')) {
|
|
candidateSha256 = arg.slice('--candidate-sha256='.length);
|
|
if (!candidateSha256) rejectCliArgument('--candidate-sha256 requires a SHA-256 digest.', {
|
|
code: 'cli/missing-option-value',
|
|
subject: { option: '--candidate-sha256' },
|
|
supportedFixes: ['provide the candidate sha256 from the passing validate receipt'],
|
|
});
|
|
continue;
|
|
}
|
|
rest.push(arg);
|
|
}
|
|
if (candidateSha256 !== undefined && !/^[0-9a-f]{64}$/.test(candidateSha256)) {
|
|
rejectCliArgument('--candidate-sha256 must be 64 lowercase hexadecimal characters.', {
|
|
code: 'cli/invalid-option-value',
|
|
subject: { option: '--candidate-sha256' },
|
|
evidence: { value: candidateSha256 },
|
|
supportedFixes: ['copy candidate.sha256 from the passing validate receipt without modification'],
|
|
});
|
|
}
|
|
return { rest, candidateSha256 };
|
|
}
|
|
|
|
async function commandFinalize(rawArgs) {
|
|
const qualityArgs = extractQualityArgs(rawArgs);
|
|
const repoArgs = extractRepoRootArgs(qualityArgs.rest);
|
|
const outDirArgs = extractOutDirArgs(repoArgs.rest);
|
|
const receiptArgs = extractFinalizeReceiptArgs(outDirArgs.rest);
|
|
const candidateArgs = extractCandidateSha256Args(receiptArgs.rest);
|
|
const json = candidateArgs.rest.includes('--json');
|
|
const knownOptions = new Set(['--json']);
|
|
const unknown = candidateArgs.rest.filter((arg) => arg.startsWith('--') && !knownOptions.has(arg));
|
|
if (unknown.length) rejectCliArgument(`Unknown finalize option "${unknown[0]}".`, {
|
|
code: 'cli/unknown-option',
|
|
subject: { option: unknown[0] },
|
|
supportedFixes: ['remove the unknown option and retry'],
|
|
});
|
|
const positional = candidateArgs.rest.filter((arg) => !knownOptions.has(arg));
|
|
const [type, input, output] = positional;
|
|
if (!type || !input || !output || positional.length !== 3) rejectCliArgument(usage(), {
|
|
code: 'cli/usage',
|
|
supportedFixes: ['use: archify finalize <type> <input.json> <output.html> [options]'],
|
|
});
|
|
rendererPath(type);
|
|
|
|
let runFinalize;
|
|
try {
|
|
({ runFinalize } = await import('./finalize.mjs'));
|
|
} catch (error) {
|
|
fail(`Could not load finalize: ${error.message}`, 1);
|
|
}
|
|
|
|
let result;
|
|
try {
|
|
const pathIdentityRuntime = await loadPathIdentityRuntime();
|
|
result = await runFinalize({
|
|
cliPath: fileURLToPath(import.meta.url),
|
|
type,
|
|
input,
|
|
output,
|
|
quality: qualityArgs.quality || 'showcase',
|
|
repoRoot: repoArgs.repoRoot,
|
|
candidateSha256: candidateArgs.candidateSha256,
|
|
outDir: outDirArgs.outDir,
|
|
receiptPath: receiptArgs.receiptPath,
|
|
inspectDelivery: artifact => inspectArtifactDeliveryProvenance(artifact, true, pathIdentityRuntime),
|
|
deliveryPaths: artifact => ({
|
|
provenance: deliveryProvenancePath(artifact),
|
|
pending: deliveryPendingPath(artifact),
|
|
lock: deliverySidecarPath(artifact, '.delivery-lock.json'),
|
|
directoryLock: path.join(path.dirname(deliveryProvenancePath(artifact)), DELIVERY_DIRECTORY_LOCK),
|
|
}),
|
|
runBrowserCheck: options => executeBrowserEvidence({
|
|
...options, command: 'browser-check', capture: false, requireProvenance: true,
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
const failure = {
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
command: 'finalize',
|
|
status: 'fail',
|
|
type,
|
|
quality: qualityArgs.quality || 'showcase',
|
|
specification: { path: path.resolve(input) },
|
|
artifact: { path: path.resolve(output) },
|
|
gates: Object.fromEntries(['validate', 'deliver', 'check', 'browser-check'].map((stage) => [stage, 'not-run'])),
|
|
diagnostics: error.archifyDiagnostics || [{
|
|
code: error.finalizeCode || 'finalize/runtime',
|
|
severity: 'error',
|
|
message: error.message,
|
|
...(error.finalizeEvidence ? { evidence: error.finalizeEvidence } : {}),
|
|
}],
|
|
visualReview: 'not-requested',
|
|
};
|
|
if (json) console.log(JSON.stringify(failure));
|
|
else console.error(formatDiagnostics('finalize could not start', failure.diagnostics));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (json) {
|
|
console.log(JSON.stringify(result.summary));
|
|
} else {
|
|
console.log(`finalize ${result.summary.status}: ${result.summary.artifact.path}`);
|
|
console.log(`gates ${Object.entries(result.summary.gates).map(([stage, status]) => `${stage}:${status}`).join(' ')}`);
|
|
console.log(`receipt ${result.summary.evidence.receipt}`);
|
|
console.log(`perceptual visual review ${result.summary.visualReview}`);
|
|
if (result.summary.update?.noticeRequired) console.log(result.summary.update.noticeText);
|
|
}
|
|
process.exitCode = result.exitCode;
|
|
}
|
|
|
|
function commandExamples(args) {
|
|
const unknown = args.find((arg) => arg.startsWith('--'));
|
|
if (unknown) fail(`Unknown examples option "${unknown}".`);
|
|
if (args.length) fail(usage());
|
|
const result = runNode([path.join(skillRoot, 'scripts/render-examples.mjs')], { cwd: skillRoot });
|
|
if (result.status !== 0) exitFrom(result);
|
|
}
|
|
|
|
async function commandDoctor(args) {
|
|
const unknown = args.find((arg) => arg.startsWith('--'));
|
|
if (unknown) fail(`Unknown doctor option "${unknown}".`);
|
|
if (args.length) fail(usage());
|
|
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 finalizeRuntime = path.join(skillRoot, 'bin/finalize.mjs');
|
|
checks.push({
|
|
label: 'Finalize runtime',
|
|
ok: fs.existsSync(finalizeRuntime),
|
|
missing: fs.existsSync(finalizeRuntime) ? 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 atomicOutputRuntime = path.join(skillRoot, 'renderers/shared/atomic-output.mjs');
|
|
checks.push({
|
|
label: 'Atomic output safety runtime',
|
|
ok: fs.existsSync(atomicOutputRuntime),
|
|
missing: fs.existsSync(atomicOutputRuntime) ? 0 : 1,
|
|
});
|
|
|
|
const pathSemanticsRuntime = path.join(skillRoot, 'renderers/shared/path-semantics.mjs');
|
|
checks.push({
|
|
label: 'Physical path semantics runtime',
|
|
ok: fs.existsSync(pathSemanticsRuntime),
|
|
missing: fs.existsSync(pathSemanticsRuntime) ? 0 : 1,
|
|
});
|
|
|
|
const sidecarPathRuntime = path.join(skillRoot, 'renderers/shared/sidecar-path.mjs');
|
|
checks.push({
|
|
label: 'Sidecar path naming runtime',
|
|
ok: fs.existsSync(sidecarPathRuntime),
|
|
missing: fs.existsSync(sidecarPathRuntime) ? 0 : 1,
|
|
});
|
|
|
|
const portablePathRuntime = path.join(skillRoot, 'renderers/shared/portable-path.mjs');
|
|
checks.push({
|
|
label: 'Portable path contract runtime',
|
|
ok: fs.existsSync(portablePathRuntime),
|
|
missing: fs.existsSync(portablePathRuntime) ? 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) {
|
|
const unknown = args.find((arg) => arg.startsWith('--'));
|
|
if (unknown) fail(`Unknown demo option "${unknown}".`);
|
|
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;
|
|
let output;
|
|
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 === '--output') {
|
|
output = args[index + 1];
|
|
if (!output || output.startsWith('--')) fail('--output requires a portable HTML path.');
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--output=')) {
|
|
output = arg.slice('--output='.length);
|
|
if (!output) fail('--output requires a portable HTML path.');
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--')) fail(`Unknown migrate option "${arg}".`);
|
|
positional.push(arg);
|
|
}
|
|
return { positional, json, toSchema, output };
|
|
}
|
|
|
|
async function commandMigrate(args) {
|
|
const repoArgs = extractRepoRootArgs(args);
|
|
const options = extractMigrationOptions(repoArgs.rest);
|
|
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 [--output portable.html] [--json] [--repo-root path]');
|
|
}
|
|
|
|
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;
|
|
}
|
|
const { pathsAlias, validateAuthoredOutputPath } = await import('../renderers/shared/output-path.mjs');
|
|
const {
|
|
backupPublicRegularFileBinding,
|
|
captureAtomicOutput,
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
verifyAtomicOutput,
|
|
verifyRegularFileBinding,
|
|
} = await import('../renderers/shared/atomic-output.mjs');
|
|
// `--output` repairs only the durable output field in the migration
|
|
// candidate. It never writes the source and deliberately leaves every
|
|
// other schema and compiler failure visible to the normal migration path.
|
|
let migrationSourceDocument = sourceDocument;
|
|
if (options.output !== undefined) {
|
|
try {
|
|
validateAuthoredOutputPath(options.output);
|
|
} catch (error) {
|
|
reportMigrationFailure({
|
|
preExistingDiagnostics: migrationPathDiagnostics(error, sourcePath, destinationPath),
|
|
});
|
|
return;
|
|
}
|
|
if (sourceDocument?.meta && typeof sourceDocument.meta === 'object' && !Array.isArray(sourceDocument.meta)) {
|
|
migrationSourceDocument = {
|
|
...sourceDocument,
|
|
meta: { ...sourceDocument.meta, output: options.output },
|
|
};
|
|
}
|
|
}
|
|
if (migrationSourceDocument?.meta?.output !== undefined) {
|
|
try {
|
|
validateAuthoredOutputPath(migrationSourceDocument.meta.output);
|
|
} catch (error) {
|
|
reportMigrationFailure({
|
|
preExistingDiagnostics: migrationPathDiagnostics(error, sourcePath, destinationPath),
|
|
});
|
|
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 = migrationSourceDocument?.meta?.quality_profile || 'standard';
|
|
|
|
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(migrationSourceDocument);
|
|
} 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;
|
|
}
|
|
|
|
const destinationDirectory = path.dirname(destinationPath);
|
|
let stagingDirectory;
|
|
let stagingIdentity;
|
|
let destinationCapture;
|
|
try {
|
|
fs.mkdirSync(destinationDirectory, { recursive: true });
|
|
destinationCapture = captureAtomicOutput(destinationPath, {
|
|
requestedEntryPolicy: 'regular-or-absent',
|
|
});
|
|
if (destinationCapture.status !== 'captured') {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-type',
|
|
message: 'Workflow migration destination must be an absent or stable regular file path.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { targetState: destinationCapture.reason },
|
|
supportedFixes: ['choose a destination path that is absent or names one non-hardlinked regular file'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const staging = createOwnedEmptyStagingDirectory(
|
|
path.join(destinationDirectory, '.archify-migration-'),
|
|
);
|
|
stagingDirectory = staging.directory;
|
|
stagingIdentity = staging.identity;
|
|
} 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));
|
|
let candidateBinding = null;
|
|
let previousDestinationBinding = null;
|
|
let preserveRecoveryDirectory = false;
|
|
const fileBindingRuntime = {
|
|
captureRegularFileBinding,
|
|
quarantineRemoveRegularFileBinding,
|
|
releaseRegularFileBinding,
|
|
};
|
|
const stagingOwnership = [];
|
|
try {
|
|
fs.writeFileSync(candidatePath, destinationBytes, { flag: 'wx' });
|
|
if (destinationCapture.mode !== null) fs.chmodSync(candidatePath, destinationCapture.mode);
|
|
const candidateCapture = captureOwnedStagingFile(
|
|
stagingOwnership,
|
|
candidatePath,
|
|
fileBindingRuntime,
|
|
{
|
|
subject: 'migration-candidate',
|
|
content: artifactIdentity(destinationBytes),
|
|
},
|
|
);
|
|
candidateBinding = candidateCapture.binding;
|
|
const render = runNode([rendererPath('workflow'), candidatePath, artifactPath], {
|
|
stdio: 'pipe',
|
|
env: rendererEnv(activeQualityProfile, repoArgs.repoRoot, true),
|
|
});
|
|
if (render.status !== 0) {
|
|
const failure = rendererFailure(render);
|
|
reportMigrationFailure({
|
|
...migration,
|
|
newSchemaDiagnostics: [...migration.newSchemaDiagnostics, ...failure.diagnostics],
|
|
status: render.status ?? 1,
|
|
});
|
|
return;
|
|
}
|
|
const migrationArtifact = fs.readFileSync(artifactPath);
|
|
captureOwnedStagingFile(stagingOwnership, artifactPath, fileBindingRuntime, {
|
|
subject: 'migration-check-artifact',
|
|
content: artifactIdentity(migrationArtifact),
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
const candidateVerification = verifyRegularFileBinding(candidateBinding, { expectedLinks: 1 });
|
|
if (candidateVerification.status !== 'match') {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/candidate-changed',
|
|
message: 'Workflow migration candidate changed before the destination commit.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { candidateState: candidateVerification.reason },
|
|
supportedFixes: ['retry the migration from a stable workflow source and destination'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
|
|
const destinationVerification = verifyAtomicOutput(destinationCapture.snapshot);
|
|
if (destinationVerification.status !== 'match') {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Workflow migration destination changed while the candidate was being verified.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { targetState: destinationVerification.reason },
|
|
supportedFixes: ['retry only after other processes stop creating, replacing, or redirecting the destination'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const backupPath = path.join(stagingDirectory, '.previous-destination');
|
|
let backupPresent = false;
|
|
if (destinationCapture.snapshot.target.kind === 'file') {
|
|
const expected = destinationCapture.snapshot.target;
|
|
const previousCapture = captureRegularFileBinding(destinationCapture.commitPath, {
|
|
subject: 'previous-migration-destination',
|
|
expectedIdentity: { device: expected.device, inode: expected.inode },
|
|
expectedMode: expected.mode,
|
|
expectedLinks: 1,
|
|
});
|
|
if (previousCapture.status !== 'captured') {
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Workflow migration destination changed before publication.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { targetState: previousCapture.reason },
|
|
supportedFixes: ['retry only after other processes stop changing the destination'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
previousDestinationBinding = previousCapture.binding;
|
|
const backedUp = backupPublicRegularFileBinding(
|
|
previousDestinationBinding,
|
|
destinationCapture.commitPath,
|
|
backupPath,
|
|
{ subject: 'previous-migration-destination' },
|
|
);
|
|
backupPresent = backedUp.backupCreated === true;
|
|
if (backedUp.status !== 'backed-up') {
|
|
preserveRecoveryDirectory = backupPresent || backedUp.status === 'recovery-required';
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Workflow migration destination changed while its previous value was being preserved.',
|
|
subject: { destination: destinationPath },
|
|
evidence: {
|
|
targetState: backedUp.reason,
|
|
...(backedUp.recoveryFile ? { recoveryFile: backedUp.recoveryFile } : {}),
|
|
},
|
|
supportedFixes: ['inspect the retained recovery entry before retrying'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const candidateBeforePublish = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: candidatePath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (candidateBeforePublish.status !== 'match') {
|
|
preserveRecoveryDirectory = backupPresent;
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/candidate-changed',
|
|
message: 'Workflow migration candidate changed before publication.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { candidateState: candidateBeforePublish.reason },
|
|
supportedFixes: ['inspect any retained previous destination, then retry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
fs.linkSync(candidatePath, destinationCapture.commitPath);
|
|
} catch (error) {
|
|
preserveRecoveryDirectory = backupPresent;
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Workflow migration destination was claimed during publication.',
|
|
subject: { destination: destinationPath },
|
|
evidence: {
|
|
...(error?.code ? { systemCode: error.code } : {}),
|
|
...(backupPresent ? { recoveryFile: backupPath } : {}),
|
|
},
|
|
supportedFixes: backupPresent
|
|
? ['preserve the claimant and inspect the retained previous destination before retrying']
|
|
: ['preserve the claimant and retry with a different destination'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const published = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: destinationCapture.commitPath,
|
|
expectedLinks: 2,
|
|
});
|
|
if (published.status !== 'match') {
|
|
preserveRecoveryDirectory = backupPresent;
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Workflow migration destination changed during publication.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { targetState: published.reason },
|
|
supportedFixes: ['preserve the current claimant and inspect any retained recovery entry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const removedCandidate = quarantineRemoveRegularFileBinding(candidateBinding, candidatePath, {
|
|
subject: 'migration-candidate',
|
|
expectedLinks: 2,
|
|
});
|
|
if (removedCandidate.status !== 'removed') {
|
|
preserveRecoveryDirectory = backupPresent || removedCandidate.status === 'recovery-required';
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/candidate-changed',
|
|
message: 'Workflow migration candidate changed while publication was finalized.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { candidateState: removedCandidate.reason },
|
|
supportedFixes: ['preserve the candidate claimant and inspect the published destination'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
const finalized = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: destinationCapture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalized.status !== 'match') {
|
|
preserveRecoveryDirectory = backupPresent;
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Published workflow migration changed before final verification.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { targetState: finalized.reason },
|
|
supportedFixes: ['preserve the current claimant and inspect any retained recovery entry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
if (backupPresent) {
|
|
const removedBackup = quarantineRemoveRegularFileBinding(
|
|
previousDestinationBinding,
|
|
backupPath,
|
|
{ subject: 'previous-migration-destination', expectedLinks: 1 },
|
|
);
|
|
if (removedBackup.status === 'removed') backupPresent = false;
|
|
}
|
|
const finalPublishedState = verifyRegularFileBinding(candidateBinding, {
|
|
filePath: destinationCapture.commitPath,
|
|
expectedLinks: 1,
|
|
});
|
|
if (finalPublishedState.status !== 'match') {
|
|
preserveRecoveryDirectory = backupPresent;
|
|
reportMigrationFailure({
|
|
...migration,
|
|
migrationDiagnostics: [...migration.migrationDiagnostics, diagnostic({
|
|
code: 'migration/destination-changed',
|
|
message: 'Published workflow migration did not remain stable through finalization.',
|
|
subject: { destination: destinationPath },
|
|
evidence: { targetState: finalPublishedState.reason },
|
|
supportedFixes: ['preserve the current destination and inspect any retained recovery entry'],
|
|
})],
|
|
});
|
|
return;
|
|
}
|
|
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 {
|
|
if (previousDestinationBinding) releaseRegularFileBinding(previousDestinationBinding);
|
|
try {
|
|
if (preserveRecoveryDirectory) {
|
|
releaseOwnedStagingBindings(stagingOwnership, fileBindingRuntime);
|
|
} else {
|
|
cleanupOwnedStagingDirectory(
|
|
stagingDirectory,
|
|
stagingIdentity,
|
|
stagingOwnership,
|
|
fileBindingRuntime,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Warning: could not remove workflow migration staging directory "${stagingDirectory}": ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async 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) rejectCliArgument(`Unknown validate option "${unknown[0]}".`, {
|
|
code: 'cli/unknown-option',
|
|
subject: { option: unknown[0] },
|
|
supportedFixes: ['remove the unknown option and retry'],
|
|
});
|
|
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) rejectCliArgument(usage(), {
|
|
code: 'cli/usage',
|
|
supportedFixes: ['use: archify validate <type> <input.json> [options]'],
|
|
});
|
|
const renderer = rendererPath(type);
|
|
|
|
if (layoutJson && !['architecture', 'workflow'].includes(type)) {
|
|
rejectCliArgument('--layout-json is currently supported for architecture and workflow diagrams only.', {
|
|
code: 'cli/unsupported-option',
|
|
subject: { option: '--layout-json', type },
|
|
supportedFixes: ['remove --layout-json or use an architecture or workflow diagram'],
|
|
});
|
|
}
|
|
|
|
const inputPath = path.resolve(input);
|
|
let specification;
|
|
try {
|
|
specification = fs.readFileSync(inputPath);
|
|
const document = JSON.parse(specification.toString('utf8'));
|
|
const [{ validateAuthoredOutputPath }, { validateSchema }] = await Promise.all([
|
|
import('../renderers/shared/output-path.mjs'),
|
|
import('../renderers/shared/validator.mjs'),
|
|
]);
|
|
if (document?.meta?.output === undefined) validateSchema(type, document);
|
|
else validateAuthoredOutputPath(document.meta.output);
|
|
} catch (error) {
|
|
const diagnostics = error.archifyDiagnostics || [inputDiagnostic(error, inputPath)];
|
|
reportValidateFailure({
|
|
json,
|
|
stage: diagnostics.some((entry) => entry.code.startsWith('input/')) ? 'input' : 'render',
|
|
type,
|
|
input: inputPath,
|
|
error: error.message,
|
|
diagnostics,
|
|
status: 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (layoutJson) {
|
|
// Layout mode emits JSON without writing HTML; keep its unused target typed.
|
|
const layoutOutput = path.join(os.tmpdir(), `archify-layout-${process.pid}-${type}.html`);
|
|
const result = runNode([renderer, input, layoutOutput, '--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 snapshot = path.join(tmp, 'specification.snapshot.json');
|
|
fs.writeFileSync(snapshot, specification, { flag: 'wx' });
|
|
const render = runNode([renderer, snapshot, 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 {
|
|
if (render.stderr) process.stderr.write(render.stderr);
|
|
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) {
|
|
const candidate = {
|
|
path: path.resolve(input),
|
|
...artifactIdentity(specification),
|
|
};
|
|
const resolvedQuality = quality || result.composition.profile || 'standard';
|
|
console.log(JSON.stringify({
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
command: 'validate',
|
|
type,
|
|
input: candidate.path,
|
|
candidate,
|
|
candidateFrozen: true,
|
|
nextAction: {
|
|
command: 'finalize',
|
|
arguments: [
|
|
type,
|
|
candidate.path,
|
|
'<output.html>',
|
|
'--quality',
|
|
resolvedQuality,
|
|
...(repoRoot ? ['--repo-root', repoRoot] : []),
|
|
'--candidate-sha256',
|
|
candidate.sha256,
|
|
'--json',
|
|
],
|
|
},
|
|
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);
|
|
|
|
try {
|
|
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 'finalize':
|
|
await commandFinalize(args);
|
|
break;
|
|
case 'preview':
|
|
await commandPreview(args);
|
|
break;
|
|
case 'validate':
|
|
await 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':
|
|
await commandCheck(args);
|
|
break;
|
|
case 'visual-check':
|
|
await commandVisualCheck(args);
|
|
break;
|
|
case 'browser-check':
|
|
await commandBrowserCheck(args);
|
|
break;
|
|
case 'guide':
|
|
await commandGuide(args);
|
|
break;
|
|
case 'brands':
|
|
await commandBrands(args);
|
|
break;
|
|
case 'examples':
|
|
commandExamples(args);
|
|
break;
|
|
case 'doctor':
|
|
await commandDoctor(args);
|
|
break;
|
|
case 'demo':
|
|
commandDemo(args);
|
|
break;
|
|
default:
|
|
fail(`Unknown command "${command}".\n\n${usage()}`);
|
|
}
|
|
} catch (error) {
|
|
if (!error.archifyArgument) throw error;
|
|
if (['validate', 'deliver', 'finalize'].includes(command) && args.includes('--json')) {
|
|
reportArtifactArgumentFailure(command, error);
|
|
} else {
|
|
fail(error.message);
|
|
}
|
|
}
|