mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 18:16:30 +03:00
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
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e355ebf714
commit
0547ed6a63
@@ -0,0 +1,243 @@
|
||||
//! The headless-browser side of `impeccable font-match`, wired over
|
||||
//! `crates/browser`'s CDP client. This is the one piece the open
|
||||
//! `impeccable-comp-verbs` crate cannot do on its own; it is injected as a
|
||||
//! `FontRenderer` so the browser (and its `core` dependency) stays out of that
|
||||
//! crate. Ported from `font-match.mjs` `renderCandidates` / `renderProofSheet`,
|
||||
//! which drove Playwright/Puppeteer; here the same steps run over CDP against a
|
||||
//! discovered Chrome (the browser the URL engine already uses).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use impeccable_browser::cdp::{default_chrome_args, Browser, EvalOutcome, Page, Viewport};
|
||||
use impeccable_browser::discovery;
|
||||
use impeccable_comp::font_fingerprint::{fingerprint, FpOpts};
|
||||
use impeccable_comp::png_io;
|
||||
use impeccable_comp::raster::Image;
|
||||
use impeccable_comp_verbs::font_match::{FontRenderer, RankCandidate, RenderedCandidate};
|
||||
|
||||
const NAV_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A renderer that discovers and drives an installed Chrome over CDP.
|
||||
pub struct CdpFontRenderer {
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CdpFontRenderer {
|
||||
pub fn from_process_env() -> Self {
|
||||
CdpFontRenderer { env: std::env::vars().collect() }
|
||||
}
|
||||
|
||||
fn launch(&self) -> Option<Browser> {
|
||||
let exe = discovery::find_browser(&self.env).ok()?;
|
||||
// JS launchArgs: `process.env.CI ? ['--no-sandbox','--disable-setuid-sandbox'] : []`.
|
||||
let mut user_args: Vec<String> = Vec::new();
|
||||
if self.env.get("CI").map(|v| !v.is_empty()).unwrap_or(false) {
|
||||
user_args.push("--no-sandbox".into());
|
||||
user_args.push("--disable-setuid-sandbox".into());
|
||||
}
|
||||
let dangerous = self.env.get("PUPPETEER_DANGEROUS_NO_SANDBOX").map(String::as_str) == Some("true");
|
||||
let _ = default_chrome_args(&user_args, dangerous); // parity: same flag set the URL engine uses
|
||||
Browser::launch(&exe, &user_args, dangerous).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn data_url(html: &str) -> String {
|
||||
format!("data:text/html;base64,{}", b64(html.as_bytes()))
|
||||
}
|
||||
|
||||
/// encodeURIComponent(fam).replace(/%20/g,'+') for the Google Fonts css2 URL.
|
||||
fn encode_family(fam: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for ch in fam.chars() {
|
||||
if ch == ' ' {
|
||||
out.push('+');
|
||||
} else if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '!' | '~' | '*' | '\'' | '(' | ')') {
|
||||
out.push(ch);
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
for byte in ch.encode_utf8(&mut buf).bytes() {
|
||||
out.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn wfmt(w: f64) -> String {
|
||||
(w as i64).to_string()
|
||||
}
|
||||
|
||||
fn js_string(s: &str) -> String {
|
||||
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
|
||||
}
|
||||
|
||||
fn links_html(candidates: &[RankCandidate]) -> String {
|
||||
candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family={}:wght@{}&display=block\">",
|
||||
encode_family(&c.family),
|
||||
wfmt(c.weight)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn eval_bool(page: &mut Page<'_>, expr: &str) -> bool {
|
||||
match page.evaluate(expr) {
|
||||
Ok(EvalOutcome::Value(v)) => v.as_bool().unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_value(page: &mut Page<'_>, expr: &str) -> Option<serde_json::Value> {
|
||||
match page.evaluate(expr) {
|
||||
Ok(EvalOutcome::Value(v)) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl FontRenderer for CdpFontRenderer {
|
||||
fn render_candidates(
|
||||
&mut self,
|
||||
candidates: &[RankCandidate],
|
||||
text: &str,
|
||||
target_cap_px: f64,
|
||||
transform: &str,
|
||||
) -> Option<Vec<RenderedCandidate>> {
|
||||
let mut browser = self.launch()?;
|
||||
let links = links_html(candidates);
|
||||
let html = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\">{links}<style>body{{margin:0;background:#fff}}div.s{{position:absolute;left:0;top:0;white-space:nowrap;color:#000;line-height:1;padding:8px;text-transform:{transform}}}</style></head><body></body></html>"
|
||||
);
|
||||
let size0 = 12f64.max((target_cap_px * 1.4).round());
|
||||
let mut results: Vec<RenderedCandidate> = Vec::new();
|
||||
let outcome = (|| -> Option<()> {
|
||||
let mut page = browser.new_page().ok()?;
|
||||
page.set_viewport(Viewport { width: 1600, height: 400 }).ok()?;
|
||||
page.goto(&data_url(&html), "load", NAV_TIMEOUT).ok()?;
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
for c in candidates {
|
||||
let mut size = size0;
|
||||
let mut fp = None;
|
||||
let mut ok = true;
|
||||
for pass in 0..2 {
|
||||
let div = format!(
|
||||
"<div class=\"s\" style=\"font-family:'{}',sans-serif;font-weight:{};font-size:{}px\">{}</div>",
|
||||
c.family,
|
||||
wfmt(c.weight),
|
||||
size as i64,
|
||||
text
|
||||
);
|
||||
let set = format!("(() => {{ document.body.innerHTML = {}; }})()", js_string(&div));
|
||||
let _ = page.evaluate(&set);
|
||||
// Loaded means a real face of this family covers the weight.
|
||||
let check = format!(
|
||||
"(async () => {{ const f = {{ family: {}, weight: {} }}; const faces = await document.fonts.load(f.weight + \" 32px '\" + f.family + \"'\"); await document.fonts.ready; const covers = (face) => {{ const w = String(face.weight || '400').split(/\\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; }}; return faces.some((face) => face.family.replace(/[\"']/g, '') === f.family && face.status === 'loaded' && covers(face)); }})()",
|
||||
js_string(&c.family),
|
||||
wfmt(c.weight)
|
||||
);
|
||||
let loaded = eval_bool(&mut page, &check);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
if !loaded {
|
||||
ok = false;
|
||||
}
|
||||
let box_v = eval_value(&mut page, "(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; })()");
|
||||
let (bw, bh) = box_v
|
||||
.as_ref()
|
||||
.map(|v| (v.get("w").and_then(|x| x.as_f64()).unwrap_or(0.0), v.get("h").and_then(|x| x.as_f64()).unwrap_or(0.0)))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
let clip_w = 1600f64.min(bw);
|
||||
let clip_h = 400f64.min(bh);
|
||||
let shot = page.screenshot_clip(0.0, 0.0, clip_w, clip_h).ok()?;
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(shot.as_bytes()).ok()?;
|
||||
fp = png_io::decode_png(&png).ok().and_then(|d| fingerprint(&d.image, &FpOpts::default()));
|
||||
if fp.is_none() || pass == 1 {
|
||||
break;
|
||||
}
|
||||
let cap = fp.as_ref().unwrap().cap_height_px;
|
||||
size = 8f64.max((size * (target_cap_px / cap)).round());
|
||||
}
|
||||
results.push(RenderedCandidate {
|
||||
family: c.family.clone(),
|
||||
weight: c.weight,
|
||||
loaded: ok,
|
||||
font_size_px: size as i64,
|
||||
fp,
|
||||
});
|
||||
}
|
||||
page.close();
|
||||
Some(())
|
||||
})();
|
||||
browser.close();
|
||||
outcome.map(|_| results)
|
||||
}
|
||||
|
||||
fn render_proof_sheet(
|
||||
&mut self,
|
||||
comp_crop: &Image,
|
||||
top: &[RenderedCandidate],
|
||||
text: &str,
|
||||
_cap_px: f64,
|
||||
transform: &str,
|
||||
) -> Option<Vec<u8>> {
|
||||
let comp_png = png_io::encode_png(comp_crop, &[]).ok()?;
|
||||
let comp_b64 = b64(&comp_png);
|
||||
let links: String = top
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family={}:wght@{}&display=block\">",
|
||||
encode_family(&c.family),
|
||||
wfmt(c.weight)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let rows: String = top
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"<div class=\"row\"><div class=\"lab\">{} {} · {}px</div><div class=\"s\" style=\"font-family:'{}';font-weight:{};font-size:{}px;text-transform:{}\">{}</div></div>",
|
||||
&c.family,
|
||||
wfmt(c.weight),
|
||||
c.font_size_px,
|
||||
c.family,
|
||||
wfmt(c.weight),
|
||||
c.font_size_px,
|
||||
transform,
|
||||
text
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let html = format!(
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\">{links}<style>body{{margin:0;background:#fff;padding:12px;font-family:system-ui}}img{{display:block;max-width:100%}}.lab{{font:12px system-ui;color:#666;margin:10px 0 2px}}.s{{white-space:nowrap;line-height:1.05;color:#111}}</style></head><body><div class=\"lab\">COMP</div><img src=\"data:image/png;base64,{comp_b64}\">{rows}</body></html>"
|
||||
);
|
||||
let mut browser = self.launch()?;
|
||||
let vw = 1600u32.min(600u32.max(comp_crop.width as u32 + 24));
|
||||
let outcome = (|| -> Option<Vec<u8>> {
|
||||
let mut page = browser.new_page().ok()?;
|
||||
page.set_viewport(Viewport { width: vw, height: 200 }).ok()?;
|
||||
page.goto(&data_url(&html), "load", NAV_TIMEOUT).ok()?;
|
||||
let _ = page.evaluate("(async () => { await document.fonts.ready; })()");
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
let size = eval_value(&mut page, "(() => ({ w: Math.ceil(document.documentElement.scrollWidth), h: Math.ceil(document.documentElement.scrollHeight) }))()");
|
||||
let (w, h) = size
|
||||
.as_ref()
|
||||
.map(|v| (v.get("w").and_then(|x| x.as_f64()).unwrap_or(vw as f64), v.get("h").and_then(|x| x.as_f64()).unwrap_or(200.0)))
|
||||
.unwrap_or((vw as f64, 200.0));
|
||||
let shot = page.screenshot_clip(0.0, 0.0, w, h).ok()?;
|
||||
let png = base64::engine::general_purpose::STANDARD.decode(shot.as_bytes()).ok()?;
|
||||
page.close();
|
||||
Some(png)
|
||||
})();
|
||||
browser.close();
|
||||
outcome
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! `impeccable` binary: verb router.
|
||||
//!
|
||||
//! Every skill script and CLI subcommand is a verb here. Verb crates expose
|
||||
//! `run(args: &[String], io: &mut Io) -> i32` (exit code) and never call
|
||||
//! `std::process::exit` themselves, so this file is the single place exit codes
|
||||
//! and stream flushing are decided (contract: docs/CLI-CONTRACT.md in the
|
||||
//! public repo). Verb names are the JS script basenames; a few carry aliases
|
||||
//! (`signals` for context-signals, `hooks` for hook-admin).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use impeccable_common::Io;
|
||||
|
||||
mod font_render;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut io = Io::stdio();
|
||||
let code = run(&args, &mut io);
|
||||
let _ = io.stdout.flush();
|
||||
let _ = io.stderr.flush();
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
// cli/bin/cli.js dispatch: help / version / detect / ignores / skills verbs
|
||||
let Some(verb) = args.first().map(String::as_str) else {
|
||||
io.out(impeccable_detect::ROOT_USAGE);
|
||||
return 0;
|
||||
};
|
||||
let rest = &args[1..];
|
||||
match verb {
|
||||
"--help" | "-h" => {
|
||||
io.out(impeccable_detect::ROOT_USAGE);
|
||||
0
|
||||
}
|
||||
"--version" | "-v" => {
|
||||
io.out(&format!("{CLI_VERSION}\n"));
|
||||
0
|
||||
}
|
||||
// Launcher handshake: a cheap discriminator so the launchers can tell
|
||||
// this engine apart from the retired 3.x npm CLI (which answers any
|
||||
// unknown verb with `Unknown command`, exit 1) before exec'ing a
|
||||
// candidate found on PATH or in the unversioned user cache. Kept out
|
||||
// of --help on purpose; not part of the user-facing contract.
|
||||
"engine-probe" => {
|
||||
io.out(&format!("impeccable-engine {VERSION}\n"));
|
||||
0
|
||||
}
|
||||
"detect" => impeccable_detect::run_detect(rest, io, &engines()),
|
||||
"ignores" | "ignore" => impeccable_detect::run_ignores(rest, io),
|
||||
"skills" => impeccable_skills::run(rest, io),
|
||||
"help" | "install" | "link" | "update" | "check" => impeccable_skills::run(args, io),
|
||||
// skill scripts
|
||||
"context" => impeccable_context::run_context(rest, io),
|
||||
"pin" => impeccable_context::run_pin(rest, io),
|
||||
"detect-csp" => impeccable_context::run_detect_csp(rest, io),
|
||||
"palette" => impeccable_context::run_palette(rest, io),
|
||||
"surface-brief" => impeccable_context::run_surface_brief(rest, io),
|
||||
"critique-storage" => impeccable_context::run_critique_storage(rest, io),
|
||||
"embed-prompt" => impeccable_context::run_embed_prompt(rest, io),
|
||||
"signals" | "context-signals" => impeccable_context::run_signals(rest, io),
|
||||
"doctor" => impeccable_context::run_doctor(rest, io),
|
||||
"concept-seed" => impeccable_context::run_concept_seed(rest, io),
|
||||
"generate-image" => impeccable_context::run_generate_image(rest, io),
|
||||
"serve-question" => impeccable_context::run_serve_question(rest, io),
|
||||
// comp-fidelity verbs (crates/comp-verbs over crates/comp)
|
||||
"comp-spec" => impeccable_comp_verbs::run_comp_spec(rest, io),
|
||||
"comp-diff" => impeccable_comp_verbs::run_comp_diff(rest, io),
|
||||
"font-match" => {
|
||||
let mut renderer = font_render::CdpFontRenderer::from_process_env();
|
||||
impeccable_comp_verbs::run_font_match(rest, io, &mut renderer)
|
||||
}
|
||||
"build-phase" => {
|
||||
// Inject the organic-clip-path CSS scanner (a rule that lives in the
|
||||
// closed `core` crate) so comp-verbs stays core-free.
|
||||
let organic = |html: &str| -> Vec<(Option<String>, String)> {
|
||||
impeccable_core::checks::css_scan::scan_css_text_for_organic_clip_path(html)
|
||||
.into_iter()
|
||||
.map(|f| (f.selector, f.snippet))
|
||||
.collect()
|
||||
};
|
||||
impeccable_comp_verbs::run_build_phase(rest, io, &organic)
|
||||
}
|
||||
"hook" => impeccable_hook::run_hook(rest, io, engines().html),
|
||||
"hook-before-edit" => impeccable_hook::run_hook_before_edit(rest, io, engines().html),
|
||||
"hooks" | "hook-admin" => impeccable_hook::run_hook_admin(rest, io),
|
||||
v if v.starts_with("live") => impeccable_live::run(v, rest, io),
|
||||
// `npx impeccable src/` shorthand: a path-shaped, flag, URL, or existing
|
||||
// first arg is a detect target (cli.js looksLikeDetectTarget).
|
||||
v if impeccable_detect::looks_like_detect_target(v, &io.cwd.to_string_lossy()) => {
|
||||
impeccable_detect::run_detect(args, io, &engines())
|
||||
}
|
||||
"init" => {
|
||||
io.err(impeccable_detect::INIT_MESSAGE);
|
||||
1
|
||||
}
|
||||
other => {
|
||||
io.err(&format!(
|
||||
"Unknown command: \"{other}\"\n\nTo see a list of supported commands, run:\n impeccable --help\n"
|
||||
));
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The npm `impeccable` package version `cli.js --version` prints (its
|
||||
/// `package.json`), tracked separately from the crate version.
|
||||
pub const CLI_VERSION: &str = "3.6.0";
|
||||
|
||||
/// The engines wired into `impeccable detect`: the static HTML engine
|
||||
/// (crates/html). The browser engine (crates/browser) plugs in here once it
|
||||
/// lands; until then URL scans report the puppeteer message.
|
||||
fn engines() -> impeccable_detect::Engines<'static> {
|
||||
static HTML: impeccable_html::StaticHtmlEngine = impeccable_html::StaticHtmlEngine;
|
||||
impeccable_detect::Engines {
|
||||
html: &HTML,
|
||||
url: Some(url_engine()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- browser engine (crates/browser) -------------------------------------
|
||||
/// The URL engine, built once from the process environment (browser
|
||||
/// discovery reads `IMPECCABLE_BROWSER` / `PUPPETEER_EXECUTABLE_PATH` /
|
||||
/// `CHROME_PATH`, sandbox flags read `CI`).
|
||||
fn url_engine() -> &'static impeccable_browser::BrowserEngine {
|
||||
static ENGINE: std::sync::OnceLock<impeccable_browser::BrowserEngine> =
|
||||
std::sync::OnceLock::new();
|
||||
ENGINE.get_or_init(impeccable_browser::BrowserEngine::from_process_env)
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
Reference in New Issue
Block a user