mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
The detector is open source. The rules it ships were already public in this repo's git history and in every npm tarball of the JS engine, so a closed binary bought nothing it could keep; the moat is the service (the catalog, the labs, the review pipeline), not the check functions. Keeping them behind a prebuilt archive cost a C-ABI, an exact toolchain pin, a build-time download, a second release to order ahead of every engine release, and a serde layer that had to serve two encodings. Deleted - crates/core/src/ffi.rs, crates/core/build.rs, crates/core/tests/boundary.rs and the shim modules under src/checks and src/browser. - crates/foundation/src/boundary.rs and the postcard dependency. - DETECTOR_VERSION, scripts/check-detector-release.mjs and its test, the check:detector-release script, the detector gate and IMPECCABLE_SKIP_DETECTOR_CHECK in scripts/release.mjs. - scripts/lib/detector-bundle.mjs and tests/detector-bundle.test.mjs (the vendoring path for the closed browser bundle). - scripts/build-browser-detector.js and the build:browser script (a stub since the JS engine left the tree). - xtask's detector-archive subcommand and its public-repo lookup. Came back - crates/core is now the rule logic itself: every check_* / scan_*, the browser adapters, the visual-contrast decisions. It re-exports foundation as before, so no consumer changed. Its vectors dispatcher is the union of both id tables again, and tests/vectors.rs replays the frozen vectors straight through it. - crates/wasm and crates/xtask join the workspace. cargo xtask bundle builds the in-page bundle from browser-bundle/ plus the wasm core, writes dist/, refreshes the tracked crates/live/assets/detect-antipatterns- browser.js, and writes extension/detector/. bun run build:extension runs it instead of downloading. - crates/live/assets/detect-antipatterns-browser.js is tracked again; live mode embeds it and serves it as /detect.js. - Serde is back to plain derives: no is_human_readable branch in js::json_number, derived Serialize for Rgba and BrowserFinding with their skip_serializing_if attributes. - profile.release has lto = "fat" again; rust-toolchain.toml is plain stable plus the wasm32 target. The rust, rust-windows and oracle CI jobs lose continue-on-error and can be required. Verified - cargo build --workspace --all-targets: clean, no warnings. - cargo test --workspace: 346 pass, 0 fail (the 8 boundary tests are gone with the boundary). - cargo build -p impeccable-wasm --target wasm32-unknown-unknown --release: ok. - cargo xtask bundle && cargo xtask bundle --check: reproducible; the regenerated bundle is committed (it differs from the archived one, which was built with a pinned rustc and lto = false). - cargo build --release -p impeccable: no linker warnings, 12.5 MB (the same source at lto = false is 13.1 MB). - oracle: 795 pass, 0 fail, 0 accepted deltas, 0 missing goldens. - bun run build, bun run build:extension, web-ext lint (0 errors, 8 warnings), bun run test: 363 + 80 + 1 + 1 + 133 + 180 + 4 pass, 0 fail. - impeccable detect --no-config --json tests/fixtures/antipatterns: 128.7 ms median of 5. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
197 lines
8.3 KiB
JavaScript
197 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Builds the browser DevTools extension (Chrome + Firefox).
|
|
*
|
|
* 1. Builds the five generated detector pieces (core.js, core_bg.wasm,
|
|
* snapshot.js, overlay.js, antipatterns.json) into extension/detector/ by
|
|
* running `cargo xtask bundle`, which compiles the rule core to
|
|
* WebAssembly and concatenates it with the page JS in browser-bundle/.
|
|
* 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.
|
|
*
|
|
* Needs a Rust toolchain and wasm-pack for step 1. CI matrices that already
|
|
* ran `cargo xtask bundle` can skip it with IMPECCABLE_EXTENSION_SKIP_BUNDLE=1,
|
|
* which is honored only when extension/detector/ already holds all five pieces.
|
|
*
|
|
* Run: node scripts/build-extension.js
|
|
*/
|
|
|
|
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
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');
|
|
|
|
// --- 1. Build the detector pieces (cargo xtask bundle) ---
|
|
|
|
/** The five generated pieces the extension shell loads. */
|
|
const DETECTOR_PIECES = ['core.js', 'core_bg.wasm', 'snapshot.js', 'overlay.js', 'antipatterns.json'];
|
|
|
|
const havePieces = DETECTOR_PIECES.every((piece) => fs.existsSync(path.join(DETECTOR_DIR, piece)));
|
|
if (process.env.IMPECCABLE_EXTENSION_SKIP_BUNDLE === '1' && havePieces) {
|
|
console.log('Skipping `cargo xtask bundle` (IMPECCABLE_EXTENSION_SKIP_BUNDLE=1, extension/detector/ is complete)');
|
|
} else {
|
|
console.log('Building extension/detector/ with `cargo xtask bundle` ...');
|
|
execSync('cargo xtask bundle', { cwd: ROOT, stdio: 'inherit' });
|
|
}
|
|
|
|
const missingPieces = DETECTOR_PIECES.filter((piece) => !fs.existsSync(path.join(DETECTOR_DIR, piece)));
|
|
if (missingPieces.length) {
|
|
throw new Error(
|
|
`extension/detector/ is missing generated piece(s) after the bundle step:\n` +
|
|
missingPieces.map((p) => ` \u00b7 ${p}`).join('\n'),
|
|
);
|
|
}
|
|
const totalKb =
|
|
DETECTOR_PIECES.reduce((sum, piece) => sum + fs.statSync(path.join(DETECTOR_DIR, piece)).size, 0) / 1024;
|
|
console.log(`Built ${DETECTOR_PIECES.length} detector pieces into extension/detector/ (${totalKb.toFixed(1)} KB)`);
|
|
|
|
const ruleCount = JSON.parse(fs.readFileSync(path.join(DETECTOR_DIR, 'antipatterns.json'), 'utf-8')).length;
|
|
console.log(` antipatterns.json: ${ruleCount} rules`);
|
|
|
|
// --- 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 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;
|
|
}
|
|
|
|
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 ---
|
|
|
|
const DIST = path.join(ROOT, 'dist');
|
|
fs.mkdirSync(DIST, { recursive: true });
|
|
|
|
// `excludes` are passed to `zip -x`; patterns match the full archive path with
|
|
// `*` spanning `/`, so `*.DS_Store` strips the file at every depth, not just root.
|
|
function packZip(zipPath, cwd, excludes = []) {
|
|
try { fs.unlinkSync(zipPath); } catch {}
|
|
const exArgs = excludes.map((e) => `-x ${JSON.stringify(e)}`).join(' ');
|
|
execSync(
|
|
`zip -r ${JSON.stringify(zipPath)} .${exArgs ? ' ' + exArgs : ''}`,
|
|
{ cwd, stdio: 'pipe' },
|
|
);
|
|
const size = fs.statSync(zipPath).size;
|
|
console.log(`Packaged ${path.relative(ROOT, zipPath)} (${(size / 1024).toFixed(1)} KB)`);
|
|
}
|
|
|
|
// --- 3a. Chrome zip (manifest unchanged) ---
|
|
|
|
packZip(path.join(DIST, 'extension.zip'), EXT_DIR, ['STORE_LISTING.md', '*.DS_Store']);
|
|
|
|
// --- 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 firefoxManifest = {
|
|
...chromeManifest,
|
|
// Gecko supports MV3 via non-persistent event pages. Declaring `scripts`
|
|
// (rather than `service_worker`) is the path supported across all MV3 Firefox
|
|
// releases; service-worker.js uses only top-level listeners + an in-memory
|
|
// Map, so it runs unchanged as an event page.
|
|
background: { scripts: [serviceWorker] },
|
|
// Required by AMO for signing/distribution. Ignored by Chrome.
|
|
browser_specific_settings: {
|
|
gecko: {
|
|
id: 'impeccable@bakaus.com',
|
|
// `data_collection_permissions` (below) is required by AMO for new
|
|
// submissions and is only honored on Firefox 140+. We set the floor to
|
|
// 140 so the declared min version actually supports every key we ship;
|
|
// 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 rules run in the extension's own offscreen document; nothing is
|
|
// transmitted off-device.
|
|
data_collection_permissions: { required: ['none'] },
|
|
},
|
|
},
|
|
};
|
|
|
|
const ffStageDir = path.join(DIST, 'extension-firefox');
|
|
fs.rmSync(ffStageDir, { recursive: true, force: true });
|
|
fs.cpSync(EXT_DIR, ffStageDir, {
|
|
recursive: true,
|
|
filter: (src) => {
|
|
const base = path.basename(src);
|
|
return base !== 'STORE_LISTING.md' && base !== '.DS_Store';
|
|
},
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(ffStageDir, 'manifest.json'),
|
|
JSON.stringify(firefoxManifest, null, 2) + '\n',
|
|
);
|
|
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.',
|
|
);
|