@adia-ai/mcp 0.8.37 → 0.8.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ // adia-probe — the surface-qa browser gate, shipped (factory-audit Wave 2, gh#259).
3
+ //
4
+ // Runs the four-gate probe the surface-qa skill defines (references/
5
+ // verification.md) and emits a VerifyProof: zero console/page errors,
6
+ // non-zero bounding boxes on the named selectors, a deviceScaleFactor:2
7
+ // screenshot — and an explicit HUMAN/MODEL-READS-THE-IMAGE slot, because a
8
+ // probe that screenshots but doesn't read the image has verified nothing.
9
+ // It also records navigation timing as an ADVISORY perf row (REQ-06,
10
+ // gh#1200/gh#1211, realizing ADR-0040's ui-verifier "perf" clause) — a
11
+ // budget observation only; it never flips the verdict, since promoting it
12
+ // to a blocking gate is a later ruling that needs real data first.
13
+ //
14
+ // gh#1259 adds the instrumented AA-contrast slice — the one VerifyProof row
15
+ // QA seats carried as UNMEASURED or hand-computed (the #1246 wave's probes
16
+ // 4/5). It samples rendered foreground/background pairs for visible text
17
+ // nodes (computed sRGB, alpha-composited up to the first opaque ancestor
18
+ // background — the math probe 5 hand-executed), scores each pair against
19
+ // WCAG AA (4.5 normal / 3.0 large text: ≥18pt, or ≥14pt bold), and reports
20
+ // pass/fail per pair with the sampled values. Unlike perf, contrast is a
21
+ // GATE when measured: a failing pair fails the verdict (it is the a11y
22
+ // rubric row's contrast slice, and that row is blocking). When no samples
23
+ // can be captured the row degrades to UNMEASURED, never a silent fail.
24
+ // Pairs whose background chain crosses a background-image before an opaque
25
+ // color are skipped as indeterminate (computed styles can't see pixels of
26
+ // an image) — skipped counts are reported, and those pairs stay the
27
+ // image-reader's judgment.
28
+ //
29
+ // Requires Playwright IN THE CONSUMER APP (a dev dependency it very likely
30
+ // already has; this plugin ships no browser): npm i -D playwright
31
+ //
32
+ // Usage:
33
+ // node adia-probe.mjs <url> --selector <css> [--selector <css> …]
34
+ // [--screenshot probe.png] [--perf-budget-ms 3000] [--json]
35
+ // node adia-probe.mjs selftest # gate logic + report shape (no browser)
36
+ //
37
+ // Exit: 0 = mechanized gates pass (verdict "pass-pending-read" — gate 3's
38
+ // image read remains the model's), 1 = a gate failed, 2 = setup error.
39
+ // The perf row is ADVISORY: exceeding perfBudgetMs never changes this exit
40
+ // code or the verdict string — it is reported, not enforced.
41
+
42
+ import process from 'node:process';
43
+
44
+ // REQ-06 default budget: generous on purpose (advisory, not a floor) — a
45
+ // number worth reporting against without a real dataset to justify a
46
+ // tighter one yet. Overridable per invocation via --perf-budget-ms.
47
+ const DEFAULT_PERF_BUDGET_MS = 3000;
48
+
49
+ // ---- WCAG AA contrast math (gh#1259) — pure, browser-free, selftestable ----
50
+
51
+ // sRGB channel linearization (WCAG 2.x relative-luminance definition).
52
+ export function srgbChannel(c8) {
53
+ const c = c8 / 255;
54
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
55
+ }
56
+
57
+ export function relativeLuminance([r, g, b]) {
58
+ return 0.2126 * srgbChannel(r) + 0.7152 * srgbChannel(g) + 0.0722 * srgbChannel(b);
59
+ }
60
+
61
+ export function contrastRatio(fg, bg) {
62
+ const a = relativeLuminance(fg);
63
+ const b = relativeLuminance(bg);
64
+ const [hi, lo] = a >= b ? [a, b] : [b, a];
65
+ return (hi + 0.05) / (lo + 0.05);
66
+ }
67
+
68
+ // WCAG "large text": ≥18pt (24px), or ≥14pt (18.66px) bold. AA then asks
69
+ // 3.0 instead of 4.5.
70
+ export function isLargeText(fontSizePx, fontWeight) {
71
+ return fontSizePx >= 24 || (fontSizePx >= 18.66 && fontWeight >= 700);
72
+ }
73
+
74
+ // samples: [{ text, element, fg:[r,g,b], bg:[r,g,b], fontSizePx, fontWeight }]
75
+ export function scoreContrastSamples(samples, { skipped = 0 } = {}) {
76
+ const pairs = samples.map((s) => {
77
+ const large = isLargeText(s.fontSizePx, s.fontWeight);
78
+ const required = large ? 3.0 : 4.5;
79
+ const ratio = Math.round(contrastRatio(s.fg, s.bg) * 100) / 100;
80
+ return { ...s, large, required, ratio, pass: ratio >= required };
81
+ });
82
+ const failing = pairs.filter((p) => !p.pass);
83
+ return {
84
+ pass: failing.length === 0,
85
+ standard: 'WCAG AA — 4.5:1 normal / 3.0:1 large text (≥18pt, or ≥14pt bold)',
86
+ checked: pairs.length,
87
+ skippedImageBacked: skipped,
88
+ failing,
89
+ pairs,
90
+ };
91
+ }
92
+
93
+ export function buildProof({ url, errors, boxes, screenshotPath, navTiming, perfBudgetMs = DEFAULT_PERF_BUDGET_MS, contrastSamples = null, contrastSkipped = 0 }) {
94
+ const consolePass = errors.length === 0;
95
+ const boxFailures = Object.entries(boxes)
96
+ .filter(([, b]) => !b || b.width <= 0 || b.height <= 0)
97
+ .map(([sel]) => sel);
98
+ const boxPass = boxFailures.length === 0;
99
+ // ADVISORY — REQ-06: reports navigation timing against a budget but never
100
+ // participates in `verdict` below. `navTiming` missing/null means the
101
+ // caller couldn't measure it (e.g. the selftest's synthetic fixtures);
102
+ // that is UNMEASURED, not a failure — a VerifyProof still carries the row.
103
+ const perf = navTiming
104
+ ? {
105
+ advisory: true,
106
+ budgetMs: perfBudgetMs,
107
+ observed: navTiming,
108
+ withinBudget: navTiming.loadMs <= perfBudgetMs,
109
+ }
110
+ : { advisory: true, budgetMs: perfBudgetMs, observed: null, withinBudget: null, reason: 'UNMEASURED — no navigation timing captured' };
111
+ // gh#1259 — GATE when measured, UNMEASURED (not failing) when the caller
112
+ // captured no samples (selftest fixtures, an all-image page, a sampler
113
+ // crash). This mirrors the perf row's degradation shape but NOT its
114
+ // advisory nature: a measured failing pair fails the verdict.
115
+ const contrast = contrastSamples
116
+ ? scoreContrastSamples(contrastSamples, { skipped: contrastSkipped })
117
+ : { pass: null, reason: 'UNMEASURED — no text foreground/background pairs sampled' };
118
+ return {
119
+ record: 'VerifyProof',
120
+ url,
121
+ gates: {
122
+ consoleErrors: { pass: consolePass, errors },
123
+ boundingBoxes: { pass: boxPass, failing: boxFailures, boxes },
124
+ screenshot: { path: screenshotPath, deviceScaleFactor: 2 },
125
+ imageRead: 'REQUIRED — a screenshot nobody reads verifies nothing; the reader states what the pixels show',
126
+ perf,
127
+ contrast,
128
+ },
129
+ // perf is deliberately excluded here — advisory means it can never turn
130
+ // a green gate red (REQ-06's "Advisory means" clause). contrast joins the
131
+ // verdict only when measured: `pass === false` fails; `null` (UNMEASURED)
132
+ // does not (gh#1259).
133
+ verdict: consolePass && boxPass && contrast.pass !== false ? 'pass-pending-read' : 'fail',
134
+ };
135
+ }
136
+
137
+ function selftest() {
138
+ const good = buildProof({
139
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
140
+ navTiming: { loadMs: 120, domContentLoadedMs: 80 },
141
+ });
142
+ const badBox = buildProof({
143
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 0, height: 0 } }, screenshotPath: 'p.png',
144
+ });
145
+ const badErr = buildProof({
146
+ url: 'http://x', errors: ['TypeError: boom'], boxes: { 'my-surface': { width: 1, height: 1 } }, screenshotPath: 'p.png',
147
+ });
148
+ // REQ-06: a slow page (over budget) still PASSES the mechanized gates —
149
+ // advisory means perf never turns a green gate red.
150
+ const overBudget = buildProof({
151
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
152
+ navTiming: { loadMs: 9000, domContentLoadedMs: 8000 }, perfBudgetMs: 3000,
153
+ });
154
+ const noTiming = buildProof({
155
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
156
+ });
157
+ // gh#1259 — contrast fixtures. Positive control: black-on-white text
158
+ // (ratio 21:1) passes and leaves the verdict green.
159
+ const goodContrast = buildProof({
160
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
161
+ contrastSamples: [
162
+ { text: 'readable body copy', element: 'p', fg: [0, 0, 0], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
163
+ ],
164
+ });
165
+ // Negative control: #aaa on white (≈2.32:1) — a deliberately-failing
166
+ // low-contrast pair MUST fail the contrast gate AND flip the verdict.
167
+ const badContrast = buildProof({
168
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
169
+ contrastSamples: [
170
+ { text: 'faint caption', element: 'span', fg: [170, 170, 170], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
171
+ ],
172
+ });
173
+ // Large-text threshold: #949494 on white (≈3.03:1) fails at 16px normal
174
+ // (needs 4.5) but passes at 24px (large text, needs 3.0).
175
+ const borderline = scoreContrastSamples([
176
+ { text: 'gray at body size', element: 'p', fg: [148, 148, 148], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
177
+ { text: 'gray as a heading', element: 'h1', fg: [148, 148, 148], bg: [255, 255, 255], fontSizePx: 24, fontWeight: 400 },
178
+ ]);
179
+ const fails = [];
180
+ const bw = contrastRatio([0, 0, 0], [255, 255, 255]);
181
+ if (bw < 20.9 || bw > 21.1) fails.push(`black/white ratio ${bw} not ≈21`);
182
+ if (!isLargeText(24, 400) || !isLargeText(19, 700) || isLargeText(19, 400) || isLargeText(16, 400)) {
183
+ fails.push('large-text threshold (≥18pt, or ≥14pt bold) mis-scored');
184
+ }
185
+ if (goodContrast.verdict !== 'pass-pending-read' || goodContrast.gates.contrast.pass !== true) {
186
+ fails.push('passing contrast pair did not pass');
187
+ }
188
+ if (badContrast.verdict !== 'fail' || badContrast.gates.contrast.pass !== false) {
189
+ fails.push('NEGATIVE CONTROL: low-contrast pair did not fail the verdict');
190
+ }
191
+ if (badContrast.gates.contrast.failing.length !== 1 || badContrast.gates.contrast.failing[0].required !== 4.5
192
+ || badContrast.gates.contrast.failing[0].ratio >= 4.5) {
193
+ fails.push('failing pair not reported with sampled ratio + required threshold');
194
+ }
195
+ if (borderline.pairs[0].pass !== false || borderline.pairs[1].pass !== true || borderline.pairs[1].required !== 3.0) {
196
+ fails.push('large-text 3.0 exception not applied per pair');
197
+ }
198
+ if (good.gates.contrast.pass !== null || !good.gates.contrast.reason.includes('UNMEASURED')) {
199
+ fails.push('missing contrast samples did not degrade to UNMEASURED contrast row');
200
+ }
201
+ if (good.verdict !== 'pass-pending-read') fails.push('good fixture not pass-pending-read');
202
+ if (badBox.verdict !== 'fail' || badBox.gates.boundingBoxes.failing[0] !== 'my-surface') fails.push('0x0 box not failed');
203
+ if (badErr.verdict !== 'fail' || badErr.gates.consoleErrors.pass) fails.push('console error not failed');
204
+ if (!good.gates.imageRead.includes('REQUIRED')) fails.push('imageRead slot missing');
205
+ if (!good.gates.perf || good.gates.perf.advisory !== true) fails.push('perf row missing/not advisory');
206
+ if (good.gates.perf.withinBudget !== true) fails.push('perf row withinBudget mis-scored for a fast fixture');
207
+ if (overBudget.verdict !== 'pass-pending-read') fails.push('REQ-06 REGRESSION: over-budget perf turned a green gate red');
208
+ if (overBudget.gates.perf.withinBudget !== false) fails.push('over-budget perf row not flagged withinBudget:false');
209
+ if (noTiming.gates.perf.observed !== null || !noTiming.gates.perf.reason.includes('UNMEASURED')) {
210
+ fails.push('missing navTiming did not degrade to UNMEASURED perf row');
211
+ }
212
+ if (fails.length) {
213
+ console.error('selftest FAIL: ' + fails.join(' | '));
214
+ process.exit(1);
215
+ }
216
+ console.log('selftest OK — 8 fixtures, verdicts + imageRead + advisory perf row + AA contrast gate (positive/negative/large-text) correct');
217
+ process.exit(0);
218
+ }
219
+
220
+ async function main() {
221
+ const argv = process.argv.slice(2);
222
+ if (argv[0] === 'selftest') selftest();
223
+ // REQ-06 (gh#1122): help is not a usage error — checked BEFORE url/selector
224
+ // resolution so -h/--help can never be mistaken for a positional url (a
225
+ // single-dash flag doesn't start with '--', so it would otherwise fall
226
+ // through to the `.find` below) and exits 0, not the 2 reserved for a
227
+ // genuine missing-argument mis-invocation.
228
+ if (argv.includes('-h') || argv.includes('--help')) {
229
+ console.log('usage: adia-probe.mjs <url> --selector <css> [--selector <css> …] [--screenshot out.png] [--perf-budget-ms 3000] [--json]');
230
+ process.exit(0);
231
+ }
232
+ const url = argv.find((a) => !a.startsWith('--'));
233
+ const selectors = argv.flatMap((a, i) => (a === '--selector' ? [argv[i + 1]] : []));
234
+ const screenshotPath = argv.includes('--screenshot')
235
+ ? argv[argv.indexOf('--screenshot') + 1] : 'probe.png';
236
+ // REQ-06: advisory only — an unparsable/absent value falls back to the
237
+ // default rather than erroring, since this flag never gates anything.
238
+ const perfBudgetMs = argv.includes('--perf-budget-ms')
239
+ ? Number(argv[argv.indexOf('--perf-budget-ms') + 1]) || DEFAULT_PERF_BUDGET_MS
240
+ : DEFAULT_PERF_BUDGET_MS;
241
+ if (!url || selectors.length === 0) {
242
+ console.error('usage: adia-probe.mjs <url> --selector <css> [--selector <css> …] [--screenshot out.png] [--perf-budget-ms 3000] [--json]');
243
+ process.exit(2);
244
+ }
245
+
246
+ // Resolve playwright from the CONSUMER APP (cwd), not from this file's own
247
+ // location. A bare import('playwright') searches this script's ancestor
248
+ // directories — correct when the probe runs from inside the app's tree,
249
+ // wrong when a packed copy lives elsewhere (e.g. @adia-ai/mcp's vendored
250
+ // copy under an npx cache, PR #1289 review): the app's devDependency would
251
+ // never be found. Try the bare import first (fast path, zero behavior
252
+ // change in the plugin's own layout), then fall back to a resolver rooted
253
+ // at process.cwd().
254
+ let chromium;
255
+ try {
256
+ ({ chromium } = await import('playwright'));
257
+ } catch {
258
+ try {
259
+ const { createRequire } = await import('node:module');
260
+ const { pathToFileURL } = await import('node:url');
261
+ const path = await import('node:path');
262
+ const req = createRequire(path.join(process.cwd(), 'package.json'));
263
+ ({ chromium } = await import(pathToFileURL(req.resolve('playwright')).href));
264
+ } catch {
265
+ console.error('adia-probe: playwright not installed in this app — npm i -D playwright');
266
+ process.exit(2);
267
+ }
268
+ }
269
+
270
+ const browser = await chromium.launch();
271
+ // deviceScaleFactor must be set at CONTEXT creation — scale:'device' on a
272
+ // default context captures at 1x (the defect this script replaced).
273
+ const context = await browser.newContext({ deviceScaleFactor: 2 });
274
+ const page = await context.newPage();
275
+ const errors = [];
276
+ page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
277
+ page.on('pageerror', (e) => errors.push(String(e)));
278
+ await page.goto(url, { waitUntil: 'networkidle' });
279
+
280
+ const boxes = {};
281
+ for (const sel of selectors) {
282
+ boxes[sel] = await page.locator(sel).first().boundingBox().catch(() => null);
283
+ }
284
+ // REQ-06 — Navigation Timing Level 2 (the non-deprecated API); `null` on
285
+ // a page that hasn't finished loading or a substrate that doesn't expose
286
+ // it, which `buildProof` degrades to an UNMEASURED (not failing) perf row.
287
+ const navTiming = await page.evaluate(() => {
288
+ const [entry] = performance.getEntriesByType('navigation');
289
+ if (!entry) return null;
290
+ return {
291
+ loadMs: Math.round(entry.loadEventEnd - entry.startTime),
292
+ domContentLoadedMs: Math.round(entry.domContentLoadedEventEnd - entry.startTime),
293
+ responseMs: Math.round(entry.responseEnd - entry.startTime),
294
+ };
295
+ }).catch(() => null);
296
+ // gh#1259 — sample rendered fg/bg pairs for visible text nodes. The
297
+ // evaluate callback is self-contained (it serializes into the page; module
298
+ // scope is unreachable there): it collects RAW sampled values only, and the
299
+ // WCAG scoring runs in Node via the exported, selftested functions above.
300
+ // Composite backgrounds: walk up to the first opaque ancestor
301
+ // background-color, alpha-compositing translucent layers (over white, the
302
+ // document default). A background-image anywhere in that chain makes the
303
+ // pair indeterminate from computed styles — skipped and counted, never
304
+ // guessed. Deduped by (fg, bg, size, weight) and capped, so the report
305
+ // stays bounded on long pages.
306
+ const sampled = await page.evaluate(() => {
307
+ const parse = (s) => {
308
+ const m = /rgba?\(([^)]+)\)/.exec(s || '');
309
+ if (!m) return null;
310
+ const [r, g, b, a = 1] = m[1].split(',').map(Number);
311
+ return { rgb: [r, g, b], a };
312
+ };
313
+ const over = (fg, a, bg) => fg.map((c, i) => Math.round(c * a + bg[i] * (1 - a)));
314
+ const bgFor = (el) => {
315
+ const layers = [];
316
+ for (let n = el; n && n.nodeType === 1; n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null) {
317
+ const cs = getComputedStyle(n);
318
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') return null; // indeterminate — pixels, not computed styles
319
+ const c = parse(cs.backgroundColor);
320
+ if (c && c.a > 0) {
321
+ layers.push(c);
322
+ if (c.a >= 1) break;
323
+ }
324
+ }
325
+ let bg = [255, 255, 255];
326
+ for (let i = layers.length - 1; i >= 0; i--) bg = over(layers[i].rgb, layers[i].a, bg);
327
+ return bg;
328
+ };
329
+ const samples = [];
330
+ let skipped = 0;
331
+ const seen = new Set();
332
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
333
+ let node;
334
+ while ((node = walker.nextNode()) && samples.length < 120) {
335
+ const text = node.textContent.trim();
336
+ if (!text) continue;
337
+ const el = node.parentElement;
338
+ if (!el || ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(el.tagName)) continue;
339
+ const cs = getComputedStyle(el);
340
+ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue;
341
+ const rect = el.getBoundingClientRect();
342
+ if (rect.width <= 0 || rect.height <= 0) continue;
343
+ const fgc = parse(cs.color);
344
+ if (!fgc) continue;
345
+ const bg = bgFor(el);
346
+ if (!bg) { skipped += 1; continue; }
347
+ const fg = fgc.a >= 1 ? fgc.rgb.map(Math.round) : over(fgc.rgb, fgc.a, bg);
348
+ const fontSizePx = parseFloat(cs.fontSize) || 16;
349
+ const fontWeight = cs.fontWeight === 'bold' ? 700 : Number(cs.fontWeight) || 400;
350
+ const key = `${fg.join()}|${bg.join()}|${fontSizePx}|${fontWeight}`;
351
+ if (seen.has(key)) continue;
352
+ seen.add(key);
353
+ samples.push({
354
+ text: text.slice(0, 40),
355
+ element: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
356
+ fg, bg, fontSizePx, fontWeight,
357
+ });
358
+ }
359
+ return { samples, skipped };
360
+ }).catch(() => null);
361
+ await page.screenshot({ path: screenshotPath, scale: 'device', fullPage: true });
362
+ await browser.close();
363
+
364
+ const proof = buildProof({
365
+ url, errors, boxes, screenshotPath, navTiming, perfBudgetMs,
366
+ contrastSamples: sampled && sampled.samples.length ? sampled.samples : null,
367
+ contrastSkipped: sampled ? sampled.skipped : 0,
368
+ });
369
+ if (process.argv.includes('--json')) {
370
+ console.log(JSON.stringify(proof, null, 1));
371
+ } else {
372
+ const perfNote = proof.gates.perf.observed
373
+ ? `perf(advisory):${proof.gates.perf.observed.loadMs}ms/${proof.gates.perf.budgetMs}ms budget`
374
+ : 'perf(advisory):UNMEASURED';
375
+ const c = proof.gates.contrast;
376
+ const contrastNote = c.pass === null
377
+ ? 'contrast:UNMEASURED'
378
+ : `contrast:${c.pass ? 'pass' : 'FAIL'} ${c.checked - c.failing.length}/${c.checked} pairs${c.skippedImageBacked ? ` (+${c.skippedImageBacked} image-backed skipped)` : ''}`;
379
+ console.log(`[adia-probe] ${proof.verdict} — errors:${errors.length} boxes:${selectors.length - proof.gates.boundingBoxes.failing.length}/${selectors.length} shot:${screenshotPath} ${perfNote} ${contrastNote}`);
380
+ if (proof.verdict === 'fail') {
381
+ for (const e of errors) console.log(` console: ${e}`);
382
+ for (const s of proof.gates.boundingBoxes.failing) console.log(` 0×0/missing: ${s}`);
383
+ if (c.pass === false) {
384
+ for (const p of c.failing) {
385
+ console.log(` contrast: ${p.ratio}:1 < ${p.required}:1 — ${p.element} "${p.text}" fg rgb(${p.fg.join(',')}) on bg rgb(${p.bg.join(',')}) @ ${p.fontSizePx}px w${p.fontWeight}${p.large ? ' (large)' : ''}`);
386
+ }
387
+ }
388
+ }
389
+ if (proof.gates.perf.observed && !proof.gates.perf.withinBudget) {
390
+ console.log(` NOTE: perf row over advisory budget (${proof.gates.perf.observed.loadMs}ms > ${proof.gates.perf.budgetMs}ms) — advisory only, does not fail this gate.`);
391
+ }
392
+ console.log(` NEXT: read ${screenshotPath} — the proof is incomplete until the pixels are described.`);
393
+ }
394
+ process.exit(proof.verdict === 'fail' ? 1 : 0);
395
+ }
396
+
397
+ // Entry guard (PR #1289 review): this module exports pure contrast helpers
398
+ // (srgbChannel … buildProof) for tests and other tooling — an unguarded
399
+ // top-level main() would launch the CLI (and its process.exit(2) usage path)
400
+ // on ANY import. Canonical-path compare via realpathSync on both sides, the
401
+ // repo's standing rule, because skill invocation paths are symlinks
402
+ // (adia-contract-check.mjs carries the same guard).
403
+ const isMain = await (async () => {
404
+ if (!process.argv[1]) return false;
405
+ try {
406
+ const { realpathSync } = await import('node:fs');
407
+ const { fileURLToPath } = await import('node:url');
408
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
409
+ } catch {
410
+ return false;
411
+ }
412
+ })();
413
+ if (isMain) await main();