Archify Diagram Viewer 0.1.0
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>
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
|
||||
const recorded = [];
|
||||
const recordedMessages = new Set();
|
||||
const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
|
||||
let recordingSuppressionDepth = 0;
|
||||
|
||||
function plainObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||
}
|
||||
|
||||
function normalizedDiagnostic(diagnostic) {
|
||||
const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
|
||||
return {
|
||||
code: String(diagnostic?.code || 'internal/unclassified'),
|
||||
severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
|
||||
message,
|
||||
subject: plainObject(diagnostic?.subject),
|
||||
evidence: plainObject(diagnostic?.evidence),
|
||||
supportedFixes: Array.isArray(diagnostic?.supportedFixes)
|
||||
? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
|
||||
: [],
|
||||
...(Array.isArray(diagnostic?.suppresses) ? {
|
||||
suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDiagnostic(diagnostic) {
|
||||
if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
|
||||
const normalized = normalizedDiagnostic(diagnostic);
|
||||
if (recordedMessages.has(normalized.message)) return;
|
||||
recordedMessages.add(normalized.message);
|
||||
recorded.push(normalized);
|
||||
}
|
||||
|
||||
export function withDiagnosticRecordingSuppressed(callback) {
|
||||
recordingSuppressionDepth += 1;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
recordingSuppressionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function throwDiagnosticError(message, diagnostics) {
|
||||
for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
|
||||
const error = new Error(message);
|
||||
error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
|
||||
throw error;
|
||||
}
|
||||
|
||||
export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {}, diagnostics: details = [] } = {}) {
|
||||
const messages = (problems || []).map((problem) => String(problem));
|
||||
const byMessage = new Map(details.map((entry) => [entry.message, entry]));
|
||||
const diagnostics = messages.map((message) => normalizedDiagnostic(byMessage.get(message) || {
|
||||
code,
|
||||
severity: 'error',
|
||||
message,
|
||||
subject,
|
||||
evidence: {},
|
||||
supportedFixes: [],
|
||||
}));
|
||||
throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
|
||||
}
|
||||
|
||||
function fallbackDiagnostic(error) {
|
||||
const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
|
||||
return normalizedDiagnostic({
|
||||
code: 'internal/unclassified',
|
||||
severity: 'error',
|
||||
message: error?.message || 'Renderer failed without a diagnostic.',
|
||||
subject: { input },
|
||||
evidence: { errorName: error?.name || 'Error' },
|
||||
supportedFixes: [],
|
||||
});
|
||||
}
|
||||
export function rendererFailure(error) {
|
||||
const attached = Array.isArray(error?.archifyDiagnostics)
|
||||
? error.archifyDiagnostics.map(normalizedDiagnostic)
|
||||
: [];
|
||||
// Earlier diagnostics do not classify a later, unrelated implementation error.
|
||||
const diagnostics = attached.length
|
||||
? (recorded.length ? recorded : attached)
|
||||
: [fallbackDiagnostic(error)];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
ok: false,
|
||||
source: 'renderer',
|
||||
error: error?.message || 'Renderer failed without a diagnostic.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// Match the public CLI's text format without making its standalone doctor
|
||||
// bootstrap depend on this renderer runtime being present.
|
||||
function formatDiagnostics(error, diagnostics = []) {
|
||||
if (!diagnostics.length) return error;
|
||||
return [
|
||||
error,
|
||||
...diagnostics.map((entry) => {
|
||||
const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : '';
|
||||
return `[${entry.code}] ${entry.message}${fix}`;
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const readerSignal = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function waitForReader() {
|
||||
// Sleep instead of spinning on EAGAIN. A retry budget looks like a safeguard
|
||||
// and behaves like a truncation gate: a spinning loop burns thousands of
|
||||
// attempts in a few milliseconds, so a reader that is merely slow to start
|
||||
// exhausts it and loses the tail of the receipt. Waiting costs nothing while
|
||||
// the reader catches up, and a reader that goes away raises EPIPE, which the
|
||||
// caller already treats as a real write failure.
|
||||
Atomics.wait(readerSignal, 0, 0, 1);
|
||||
}
|
||||
|
||||
export function installRendererDiagnosticBoundary() {
|
||||
if (globalThis[boundaryKey]) return;
|
||||
globalThis[boundaryKey] = true;
|
||||
if (!DIAGNOSTIC_MODE) {
|
||||
process.once('uncaughtException', (error) => {
|
||||
// Only errors classified at their operation boundary are author-facing.
|
||||
// Preserve Node's debugging information for unexpected implementation errors.
|
||||
if (!error?.archifyDiagnostics?.length) {
|
||||
// The once-listener is already removed. Rethrow outside the exception
|
||||
// handler so Node retains its normal stack and exit code (not code 7).
|
||||
process.nextTick(() => { throw error; });
|
||||
return;
|
||||
}
|
||||
const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`;
|
||||
process.stderr.once('error', () => process.exit(1));
|
||||
process.stderr.write(payload, () => process.exit(1));
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.on('uncaughtException', (error) => {
|
||||
const payload = `${JSON.stringify(rendererFailure(error))}\n`;
|
||||
try {
|
||||
// stderr may be a pipe. fs.writeSync performs a PARTIAL write once the
|
||||
// payload exceeds the OS pipe buffer (8KB on macOS) and returns the byte
|
||||
// count actually written. Ignoring that return value silently truncated
|
||||
// large diagnostic payloads mid-JSON, so the parent CLI's JSON.parse
|
||||
// failed and the fail-closed boundary reported internal/unclassified
|
||||
// instead of the diagnostics we had already computed. Loop until drained.
|
||||
// A full pipe also makes writeSync throw EAGAIN; wait for the reader
|
||||
// rather than treat it as a stream failure, otherwise the tail is
|
||||
// dropped just the same.
|
||||
const buffer = Buffer.from(payload, 'utf8');
|
||||
let written = 0;
|
||||
while (written < buffer.length) {
|
||||
try {
|
||||
written += fs.writeSync(process.stderr.fd, buffer, written, buffer.length - written);
|
||||
} catch (writeError) {
|
||||
if (writeError?.code === 'EAGAIN') {
|
||||
waitForReader();
|
||||
continue;
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The renderer is already failing. Avoid replacing its real error with a
|
||||
// secondary stream failure; the parent CLI still has the exit status.
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user