build:extension: ship the wasm-core extension shell and vendor its detector from the detector release

`bun run build:extension` was broken on this branch: it still imported the
deleted JS engine (cli/engine/registry/antipatterns.mjs,
scripts/lib/browser-detector-bundle.js).

The shipped shell now matches the new design. The content script only
snapshots the DOM; an extension-owned offscreen document runs the
WebAssembly rule core over that snapshot, so the scanned page's CSP no
longer matters. That replaces the old approach of injecting a JS rules
bundle into the page. New files: extension/offscreen/offscreen.html, plus
the "offscreen" permission and a 'wasm-unsafe-eval' extension_pages CSP in
the manifest.

The manifest version stays at 1.3.3. The shell's own manifest carried
2.0.0; feature branches never bump versions, so the bump is a release step.

The five generated detector pieces (core.js, core_bg.wasm, snapshot.js,
overlay.js, antipatterns.json) are vendored at build time into the
gitignored extension/detector/ by the new scripts/lib/detector-bundle.mjs,
which resolves them the same three ways crates/core/build.rs resolves the
native archive: IMPECCABLE_DETECTOR_LIB/extension-detector/, the
~/.impeccable/detector/<DETECTOR_VERSION>/ cache, then a checksum-verified
download of detector-browser-bundle.zip from the detector release.
antipatterns.json is no longer regenerated here.

The zip packaging is unchanged. The Firefox variant still builds so
`web-ext lint` keeps covering the shared shell, but it cannot scan: Gecko
has no chrome.offscreen API. The build prints a one-line warning saying so.

