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
+94 -49
View File
@@ -3,70 +3,117 @@
/**
* Builds the browser DevTools extension (Chrome + Firefox).
*
* 1. Generates the extension variant of the browser detector
* 2. Extracts antipatterns.json for the panel UI
* 3. Packages extension.zip (Chrome Web Store) and extension-firefox.zip (AMO)
* 1. Vendors the five generated detector pieces (core.js, core_bg.wasm,
* snapshot.js, overlay.js, antipatterns.json) into extension/detector/
* from the closed detector release, resolved by
* scripts/lib/detector-bundle.mjs the same three ways crates/core/build.rs
* resolves the native archive.
* 2. Checks that every path the manifest and the service worker reference
* exists in extension/.
* 3. Packages extension.zip (Chrome Web Store) and extension-firefox.zip (AMO).
*
* The source `extension/manifest.json` is the Chrome manifest. The Firefox
* variant is derived at build time: the MV3 background service worker is
* declared as an event-page `scripts` entry (the universally-supported path on
* Gecko), and `browser_specific_settings.gecko` is added for AMO signing.
*
* Firefox caveat: the shell runs the WebAssembly rule core in an extension
* offscreen document, and Gecko has no `chrome.offscreen` API, so the Firefox
* package builds and lints but cannot scan until that gap is closed. The
* Firefox artifact is still produced so `web-ext lint` keeps covering the
* shared shell.
*
* Run: node scripts/build-extension.js
* IMPECCABLE_DETECTOR_LIB=<dir> use a local `cargo xtask detector-archive` output
* IMPECCABLE_DETECTOR_BASE=<url> override the detector release root
* IMPECCABLE_DETECTOR_OFFLINE=1 refuse to download
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
import { bundleBrowserDetectorModules } from './lib/browser-detector-bundle.js';
import { DETECTOR_PIECES, vendorDetectorBundle } from './lib/detector-bundle.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const EXT_DIR = path.join(ROOT, 'extension');
const DETECTOR_DIR = path.join(EXT_DIR, 'detector');
const DETECTOR_OUTPUT = path.join(EXT_DIR, 'detector/detect.js');
const AP_OUTPUT = path.join(EXT_DIR, 'detector/antipatterns.json');
// --- 1. Vendor the detector pieces ---
const code = bundleBrowserDetectorModules(ROOT);
const vendored = await vendorDetectorBundle({
root: ROOT,
destDir: DETECTOR_DIR,
log: (line) => console.log(line),
});
const totalKb = vendored.files.reduce((sum, f) => sum + f.bytes, 0) / 1024;
console.log(
`Vendored ${DETECTOR_PIECES.length} detector pieces into extension/detector/ ` +
`(${totalKb.toFixed(1)} KB, detector v${vendored.version}, source: ${vendored.source})`,
);
// --- 1. Build detector ---
const ruleCount = JSON.parse(fs.readFileSync(path.join(DETECTOR_DIR, 'antipatterns.json'), 'utf-8')).length;
console.log(` antipatterns.json: ${ruleCount} rules (generated; not regenerated here)`);
const output = `/**
* Anti-Pattern Browser Detector for Impeccable (Extension Variant)
* Copyright (c) 2026 Paul Bakaus
* SPDX-License-Identifier: Apache-2.0
*
* GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs
* Rebuild: node scripts/build-extension.js
// --- 2. Referenced-file check ---
const chromeManifest = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf-8'));
const serviceWorker = chromeManifest.background?.service_worker;
if (!serviceWorker) {
throw new Error(
'extension/manifest.json: expected background.service_worker to derive the Firefox manifest',
);
}
/** Every extension-relative path the manifest declares. */
function manifestReferences(manifest) {
const refs = [];
const add = (value) => { if (typeof value === 'string' && value) refs.push(value.replace(/^\//, '')); };
add(manifest.background?.service_worker);
for (const script of manifest.background?.scripts || []) add(script);
add(manifest.devtools_page);
add(manifest.action?.default_popup);
for (const icon of Object.values(manifest.action?.default_icon || {})) add(icon);
for (const icon of Object.values(manifest.icons || {})) add(icon);
for (const entry of manifest.content_scripts || []) {
for (const file of entry.js || []) add(file);
for (const file of entry.css || []) add(file);
}
for (const entry of manifest.web_accessible_resources || []) {
for (const resource of entry.resources || []) add(resource);
}
return refs;
}
/**
* The service worker injects the content script and its generated companions
* by path and opens the offscreen document by path, so those files are
* referenced without appearing in the manifest.
*/
(function () {
if (typeof window === 'undefined') return;
${code}
})();
`;
function serviceWorkerReferences(source) {
const refs = [];
const offscreen = source.match(/OFFSCREEN_URL\s*=\s*['"]([^'"]+)['"]/);
if (offscreen) refs.push(offscreen[1]);
for (const block of source.matchAll(/files:\s*\[([^\]]*)\]/g)) {
for (const file of block[1].matchAll(/['"]([^'"]+)['"]/g)) refs.push(file[1]);
}
return refs;
}
fs.mkdirSync(path.dirname(DETECTOR_OUTPUT), { recursive: true });
fs.writeFileSync(DETECTOR_OUTPUT, output);
console.log(`Generated ${path.relative(ROOT, DETECTOR_OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`);
// --- 2. Extract antipatterns.json ---
// Include description so the devtools panel can show the full rule explanation
// in tooltips.
const apJson = ANTIPATTERNS.map(({ id, name, category, description }) => ({
id,
name,
category: category || 'quality',
description: description || '',
}));
fs.writeFileSync(AP_OUTPUT, JSON.stringify(apJson, null, 2) + '\n');
console.log(`Generated ${path.relative(ROOT, AP_OUTPUT)} (${ANTIPATTERNS.length} rules)`);
const swSource = fs.readFileSync(path.join(EXT_DIR, serviceWorker), 'utf-8');
const referenced = [...new Set([...manifestReferences(chromeManifest), ...serviceWorkerReferences(swSource)])];
const missingRefs = referenced.filter((rel) => !fs.existsSync(path.join(EXT_DIR, rel)));
if (missingRefs.length) {
throw new Error(
`extension/ is missing referenced file(s):\n${missingRefs.map((r) => ` · ${r}`).join('\n')}`,
);
}
console.log(`Checked ${referenced.length} referenced paths; all present in extension/`);
// --- 3. Zip packaging ---
import { execSync } from 'child_process';
const DIST = path.join(ROOT, 'dist');
fs.mkdirSync(DIST, { recursive: true });
@@ -90,15 +137,6 @@ packZip(path.join(DIST, 'extension.zip'), EXT_DIR, ['STORE_LISTING.md', '*.DS_St
// --- 3b. Firefox: derive a Gecko-compatible manifest and stage an unpacked
// build (consumed by `web-ext lint` in CI), then zip it for AMO. ---
const chromeManifest = JSON.parse(fs.readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf-8'));
const serviceWorker = chromeManifest.background?.service_worker;
if (!serviceWorker) {
throw new Error(
'extension/manifest.json: expected background.service_worker to derive the Firefox manifest',
);
}
const firefoxManifest = {
...chromeManifest,
// Gecko supports MV3 via non-persistent event pages. Declaring `scripts`
@@ -116,7 +154,8 @@ const firefoxManifest = {
// everything else this extension uses (MV3 action, scripting, devtools,
// object-form web_accessible_resources, storage.sync) landed long before.
strict_min_version: '140.0',
// The detector runs entirely in-page; nothing is transmitted off-device.
// The rules run in the extension's own offscreen document; nothing is
// transmitted off-device.
data_collection_permissions: { required: ['none'] },
},
},
@@ -139,3 +178,9 @@ console.log(`Staged ${path.relative(ROOT, ffStageDir)}/ (Firefox manifest)`);
// STORE_LISTING.md is already filtered out of the stage dir above.
packZip(path.join(DIST, 'extension-firefox.zip'), ffStageDir, ['*.DS_Store']);
console.warn(
'Warning: the Firefox package cannot scan yet. The rule core runs in an ' +
'extension offscreen document and Gecko has no chrome.offscreen API. The ' +
'artifact is built so web-ext lint keeps covering the shared shell.',
);