Files
pbakaus_impeccable/browser-bundle/50-scan.js
T
Paul BakausandClaude Fable 5.1 0547ed6a63 reorg C: the open Rust runtime joins this repo as one Cargo workspace
The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.

The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.

What changed versus the engine repo copy:

- Every crate manifest moves from `license-file.workspace` to
  `license.workspace` (this workspace declares Apache-2.0), and the workspace
  gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
  a sibling `launcher/` dir, and `engine_binary` downloads from
  `github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
  of the retired dist repo. No oracle golden carried the old URL, so no
  re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
  `../impeccable-second` or a hardcoded home directory now resolve the root as
  `CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
  override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
  glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
  archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
  live mode serves that. `scripts/check-detector-release.mjs` now requires it
  and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
  `modern-screenshot.umd.js` directly rather than through vendored copies, so
  the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
  plain `cargo build --release -p impeccable` is enough to run `bun run test`.

Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-01 15:31:26 -07:00

470 lines
19 KiB
JavaScript

// --- browser-bundle/50-scan.js ---
// The in-page scan/detect API, the WASM core bridge (group-map
// marshalling), and the extension-mode message loop of the standalone
// bundle. Ported from cli/engine/browser/injected/index.mjs Section 7; every
// rule decision is a call into the WASM core (`__impeccable.*`), the DOM
// reads it needs go through the probe, the overlay UI is 40-overlay.js and
// the visual-contrast sampling 35-visual.js.
const IS_BROWSER = typeof window !== 'undefined';
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
if (IS_BROWSER && !__impeccable) {
// The core could not start (in practice: a Content-Security-Policy whose
// script-src lacks 'wasm-unsafe-eval'). Keep the API surface so callers get
// one clear error instead of "impeccableDetect is not a function".
const reason = __impeccableInitError && __impeccableInitError.message
? __impeccableInitError.message
: String(__impeccableInitError);
const message = `[impeccable] detector core unavailable: ${reason} (a Content-Security-Policy without 'wasm-unsafe-eval' blocks WebAssembly)`;
const fail = () => { throw new Error(message); };
const _myScript = document.currentScript;
const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
|| document.documentElement.dataset.impeccableExtension === 'true';
console.warn(message);
window.impeccableDetect = fail;
window.impeccableDetectAsync = async () => fail();
window.impeccableScan = fail;
window.impeccableScanAsync = async () => fail();
window.impeccableMeasureHiddenText = fail;
window.impeccableCollectVisualContrastCandidates = fail;
window.impeccableAnalyzeVisualContrast = async () => fail();
window.impeccableGetLastVisualContrastAnalyses = () => [];
window.__impeccableCoreError = message;
if (EXTENSION_MODE) {
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
if (e.data.action === 'scan') window.postMessage({ source: 'impeccable-error', message }, '*');
});
window.postMessage({ source: 'impeccable-ready' }, '*');
}
} else if (IS_BROWSER) {
// Detect extension mode via the script tag's data attribute or the document element fallback.
// currentScript is reliable for synchronously-executing scripts (which our IIFE is).
const _myScript = document.currentScript;
const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
|| document.documentElement.dataset.impeccableExtension === 'true';
const ui = createImpeccableOverlay({
extensionMode: EXTENSION_MODE,
antipatterns: JSON.parse(__impeccable.antipatterns_json()),
});
const {
collectVisualContrastCandidates,
analyzeVisualContrastCandidate,
analyzeVisualContrast,
waitForVisualPaint,
} = createVisualContrast(createInPageVisualIO(__impeccable));
// ── WASM core bridge ──────────────────────────────────────────────────────
// The rule core runs collectBrowserFindings in WASM and hands back element
// handles; this side keeps a Map<Element, findings[]> so later additions
// (visual contrast) can join the same groups, and serializes through the
// core so selectors/labels/severities come from one place.
function collectConfigJson() {
const config = window.__IMPECCABLE_CONFIG__ || {};
return JSON.stringify({
extensionMode: EXTENSION_MODE,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
});
}
// A page matched by detector.ignoreFiles is waived wholesale: every scan
// stage answers empty so the badge and toast read zero. Mirrors
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
// overlay resolves the globs per page (live-browser-ignores.js) and
// forwards the verdict as config.skipScan. The core repeats this guard on
// the parsed config so the snapshot route answers empty too.
function skipScanActive() {
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
}
function serializeFindings(allFindings) {
const groups = allFindings.map(({ el, findings }) => ({ el: __intern(el), findings }));
return JSON.parse(__impeccable.serialize_findings(JSON.stringify(groups)));
}
const printSummary = function(allFindings) {
if (allFindings.length === 0) {
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
return;
}
console.group(
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
'color: oklch(84% 0.19 80.46); font-weight: bold'
);
for (const { el, findings } of allFindings) {
for (const f of findings) {
console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);
}
}
console.groupEnd();
};
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
function collectBrowserFindings() {
if (skipScanActive()) {
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
}
__resetRegistry();
const collected = JSON.parse(__impeccable.collect_browser_findings(collectConfigJson()));
const groupMap = new Map();
for (const g of collected.groups) {
// Handle 0 is the JS `document.body` null key (a bare document).
groupMap.set(__el(g.el), g.findings);
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
pageLevelFindings: collected.pageLevel,
};
}
// Config plumbing shared with the extension's offscreen document lives in
// 30-scan-common.js; here the config is the page's __IMPECCABLE_CONFIG__.
const pageConfig = () => window.__IMPECCABLE_CONFIG__ || {};
const visualContrastMode = (options = {}) => __visualContrastMode(options, pageConfig());
const shouldRunVisualContrast = (options = {}) => visualContrastMode(options) !== false;
const visualContrastOptions = (options = {}) => __visualContrastOptions(options, pageConfig());
const scanResultMeta = __scanResultMeta;
let lastVisualContrastAnalyses = [];
let lazyVisualContrastObserver = null;
let lazyVisualContrastPending = new WeakMap();
const lazyVisualContrastResolving = new WeakSet();
let scanGeneration = 0;
function rememberVisualContrastAnalysis(result) {
if (!result?.selector) {
lastVisualContrastAnalyses.push(result);
return;
}
const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);
if (idx >= 0) lastVisualContrastAnalyses[idx] = result;
else lastVisualContrastAnalyses.push(result);
}
function disconnectLazyVisualContrastObserver() {
if (lazyVisualContrastObserver) {
lazyVisualContrastObserver.disconnect();
lazyVisualContrastObserver = null;
}
lazyVisualContrastPending = new WeakMap();
}
function addVisualContrastResult(groupMap, result, options = {}) {
const elId = __impeccable.visual_contrast_result_el(JSON.stringify(result));
const el = __el(elId);
if (!el) return false;
const existing = groupMap.get(el) || [];
const finding = JSON.parse(__impeccable.visual_contrast_result_finding(elId, JSON.stringify(existing), JSON.stringify(result)));
if (!finding) return false;
if (groupMap.has(el)) groupMap.get(el).push(finding);
else groupMap.set(el, [finding]);
if (options.decorate && el !== document.body && el !== document.documentElement) {
ui.highlight(el, groupMap.get(el) || []);
}
return true;
}
function postSerializedFindings(groupMap, options = {}) {
if (!EXTENSION_MODE) return;
const allFindings = browserFindingsFromMap(groupMap);
window.postMessage({
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
function postExtensionError(err) {
if (!EXTENSION_MODE) return;
window.postMessage({
source: 'impeccable-error',
message: err?.message || String(err),
}, '*');
}
function reportVisualContrastError(err, detail = {}) {
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {
detail: {
...detail,
message: err?.message || String(err),
},
}));
if (EXTENSION_MODE) {
postExtensionError(err);
} else {
console.warn('[impeccable] visual contrast scan failed', err);
}
}
function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {
disconnectLazyVisualContrastObserver();
if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;
if (typeof IntersectionObserver === 'undefined') return;
const unresolved = __lazyVisualContrastCandidates(analyses);
if (unresolved.length === 0) return;
const generation = runtime.generation || scanGeneration;
lazyVisualContrastObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
const candidate = lazyVisualContrastPending.get(el);
if (!candidate || lazyVisualContrastResolving.has(el)) continue;
lazyVisualContrastObserver?.unobserve(el);
lazyVisualContrastPending.delete(el);
lazyVisualContrastResolving.add(el);
waitForVisualPaint()
.then(() => analyzeVisualContrastCandidate(candidate))
.then(result => {
if (generation !== scanGeneration) return;
rememberVisualContrastAnalysis(result);
const added = addVisualContrastResult(groupMap, result, { decorate: true });
if (added) {
postSerializedFindings(groupMap, options);
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
detail: {
selector: result.selector,
status: result.status,
finding: result.finding || null,
},
}));
}
})
.catch(err => {
reportVisualContrastError(err, { selector: candidate.selector });
})
.finally(() => {
lazyVisualContrastResolving.delete(el);
});
}
}, { threshold: 0.5 });
for (const candidate of unresolved) {
let el = null;
try {
el = document.querySelector(candidate.selector);
} catch {
el = null;
}
if (!el) continue;
lazyVisualContrastPending.set(el, candidate);
lazyVisualContrastObserver.observe(el);
}
}
async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {
if (!shouldRunVisualContrast(options)) {
lastVisualContrastAnalyses = [];
disconnectLazyVisualContrastObserver();
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
for (const result of analyses) {
addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });
}
if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);
return analyses;
}
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
const collected = collectBrowserFindings();
// The visual pass walks the DOM on its own; on a skipScan page it would
// repopulate the emptied scan, so it is skipped with everything else.
if (skipScanActive()) {
lastVisualContrastAnalyses = [];
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
}
await addVisualContrastFindings(collected.groupMap, options, runtime);
return {
...collected,
allFindings: browserFindingsFromMap(collected.groupMap),
visualContrastAnalyses: lastVisualContrastAnalyses,
};
}
function clearOverlays() {
scanGeneration += 1;
disconnectLazyVisualContrastObserver();
ui.clearOverlays();
}
function renderBrowserFindings(collected, options = {}) {
const { allFindings, pageLevelFindings } = collected;
for (const { el, findings } of allFindings) {
if (el === document.body || el === document.documentElement) continue;
ui.highlight(el, findings);
}
if (pageLevelFindings.length > 0) {
ui.showPageBanner(pageLevelFindings);
}
if (!EXTENSION_MODE) printSummary(allFindings);
// In extension mode, post serialized results for the DevTools panel
if (EXTENSION_MODE) {
window.postMessage({
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
// After this scan completes, all subsequent reveals are instant (no stagger, no animation)
setTimeout(() => { ui.setFirstScanDone(); }, 1000);
return allFindings;
}
const scan = function(options = {}) {
clearOverlays();
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected, options);
if (!skipScanActive() && shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
})
.catch(err => {
reportVisualContrastError(err);
});
}
return allFindings;
};
const scanAsync = async function(options = {}) {
clearOverlays();
const generation = scanGeneration;
if (shouldRunVisualContrast(options)) {
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
if (generation !== scanGeneration) return [];
return renderBrowserFindings(collected, options);
}
lastVisualContrastAnalyses = [];
return renderBrowserFindings(collectBrowserFindings(), options);
};
const detect = function(options = {}) {
lastVisualContrastAnalyses = [];
const { allFindings } = collectBrowserFindings();
return options.serialize === false ? allFindings : serializeFindings(allFindings);
};
const detectAsync = async function(options = {}) {
if (shouldRunVisualContrast(options)) {
const { allFindings } = await collectBrowserFindingsAsync(options);
return options.serialize === false ? allFindings : serializeFindings(allFindings);
}
lastVisualContrastAnalyses = [];
const { allFindings } = collectBrowserFindings();
return options.serialize === false ? allFindings : serializeFindings(allFindings);
};
if (EXTENSION_MODE) {
// Extension mode: listen for commands, don't auto-scan
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
if (e.data.action === 'scan') {
if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
try {
scan(e.data.config || {});
} catch (err) {
postExtensionError(err);
}
}
if (e.data.action === 'toggle-overlays') {
const visible = ui.toggleOverlays();
window.postMessage({ source: 'impeccable-overlays-toggled', visible }, '*');
}
if (e.data.action === 'remove') {
clearOverlays();
ui.remove();
}
if (e.data.action === 'highlight') {
ui.highlightSelector(e.data.selector);
}
if (e.data.action === 'unhighlight') {
ui.unspotlight();
}
});
window.postMessage({ source: 'impeccable-ready' }, '*');
} else {
if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {
const runAutoScan = () => {
try {
scan();
} catch (err) {
console.warn('[impeccable] scan failed', err);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));
} else {
setTimeout(runAutoScan, 100);
}
}
}
window.impeccableDetect = detect;
window.impeccableDetectAsync = detectAsync;
window.impeccableScan = scan;
window.impeccableScanAsync = scanAsync;
// Raw measurement for the URL engine's content-hidden-at-rest pass: it
// drives a reveal sweep from Node and thresholds the result itself.
window.impeccableMeasureHiddenText = () => JSON.parse(__impeccable.measure_hidden_text());
window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
// The snapshot route (what the extension runs when the page's CSP keeps
// WebAssembly out of every world it can reach), exposed here so the two
// routes can be A/B'd on the same page: capture, run the same core over
// the snapshot (answering its hit-test needs from the live page), and
// serialize through it. Deterministic findings only; the visual-contrast
// pass over a snapshot is the extension's (see 60-offscreen.js).
window.impeccableSnapshotCapture = (options) => __impeccableSnapshot.capture(options);
window.impeccableDetectFromSnapshot = function (options = {}) {
const t0 = performance.now();
const cap = __impeccableSnapshot.capture(options);
if (cap.error) throw new Error(cap.error);
const t1 = performance.now();
let out = JSON.parse(__impeccable.collect_findings_from_snapshot(cap.json, collectConfigJson()));
let rounds = 1;
while (out.needs) {
__impeccable.snapshot_add_facts(JSON.stringify(__impeccableSnapshot.answer(out.needs, cap)));
out = JSON.parse(__impeccable.collect_browser_findings(collectConfigJson()));
if (__impeccable.snapshot_has_needs()) out = { needs: JSON.parse(__impeccable.snapshot_take_needs()) };
rounds++;
}
const serialized = JSON.parse(__impeccable.serialize_findings(JSON.stringify(out.groups)));
const unknownStyleProps = JSON.parse(__impeccable.snapshot_unknown_style_props());
__impeccable.snapshot_clear();
return {
findings: serialized,
pageLevel: out.pageLevel,
stats: { ...cap.stats, rounds, unknownStyleProps, captureMs: t1 - t0, coreMs: performance.now() - t1 },
};
};
}