Also here: a referenced-path check that fails the build when the manifest
or the service worker points at a file that is not in extension/, a
resolver unit test wired into the core suite, and the detector rule count
in the READMEs synced to the 61 the vendored registry carries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
Paul Bakaus
2026-09-01 16:20:54 -07:00
co-authored by Claude Fable 5.1
parent 836516a7a0
commit f2c9aeab5b
12 changed files with 873 additions and 154 deletions
+257 -70
View File
@@ -1,9 +1,20 @@
/**
* Impeccable DevTools Extension - Content Script
*
* Bridges between the extension messaging system and the page-context detector.
* The detector must run in page context (not isolated world) because it needs
* access to getComputedStyle, document.styleSheets.cssRules, etc.
* Runs in the tab's isolated world with two generated companions injected
* before it (detector/snapshot.js, detector/overlay.js). A scan is:
*
* 1. capture a snapshot of the page (measurement only, snapshot.js);
* 2. hand it to the offscreen document, where the WebAssembly rule core
* runs the same rules the in-page bundle runs (detector/core.js); the
* core asks back for what a snapshot cannot hold — hit tests, image
* pixels — and this script answers from the live page;
* 3. draw the findings it returns (overlay.js) and report them to the
* service worker exactly as before (`findings`, `overlays-toggled`).
*
* No rule logic lives in this world, so the page's Content-Security-Policy
* (which gates WebAssembly in every world an extension can reach) does not
* matter: github.com scans like any other page.
*
* Wrapped in an IIFE with an idempotency flag so re-injection (via
* chrome.scripting.executeScript) is a no-op and doesn't cause:
@@ -14,61 +25,266 @@
if (window.__IMPECCABLE_CS_LOADED__) return;
window.__IMPECCABLE_CS_LOADED__ = true;
let injected = false;
let pendingScan = false;
const OFFSCREEN = 'impeccable-offscreen';
const snapshot = window.__impeccableSnapshot;
const createOverlay = window.__impeccableCreateOverlay;
// One session id per content-script instance: a re-scan supersedes the
// previous run of the same tab in the offscreen document.
const sessionId = `tab-${Math.random().toString(36).slice(2)}`;
const candidateSessionId = `${sessionId}-lazy`;
let injected = false; // a scan has produced findings on this page
let scanConfig = null;
let ui = null;
let uiPromise = null;
let scanGeneration = 0;
let capture = null; // the last capture (elements by snapshot id)
let visualIO = null;
const groupMap = new Map(); // Element -> findings[]
let lazyObserver = null;
const lazyPending = new WeakMap();
const lazyResolving = new WeakSet();
// Measurements of the last scan (snapshot size, core time, ask rounds),
// reported alongside the findings for diagnostics.
let lastStats = null;
const send = (msg) => chrome.runtime.sendMessage({ target: OFFSCREEN, ...msg });
function reportError(err) {
chrome.runtime.sendMessage({
action: 'detector-error',
message: err?.message || String(err),
}).catch(() => {});
}
async function ensureUI() {
if (ui) return ui;
if (!uiPromise) {
uiPromise = send({ action: 'antipatterns' }).then((r) => {
if (!r || r.error) throw new Error(r?.error || 'rule registry unavailable');
ui = createOverlay({ extensionMode: true, antipatterns: r.antipatterns });
return ui;
});
uiPromise.catch(() => { uiPromise = null; });
}
return uiPromise;
}
// Answer one of the core's questions from the live page.
async function answer(askMsg) {
if (askMsg.hitTests) return snapshot.answer(askMsg, capture);
const io = askMsg.io;
if (io && io.kind === 'loadImage') return visualIO.loadImage(io.src);
if (io && io.kind === 'readPixel') return visualIO.readPixel(io.ref, io.plan, io.px, io.py);
return null;
}
function elementOf(id) {
return capture ? capture.elements[id] || null : null;
}
function setGroups(groups) {
groupMap.clear();
for (const g of groups) {
const el = elementOf(g.el);
if (el) groupMap.set(el, g.findings);
}
}
function groupsForCapture(cap) {
const out = [];
for (const [el, findings] of groupMap) {
const id = snapshot.idOf(el, cap);
if (id) out.push({ el: id, findings });
}
return out;
}
function postFindings(serialized) {
chrome.runtime.sendMessage({
action: 'findings',
findings: serialized,
count: serialized.length,
stats: lastStats,
}).catch(() => {});
}
function drawGroups(groups, onlyIds) {
for (const g of groups) {
if (onlyIds && !onlyIds.includes(g.el)) continue;
const el = elementOf(g.el);
if (!el || el === document.body || el === document.documentElement) continue;
ui.highlight(el, g.findings);
}
}
function disconnectLazy() {
if (lazyObserver) {
lazyObserver.disconnect();
lazyObserver = null;
}
}
// Elements whose visual contrast stayed unresolved only because they were
// outside the viewport: re-analyze each when it scrolls into view.
function scheduleLazy(candidates, generation) {
disconnectLazy();
if (!candidates.length || typeof IntersectionObserver === 'undefined') return;
lazyObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const el = entry.target;
const candidate = lazyPending.get(el);
if (!candidate || lazyResolving.has(el)) continue;
lazyObserver?.unobserve(el);
lazyPending.delete(el);
lazyResolving.add(el);
waitForPaint()
.then(() => analyzeCandidate(candidate, generation))
.catch(reportError)
.finally(() => lazyResolving.delete(el));
}
}, { threshold: 0.5 });
for (const candidate of candidates) {
let el = null;
try { el = document.querySelector(candidate.selector); } catch { el = null; }
if (!el) continue;
lazyPending.set(el, candidate);
lazyObserver.observe(el);
}
}
function waitForPaint() {
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
}
// Drive one offscreen session to completion, answering its asks.
async function drive(startMsg, sid, onStage) {
let r = await send({ ...startMsg, session: sid });
for (;;) {
if (!r) throw new Error('the rule core did not answer');
if (r.error) throw new Error(r.error);
if (r.superseded) return null;
if (r.ask) {
if (lastStats) {
if (r.ask.hitTests) { lastStats.rounds += 1; lastStats.hitTests += r.ask.hitTests.length; }
else if (r.ask.io) lastStats.ioAsks += 1;
}
const a = await answer(r.ask);
r = await send({ action: 'scan-continue', session: sid, answer: a });
continue;
}
if (r.stage) {
await onStage(r);
r = await send({ action: 'scan-continue', session: sid, answer: {} });
continue;
}
return r;
}
}
async function runScan(config) {
const generation = ++scanGeneration;
scanConfig = config || null;
window.__IMPECCABLE_CONFIG__ = config || {};
await ensureUI();
if (generation !== scanGeneration) return;
disconnectLazy();
ui.clearOverlays();
const cap = snapshot.capture();
if (cap.error) throw new Error(cap.error);
capture = cap;
visualIO = snapshot.visualIO(cap);
lastStats = { elements: cap.stats.elements, bytes: cap.stats.bytes, captureMs: cap.stats.ms, coreMs: null, visualMs: null, rounds: 1, hitTests: 0, ioAsks: 0, unknownStyleProps: [], startedAt: performance.now(), findingsAtMs: null, visualAtMs: null };
await drive({ action: 'scan-start', snapshot: cap.json, config: config || {} }, sessionId, async (r) => {
if (generation !== scanGeneration) return;
if (r.stats) {
if (r.stats.coreMs !== undefined) lastStats.coreMs = r.stats.coreMs;
if (r.stats.visualMs !== undefined) lastStats.visualMs = r.stats.visualMs;
if (r.stats.unknownStyleProps) lastStats.unknownStyleProps = r.stats.unknownStyleProps;
}
if (r.stage === 'findings') {
lastStats.findingsAtMs = performance.now() - lastStats.startedAt;
setGroups(r.groups);
drawGroups(r.groups);
if (r.pageLevel && r.pageLevel.length) ui.showPageBanner(r.pageLevel);
injected = true;
postFindings(r.serialized);
setTimeout(() => ui.setFirstScanDone(), 1000);
} else if (r.stage === 'visual') {
lastStats.visualAtMs = performance.now() - lastStats.startedAt;
setGroups(r.groups);
drawGroups(r.groups, r.added);
postFindings(r.serialized);
scheduleLazy(r.lazy || [], generation);
}
});
}
async function analyzeCandidate(candidate, generation) {
if (generation !== scanGeneration) return;
const cap = snapshot.capture();
if (cap.error) throw new Error(cap.error);
const priorCapture = capture;
const priorIO = visualIO;
capture = cap;
visualIO = snapshot.visualIO(cap);
try {
const out = await drive({
action: 'analyze-candidate',
snapshot: cap.json,
candidate,
groups: groupsForCapture(cap),
}, candidateSessionId, async () => {});
if (!out || generation !== scanGeneration) return;
if (out.el) {
const el = elementOf(out.el);
const group = out.groups.find(g => g.el === out.el);
if (el && group) {
groupMap.set(el, group.findings);
if (el !== document.body && el !== document.documentElement) ui.highlight(el, group.findings);
if (out.serialized) postFindings(out.serialized);
}
}
} finally {
// Later hit-test/pixel asks for the main session (none pending by now)
// would need the main capture; restore it.
capture = priorCapture || capture;
visualIO = priorIO || visualIO;
}
}
// Listen for commands from the service worker
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'scan') {
scanConfig = msg.config || null;
injectAndScan();
runScan(msg.config || null).catch((err) => {
if (err && err.message === 'superseded') return;
reportError(err);
});
sendResponse({ ok: true });
} else if (msg.action === 'toggle-overlays') {
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
if (ui) {
const visible = ui.toggleOverlays();
chrome.runtime.sendMessage({ action: 'overlays-toggled', visible }).catch(() => {});
}
sendResponse({ ok: true });
} else if (msg.action === 'remove') {
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
scanGeneration += 1;
disconnectLazy();
if (ui) ui.remove();
groupMap.clear();
injected = false;
sendResponse({ ok: true });
} else if (msg.action === 'highlight') {
window.postMessage({ source: 'impeccable-command', action: 'highlight', selector: msg.selector }, '*');
if (ui) ui.highlightSelector(msg.selector);
sendResponse({ ok: true });
} else if (msg.action === 'unhighlight') {
window.postMessage({ source: 'impeccable-command', action: 'unhighlight' }, '*');
if (ui) ui.unspotlight();
sendResponse({ ok: true });
}
return true;
});
// Listen for results and state changes from the detector in page context
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data) return;
if (e.data.source === 'impeccable-results') {
chrome.runtime.sendMessage({
action: 'findings',
findings: e.data.findings,
count: e.data.count,
}).catch(() => {});
}
if (e.data.source === 'impeccable-overlays-toggled') {
chrome.runtime.sendMessage({
action: 'overlays-toggled',
visible: e.data.visible,
}).catch(() => {});
}
if (e.data.source === 'impeccable-ready') {
injected = true;
if (pendingScan) {
pendingScan = false;
sendScanCommand();
}
}
});
// Forward "page is active" signal to the extension when the cursor moves over the page.
// This is the reliable way to know the user has left the DevTools panel — the panel's
// own pointerleave/mouseleave events are unreliable on fast cursor movement.
@@ -87,39 +303,10 @@
if (location.href === lastUrl) return;
lastUrl = location.href;
if (injected) {
// Detector is still loaded in page context, just re-scan after DOM settles
setTimeout(sendScanCommand, 500);
// Re-scan after the DOM settles
setTimeout(() => runScan(scanConfig).catch(reportError), 500);
}
}
window.addEventListener('popstate', onPossibleNavigation);
window.addEventListener('hashchange', onPossibleNavigation);
function sendScanCommand() {
const msg = { source: 'impeccable-command', action: 'scan' };
if (scanConfig) msg.config = scanConfig;
window.postMessage(msg, '*');
}
function injectAndScan() {
if (injected) {
sendScanCommand();
return;
}
// Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
document.documentElement.dataset.impeccableExtension = 'true';
// Inject the detector script into page context
const script = document.createElement('script');
script.src = chrome.runtime.getURL('detector/detect.js');
script.dataset.impeccableExtension = 'true';
pendingScan = true;
script.onload = () => script.remove();
script.onerror = () => {
script.remove();
// Fallback: use chrome.scripting.executeScript for strict CSP pages
chrome.runtime.sendMessage({ action: 'inject-fallback' });
};
(document.head || document.documentElement).appendChild(script);
}
})();