diagnostics.mjs
209 lines · archify/renderers/shared/diagnostics.mjs at a406594
1import fs from 'node:fs';
2import path from 'node:path';
4const DIAGNOSTIC_MODE = process.env.ARCHIFY_DIAGNOSTIC_FORMAT === 'json';
5let recorded = [];
6let recordedKeys = new Set();
7let recordedByMessage = new Map();
8const boundaryKey = Symbol.for('archify.renderer-diagnostic-boundary');
9let recordingSuppressionDepth = 0;
11function plainObject(value) {
12 if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
13 return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
14}
16function normalizedDiagnostic(diagnostic) {
17 const message = String(diagnostic?.message || 'Archify could not classify this failure.').trim();
18 return {
19 code: String(diagnostic?.code || 'internal/unclassified'),
20 severity: diagnostic?.severity === 'warning' ? 'warning' : 'error',
21 message,
22 subject: plainObject(diagnostic?.subject),
23 evidence: plainObject(diagnostic?.evidence),
24 supportedFixes: Array.isArray(diagnostic?.supportedFixes)
25 ? [...new Set(diagnostic.supportedFixes.map((fix) => String(fix).trim()).filter(Boolean))]
26 : [],
27 ...(Array.isArray(diagnostic?.suppresses) ? {
28 suppresses: [...new Set(diagnostic.suppresses.map((code) => String(code).trim()).filter(Boolean))],
29 } : {}),
30 };
31}
33export function recordDiagnostic(diagnostic) {
34 if (!DIAGNOSTIC_MODE || recordingSuppressionDepth > 0) return;
35 const normalized = normalizedDiagnostic(diagnostic);
36 // Identical messages can describe different subjects; only exact repeats dedupe.
37 const key = `${normalized.code} ${normalized.message} ${JSON.stringify(normalized.subject)}`;
38 if (recordedKeys.has(key)) return;
39 recordedKeys.add(key);
40 recorded.push(normalized);
41 if (!recordedByMessage.has(normalized.message)) recordedByMessage.set(normalized.message, normalized);
42}
44export function withDiagnosticRecordingSuppressed(callback) {
45 recordingSuppressionDepth += 1;
46 try {
47 return callback();
48 } finally {
49 recordingSuppressionDepth -= 1;
50 }
51}
53// Speculative synchronous work needs typed message lookup without publishing
54// its discarded diagnostics into the caller's eventual renderer failure.
55export function withIsolatedDiagnosticRecording(callback) {
56 const previousRecorded = recorded;
57 const previousKeys = recordedKeys;
58 const previousByMessage = recordedByMessage;
59 recorded = [];
60 recordedKeys = new Set();
61 recordedByMessage = new Map();
62 try {
63 return callback();
64 } finally {
65 recorded = previousRecorded;
66 recordedKeys = previousKeys;
67 recordedByMessage = previousByMessage;
68 }
69}
71export function throwDiagnosticError(message, diagnostics) {
72 for (const diagnostic of diagnostics || []) recordDiagnostic(diagnostic);
73 const error = new Error(message);
74 error.archifyDiagnostics = (diagnostics || []).map(normalizedDiagnostic);
75 throw error;
76}
78export function throwDiagnosticProblems(prefix, problems, { code = 'layout/constraint', subject = {}, diagnostics: details = [] } = {}) {
79 const messages = (problems || []).map((problem) => String(problem));
80 // Consume matching details in emission order: repeated messages map to
81 // different subjects, so a single lookup would attribute every occurrence to
82 // the last detail entry.
83 const queuesByMessage = new Map();
84 for (const entry of details) {
85 const queue = queuesByMessage.get(entry.message);
86 if (queue) queue.push(entry);
87 else queuesByMessage.set(entry.message, [entry]);
88 }
89 const diagnostics = messages.map((message) => {
90 const queue = queuesByMessage.get(message);
91 // Without a queued detail, reuse the structured diagnostic already
92 // recorded under this message rather than attaching a bare fallback.
93 const entry = queue?.length ? queue.shift() : recordedByMessage.get(message);
94 return normalizedDiagnostic(entry || {
95 code,
96 severity: 'error',
97 message,
98 subject,
99 evidence: {},
100 supportedFixes: [],
101 });
102 });
103 throwDiagnosticError(`${prefix}:\n- ${messages.join('\n- ')}`, diagnostics);
104}
106function fallbackDiagnostic(error) {
107 const input = process.argv[2] ? path.resolve(process.argv[2]) : undefined;
108 return normalizedDiagnostic({
109 code: 'internal/unclassified',
110 severity: 'error',
111 message: error?.message || 'Renderer failed without a diagnostic.',
112 subject: { input },
113 evidence: { errorName: error?.name || 'Error' },
114 supportedFixes: [],
115 });
116}
117export function rendererFailure(error) {
118 const attached = Array.isArray(error?.archifyDiagnostics)
119 ? error.archifyDiagnostics.map(normalizedDiagnostic)
120 : [];
121 // Earlier diagnostics do not classify a later, unrelated implementation error.
122 const diagnostics = attached.length
123 ? (recorded.length ? recorded : attached)
124 : [fallbackDiagnostic(error)];
125 return {
126 schemaVersion: 1,
127 ok: false,
128 source: 'renderer',
129 error: error?.message || 'Renderer failed without a diagnostic.',
130 diagnostics,
131 };
132}
134// Match the public CLI's text format without making its standalone doctor
135// bootstrap depend on this renderer runtime being present.
136function formatDiagnostics(error, diagnostics = []) {
137 if (!diagnostics.length) return error;
138 return [
139 error,
140 ...diagnostics.map((entry) => {
141 const fix = entry.supportedFixes?.length ? ` Fix: ${entry.supportedFixes.join('; ')}.` : '';
142 return `[${entry.code}] ${entry.message}${fix}`;
143 }),
144 ].join('\n');
145}
147const readerSignal = new Int32Array(new SharedArrayBuffer(4));
149function waitForReader() {
150 // Sleep instead of spinning on EAGAIN. A retry budget looks like a safeguard
151 // and behaves like a truncation gate: a spinning loop burns thousands of
152 // attempts in a few milliseconds, so a reader that is merely slow to start
153 // exhausts it and loses the tail of the receipt. Waiting costs nothing while
154 // the reader catches up, and a reader that goes away raises EPIPE, which the
155 // caller already treats as a real write failure.
156 Atomics.wait(readerSignal, 0, 0, 1);
157}
159export function installRendererDiagnosticBoundary() {
160 if (globalThis[boundaryKey]) return;
161 globalThis[boundaryKey] = true;
162 if (!DIAGNOSTIC_MODE) {
163 process.once('uncaughtException', (error) => {
164 // Only errors classified at their operation boundary are author-facing.
165 // Preserve Node's debugging information for unexpected implementation errors.
166 if (!error?.archifyDiagnostics?.length) {
167 // The once-listener is already removed. Rethrow outside the exception
168 // handler so Node retains its normal stack and exit code (not code 7).
169 process.nextTick(() => { throw error; });
170 return;
171 }
172 const payload = `${formatDiagnostics(error.message, error.archifyDiagnostics)}\n`;
173 process.stderr.once('error', () => process.exit(1));
174 process.stderr.write(payload, () => process.exit(1));
175 });
176 return;
177 }
178 process.on('uncaughtException', (error) => {
179 const payload = `${JSON.stringify(rendererFailure(error))}\n`;
180 try {
181 // stderr may be a pipe. fs.writeSync performs a PARTIAL write once the
182 // payload exceeds the OS pipe buffer (8KB on macOS) and returns the byte
183 // count actually written. Ignoring that return value silently truncated
184 // large diagnostic payloads mid-JSON, so the parent CLI's JSON.parse
185 // failed and the fail-closed boundary reported internal/unclassified
186 // instead of the diagnostics we had already computed. Loop until drained.
187 // A full pipe also makes writeSync throw EAGAIN; wait for the reader
188 // rather than treat it as a stream failure, otherwise the tail is
189 // dropped just the same.
190 const buffer = Buffer.from(payload, 'utf8');
191 let written = 0;
192 while (written < buffer.length) {
193 try {
194 written += fs.writeSync(process.stderr.fd, buffer, written, buffer.length - written);
195 } catch (writeError) {
196 if (writeError?.code === 'EAGAIN') {
197 waitForReader();
198 continue;
199 }
200 throw writeError;
201 }
202 }
203 } catch {
204 // The renderer is already failing. Avoid replacing its real error with a
205 // secondary stream failure; the parent CLI still has the exit status.
206 }
207 process.exit(1);
208 });
209}