mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
The in-page bundle, the extension pieces, the registry JSON and the wasm-pack call were reachable only through `cargo xtask bundle`, which read `browser-bundle/*.js` from the repo root. A downstream crate that links impeccable-core + impeccable-wasm with its own rule pack had to copy the page JS to produce a detector bundle for its module. They move to `impeccable-bundle` (crates/bundle), which embeds every `browser-bundle/*.js` with `include_str!` and exposes `in_page_bundle`, `extension_pieces`, `registry_json`, `check_capture_contract` and `wasm_pack_build`. Nothing writes files or exits the process; the caller places the bytes. `registry_json` now reads `all_antipatterns()`, so an installed pack's rows land in `antipatterns.json` too (no built-in change). xtask becomes the workspace's caller and writes the same files to the same places; `cargo xtask bundle` is byte-identical, tracked live asset included. `IMPECCABLE_BUNDLE_SKIP_WASM_PACK` is the skip switch's new name, the old `IMPECCABLE_XTASK_SKIP_WASM_PACK` still works. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
442 lines
18 KiB
Rust
442 lines
18 KiB
Rust
//! impeccable-bundle: the page JS and the bundler behind `cargo xtask bundle`.
|
|
//!
|
|
//! Every browser artifact the detector ships is this crate's output: the
|
|
//! in-page bundle (`dist/detect-antipatterns-browser.js`, also the tracked
|
|
//! `crates/live/assets/` copy the engine embeds and serves as `/detect.js`)
|
|
//! and the five `extension/detector/` pieces. The page JS is embedded with
|
|
//! `include_str!`, so a crate that depends on this one gets it without
|
|
//! copying `browser-bundle/*.js` anywhere.
|
|
//!
|
|
//! A downstream crate that links `impeccable-core` + `impeccable-wasm` plus
|
|
//! its own [`RulePack`](impeccable_core::rule_pack::RulePack) into one wasm
|
|
//! module builds the same artifacts for its own module:
|
|
//!
|
|
//! ```no_run
|
|
//! use std::path::Path;
|
|
//! let (glue, wasm) = impeccable_bundle::wasm_pack_build(
|
|
//! Path::new("crates/my-wasm"),
|
|
//! Path::new("target/wasm-bundle"),
|
|
//! &[],
|
|
//! )?;
|
|
//! let js = impeccable_bundle::in_page_bundle(&glue, &wasm);
|
|
//! let registry = impeccable_bundle::registry_json();
|
|
//! let ext = impeccable_bundle::extension_pieces(&glue, &wasm, ®istry);
|
|
//! # Ok::<(), String>(())
|
|
//! ```
|
|
//!
|
|
//! The pack's rows reach [`registry_json`] once the pack is installed
|
|
//! (`impeccable_core::rule_pack::install`), because the registry reads
|
|
//! built-ins plus every registered slice.
|
|
//!
|
|
//! Nothing here writes files or exits the process: the caller decides where
|
|
//! the bytes go and how a failure is reported.
|
|
|
|
use base64::Engine as _;
|
|
use std::borrow::Cow;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
macro_rules! page_js {
|
|
($($name:literal),* $(,)?) => {
|
|
/// The page JS, in the order [`in_page_bundle`] concatenates it:
|
|
/// `(file name, source)`. Embedded at compile time from
|
|
/// `browser-bundle/`.
|
|
pub const PAGE_JS: &[(&str, &str)] = &[
|
|
$(($name, include_str!(concat!("../../../browser-bundle/", $name))),)*
|
|
];
|
|
};
|
|
}
|
|
|
|
page_js![
|
|
"00-header.js",
|
|
"10-probe.js",
|
|
"15-snapshot.js",
|
|
"30-scan-common.js",
|
|
"35-visual.js",
|
|
"40-overlay.js",
|
|
"50-scan.js",
|
|
"60-offscreen.js",
|
|
"99-footer.js",
|
|
];
|
|
|
|
/// One embedded page file by name.
|
|
pub fn page_js(name: &str) -> Option<&'static str> {
|
|
PAGE_JS.iter().find(|(n, _)| *n == name).map(|(_, src)| *src)
|
|
}
|
|
|
|
fn src(name: &str) -> Cow<'static, str> {
|
|
let s = page_js(name).unwrap_or_else(|| panic!("browser-bundle/{name}: not embedded"));
|
|
if s.ends_with('\n') {
|
|
Cow::Borrowed(s)
|
|
} else {
|
|
Cow::Owned(format!("{s}\n"))
|
|
}
|
|
}
|
|
|
|
/// In-page bundle concatenation order. `@@GLUE@@` is the wasm-bindgen glue,
|
|
/// `@@WASM@@` the embedded module + synchronous instantiation.
|
|
const ORDER: &[&str] = &[
|
|
"00-header.js",
|
|
"10-probe.js",
|
|
"15-snapshot.js",
|
|
"@@GLUE@@",
|
|
"@@WASM@@",
|
|
"30-scan-common.js",
|
|
"35-visual.js",
|
|
"40-overlay.js",
|
|
"50-scan.js",
|
|
"99-footer.js",
|
|
];
|
|
|
|
/// The extension pieces, each an IIFE over a subset of the same sources plus
|
|
/// a `window.*` export line. `core.js` is the wasm glue + an async loader
|
|
/// (the module ships beside it as `core_bg.wasm`; no base64: the offscreen
|
|
/// document fetches it) + the scan plumbing and visual-contrast orchestration
|
|
/// + the offscreen session protocol.
|
|
const EXT_SNAPSHOT: &[&str] = &["15-snapshot.js"];
|
|
const EXT_OVERLAY: &[&str] = &["40-overlay.js"];
|
|
const EXT_CORE: &[&str] = &["@@GLUE@@", "@@LOADER@@", "30-scan-common.js", "35-visual.js", "60-offscreen.js"];
|
|
|
|
fn push_glue(out: &mut String, glue_js: &str) {
|
|
out.push_str("// --- wasm-bindgen glue (generated by cargo xtask bundle) ---\n");
|
|
out.push_str(glue_js);
|
|
if !glue_js.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
}
|
|
|
|
/// The in-page bundle: `dist/detect-antipatterns-browser.js` and the tracked
|
|
/// `crates/live/assets/` copy. `glue_js` and `wasm` are one wasm-pack
|
|
/// `--target no-modules` build (see [`wasm_pack_build`]); the module is
|
|
/// embedded as base64 and instantiated synchronously at load.
|
|
///
|
|
/// Deterministic: the same glue and module produce the same bytes.
|
|
pub fn in_page_bundle(glue_js: &str, wasm: &[u8]) -> String {
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(wasm);
|
|
let mut out = String::new();
|
|
for part in ORDER {
|
|
match *part {
|
|
"@@GLUE@@" => push_glue(&mut out, glue_js),
|
|
"@@WASM@@" => {
|
|
out.push_str("// --- impeccable_wasm module (generated by cargo xtask bundle) ---\n");
|
|
out.push_str(&format!("const __IMPECCABLE_WASM_BYTES = {};\n", wasm.len()));
|
|
out.push_str("const __IMPECCABLE_WASM_B64 = \"");
|
|
out.push_str(&b64);
|
|
out.push_str("\";\n");
|
|
out.push_str(WASM_INIT);
|
|
}
|
|
name => out.push_str(&src(name)),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// What goes into `extension/detector/`, from [`extension_pieces`].
|
|
pub struct ExtensionPieces {
|
|
/// `snapshot.js`: the content-script page snapshot producer.
|
|
pub snapshot_js: String,
|
|
/// `overlay.js`: the content-script overlay UI.
|
|
pub overlay_js: String,
|
|
/// `core.js`: the offscreen-document wasm loader and scan session.
|
|
pub core_js: String,
|
|
/// `core_bg.wasm`: the module `core.js` fetches beside itself.
|
|
pub core_bg_wasm: Vec<u8>,
|
|
/// `antipatterns.json`: the registry slice the extension panel reads.
|
|
pub antipatterns_json: String,
|
|
}
|
|
|
|
/// The extension pieces for one wasm build. `registry_json` is written
|
|
/// through unchanged, so a caller can pass [`registry_json()`] or its own.
|
|
pub fn extension_pieces(glue_js: &str, wasm: &[u8], registry_json: &str) -> ExtensionPieces {
|
|
let piece = |parts: &[&str], exports: &str, what: &str| -> String {
|
|
let mut p = format!(
|
|
"/**\n * Impeccable extension: {what}\n * Copyright (c) 2026 Paul Bakaus\n *\n * GENERATED -- do not edit. Source: browser-bundle/*.js (+ crates/core, crates/wasm for core.js).\n * Rebuild: cargo xtask bundle\n */\n"
|
|
);
|
|
p.push_str("(function () {\n");
|
|
for part in parts {
|
|
match *part {
|
|
"@@GLUE@@" => push_glue(&mut p, glue_js),
|
|
"@@LOADER@@" => p.push_str(EXT_CORE_LOADER),
|
|
name => p.push_str(&src(name)),
|
|
}
|
|
}
|
|
p.push_str(exports);
|
|
p.push_str("})();\n");
|
|
p
|
|
};
|
|
ExtensionPieces {
|
|
snapshot_js: piece(
|
|
EXT_SNAPSHOT,
|
|
"window.__impeccableSnapshot = __impeccableSnapshot;\nwindow.__impeccableCreateDrawableIO = __createDrawableIO;\n",
|
|
"snapshot.js, the content-script page snapshot producer (measurement only)",
|
|
),
|
|
overlay_js: piece(
|
|
EXT_OVERLAY,
|
|
"window.__impeccableCreateOverlay = createImpeccableOverlay;\n",
|
|
"overlay.js, the content-script overlay UI (draws a findings list; no rules)",
|
|
),
|
|
core_js: piece(
|
|
EXT_CORE,
|
|
"",
|
|
"core.js, the offscreen-document WASM core loader + scan session (rules run in core_bg.wasm)",
|
|
),
|
|
core_bg_wasm: wasm.to_vec(),
|
|
antipatterns_json: registry_json.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The `atob` + synchronous instantiation that runs at bundle load. Chrome
|
|
/// (verified on 151, headless and headed) compiles multi-MB modules
|
|
/// synchronously on the main thread; the historical 4 KB limit no longer
|
|
/// applies to `new WebAssembly.Module`. A CSP without 'wasm-unsafe-eval'
|
|
/// throws here; consumers handle that per docs/WASM-BUNDLE.md.
|
|
const WASM_INIT: &str = r#"function __impeccableWasmBytes() {
|
|
const bin = atob(__IMPECCABLE_WASM_B64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
let __impeccable = null;
|
|
let __impeccableInitError = null;
|
|
try {
|
|
wasm_bindgen.initSync({ module: __impeccableWasmBytes() });
|
|
__impeccable = wasm_bindgen;
|
|
} catch (e) {
|
|
__impeccableInitError = e;
|
|
}
|
|
"#;
|
|
|
|
/// The offscreen document's core loader: fetch the module beside the script
|
|
/// and instantiate asynchronously (the extension's own CSP carries
|
|
/// 'wasm-unsafe-eval'). Also stubs the live-page probe namespace the glue
|
|
/// imports, so a call that would need a page fails loudly instead of with a
|
|
/// ReferenceError.
|
|
const EXT_CORE_LOADER: &str = r#"const __impeccableDom = new Proxy({}, {
|
|
get() { throw new Error('[impeccable] the offscreen document has no live page; load a snapshot first'); },
|
|
});
|
|
async function __impeccableLoadCore() {
|
|
await wasm_bindgen({ module_or_path: chrome.runtime.getURL('detector/core_bg.wasm') });
|
|
return wasm_bindgen;
|
|
}
|
|
"#;
|
|
|
|
/// The capture contract: the property and state lists in `15-snapshot.js`
|
|
/// must equal the core's (`STYLE_PROPS`, `PSEUDO_PROPS` in snapshot.rs;
|
|
/// `STATE_PSEUDOS` in selector.rs), or a rule reads a column the capture did
|
|
/// not write. Returns the mismatch report on drift.
|
|
pub fn check_capture_contract() -> Result<(), String> {
|
|
let snapshot_js = page_js("15-snapshot.js").expect("15-snapshot.js embedded");
|
|
fn js_list(src: &str, name: &str) -> Vec<String> {
|
|
let start = src
|
|
.find(&format!("const {name} = ["))
|
|
.unwrap_or_else(|| panic!("15-snapshot.js: {name} not found"));
|
|
let rest = &src[start..];
|
|
let end = rest.find("];").expect("list end");
|
|
rest[..end]
|
|
.split('"')
|
|
.skip(1)
|
|
.step_by(2)
|
|
.map(|s| s.to_string())
|
|
.collect()
|
|
}
|
|
let pairs: [(&str, Vec<String>); 3] = [
|
|
("__SNAP_STYLE_PROPS", impeccable_core::browser::snapshot::STYLE_PROPS.iter().map(|s| s.to_string()).collect()),
|
|
("__SNAP_PSEUDO_PROPS", impeccable_core::browser::snapshot::PSEUDO_PROPS.iter().map(|s| s.to_string()).collect()),
|
|
("__SNAP_STATE_PSEUDOS", impeccable_core::browser::selector::STATE_PSEUDOS.iter().map(|s| s.to_string()).collect()),
|
|
];
|
|
for (name, want) in pairs {
|
|
let have = js_list(snapshot_js, name);
|
|
if have != want {
|
|
return Err(format!(
|
|
"browser-bundle/15-snapshot.js {name} differs from the core's list\n js: {have:?}\n core: {want:?}"
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `antipatterns.json`: `{ id, name, category, description }` per rule, in
|
|
/// registry order (built-ins first, then any installed rule pack's rows), as
|
|
/// 2-space JSON with a trailing newline. This is the shape `bun run build`
|
|
/// and the extension panel read.
|
|
pub fn registry_json() -> String {
|
|
let rows: Vec<serde_json::Value> = impeccable_core::registry::all_antipatterns()
|
|
.map(|ap| {
|
|
serde_json::json!({
|
|
"id": ap.id,
|
|
"name": ap.name,
|
|
"category": ap.category,
|
|
"description": ap.description,
|
|
})
|
|
})
|
|
.collect();
|
|
let mut s = serde_json::to_string_pretty(&rows).expect("registry json");
|
|
s.push('\n');
|
|
s
|
|
}
|
|
|
|
/// Build the wasm module for `crate_dir` and read back the wasm-bindgen glue
|
|
/// and the `.wasm`:
|
|
///
|
|
/// ```text
|
|
/// wasm-pack build <crate_dir> --target no-modules --release \
|
|
/// --no-typescript --no-pack --out-dir <out_dir> --out-name impeccable
|
|
/// ```
|
|
///
|
|
/// `extra_cargo_args` are passed to cargo after `--` (the workspace uses this
|
|
/// for `--features pure-exports`). `WASM_PACK` names the binary when it is
|
|
/// not on PATH; `IMPECCABLE_BUNDLE_SKIP_WASM_PACK=1` (or the older
|
|
/// `IMPECCABLE_XTASK_SKIP_WASM_PACK=1`) reuses whatever `out_dir` already
|
|
/// holds, for iterating on the page JS alone.
|
|
///
|
|
/// A downstream crate passes its own crate dir, so the module it gets back is
|
|
/// the engine plus its rule pack.
|
|
pub fn wasm_pack_build(
|
|
crate_dir: &Path,
|
|
out_dir: &Path,
|
|
extra_cargo_args: &[&str],
|
|
) -> Result<(String, Vec<u8>), String> {
|
|
let skip = std::env::var_os("IMPECCABLE_BUNDLE_SKIP_WASM_PACK").is_some()
|
|
|| std::env::var_os("IMPECCABLE_XTASK_SKIP_WASM_PACK").is_some();
|
|
if !skip {
|
|
let wasm_pack = std::env::var("WASM_PACK").unwrap_or_else(|_| "wasm-pack".to_string());
|
|
let mut cmd = Command::new(&wasm_pack);
|
|
cmd.arg("build")
|
|
.arg(crate_dir)
|
|
.arg("--target")
|
|
.arg("no-modules")
|
|
.arg("--release")
|
|
.arg("--no-typescript")
|
|
.arg("--no-pack")
|
|
.arg("--out-dir")
|
|
.arg(out_dir)
|
|
.arg("--out-name")
|
|
.arg("impeccable")
|
|
// Size profile for the module; native builds keep opt-level 3.
|
|
.env("CARGO_PROFILE_RELEASE_OPT_LEVEL", "z")
|
|
// wasm-pack refuses to build a `cdylib` whose Cargo.toml lives in
|
|
// a workspace with `[profile.*]` overrides only when it cannot
|
|
// find the target dir; keep it explicit.
|
|
.env("CARGO_TARGET_DIR", target_dir_for(crate_dir));
|
|
if !extra_cargo_args.is_empty() {
|
|
cmd.arg("--");
|
|
for arg in extra_cargo_args {
|
|
cmd.arg(arg);
|
|
}
|
|
}
|
|
let status = cmd
|
|
.status()
|
|
.map_err(|e| format!("wasm-pack build: failed to spawn {wasm_pack}: {e}"))?;
|
|
if !status.success() {
|
|
return Err(format!("wasm-pack build failed ({status})"));
|
|
}
|
|
}
|
|
let glue_path = out_dir.join("impeccable.js");
|
|
let wasm_path = out_dir.join("impeccable_bg.wasm");
|
|
let glue = std::fs::read_to_string(&glue_path)
|
|
.map_err(|e| format!("{}: {e}", glue_path.display()))?;
|
|
let wasm = std::fs::read(&wasm_path).map_err(|e| format!("{}: {e}", wasm_path.display()))?;
|
|
Ok((glue, wasm))
|
|
}
|
|
|
|
/// `CARGO_TARGET_DIR` for a wasm-pack run: the caller's, else the target dir
|
|
/// of the workspace `crate_dir` sits in (nearest ancestor with a
|
|
/// `Cargo.lock`), else the crate's own.
|
|
fn target_dir_for(crate_dir: &Path) -> PathBuf {
|
|
if let Some(dir) = std::env::var_os("CARGO_TARGET_DIR") {
|
|
return PathBuf::from(dir);
|
|
}
|
|
let abs = std::fs::canonicalize(crate_dir).unwrap_or_else(|_| crate_dir.to_path_buf());
|
|
abs.ancestors()
|
|
.find(|dir| dir.join("Cargo.lock").exists())
|
|
.unwrap_or(&abs)
|
|
.join("target")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn bundle_dir() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../browser-bundle")
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_files_match_the_directory() {
|
|
let mut on_disk: Vec<String> = std::fs::read_dir(bundle_dir())
|
|
.expect("browser-bundle/")
|
|
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
|
.filter(|name| name.ends_with(".js"))
|
|
.collect();
|
|
on_disk.sort();
|
|
let mut embedded: Vec<String> = PAGE_JS.iter().map(|(n, _)| n.to_string()).collect();
|
|
embedded.sort();
|
|
assert_eq!(
|
|
embedded, on_disk,
|
|
"browser-bundle/*.js changed: update PAGE_JS (and ORDER, if the file belongs in the page bundle)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embedded_sources_match_the_files_on_disk() {
|
|
for (name, src) in PAGE_JS {
|
|
let disk = std::fs::read_to_string(bundle_dir().join(name)).expect(name);
|
|
assert_eq!(*src, disk, "{name} drifted from disk");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn in_page_bundle_has_the_expected_skeleton() {
|
|
let out = in_page_bundle("const glue = 1;\n", &[0, 97, 115, 109]);
|
|
assert!(out.starts_with(page_js("00-header.js").unwrap()));
|
|
assert!(out.ends_with(page_js("99-footer.js").unwrap()));
|
|
assert!(out.contains("// --- wasm-bindgen glue (generated by cargo xtask bundle) ---\nconst glue = 1;\n"));
|
|
assert!(out.contains("const __IMPECCABLE_WASM_BYTES = 4;\n"));
|
|
// base64 of the four-byte wasm preamble.
|
|
assert!(out.contains("const __IMPECCABLE_WASM_B64 = \"AGFzbQ==\";\n"));
|
|
assert!(out.contains("wasm_bindgen.initSync({ module: __impeccableWasmBytes() });"));
|
|
// 60-offscreen.js is an extension-only piece.
|
|
assert!(!out.contains(page_js("60-offscreen.js").unwrap()));
|
|
// Order: probe and snapshot before the glue, scan after it.
|
|
let glue_at = out.find("const glue = 1;").unwrap();
|
|
assert!(out.find("__SNAP_STYLE_PROPS").unwrap() < glue_at);
|
|
assert!(out.rfind("createImpeccableOverlay").unwrap() > glue_at);
|
|
}
|
|
|
|
#[test]
|
|
fn extension_pieces_are_iifes_over_their_sources() {
|
|
let ext = extension_pieces("const glue = 1;\n", &[0, 97, 115, 109], "[]\n");
|
|
for (what, js) in [("snapshot", &ext.snapshot_js), ("overlay", &ext.overlay_js), ("core", &ext.core_js)] {
|
|
assert!(js.starts_with("/**\n * Impeccable extension: "), "{what}");
|
|
assert!(js.contains("(function () {\n"), "{what}");
|
|
assert!(js.ends_with("})();\n"), "{what}");
|
|
}
|
|
assert!(ext.snapshot_js.contains("window.__impeccableSnapshot = __impeccableSnapshot;"));
|
|
assert!(ext.overlay_js.contains("window.__impeccableCreateOverlay = createImpeccableOverlay;"));
|
|
assert!(ext.core_js.contains("chrome.runtime.getURL('detector/core_bg.wasm')"));
|
|
// The offscreen core fetches its module; nothing is base64 in there.
|
|
assert!(!ext.core_js.contains("__IMPECCABLE_WASM_B64"));
|
|
assert_eq!(ext.core_bg_wasm, vec![0, 97, 115, 109]);
|
|
assert_eq!(ext.antipatterns_json, "[]\n");
|
|
}
|
|
|
|
#[test]
|
|
fn capture_contract_holds() {
|
|
check_capture_contract().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn registry_json_is_two_space_rows() {
|
|
let json = registry_json();
|
|
assert!(json.ends_with("]\n"));
|
|
assert!(json.contains("\n {\n \"id\": "));
|
|
let rows: Vec<serde_json::Value> = serde_json::from_str(&json).expect("valid json");
|
|
assert_eq!(rows.len(), impeccable_core::registry::all_antipatterns().count());
|
|
for row in &rows {
|
|
for key in ["id", "name", "category", "description"] {
|
|
assert!(row.get(key).is_some(), "row missing {key}: {row}");
|
|
}
|
|
}
|
|
}
|
|
}
|