mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 16:16:32 +03:00
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
226 lines
7.1 KiB
Rust
226 lines
7.1 KiB
Rust
//! JS: live/generation-preflight.mjs, the pure half. The server (part 3)
|
|
//! runs `live-wrap` / `live-insert --defer-source-write` when it leases a
|
|
//! generate event; the argv it builds, the target signature that keys the
|
|
//! source-resolution cache, and the error compaction are shared model and
|
|
//! live here so the wrap/insert verbs (part 2) and the server agree.
|
|
|
|
use serde_json::{json, Map, Value};
|
|
|
|
/// JS: normalizeTarget(target)
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Target {
|
|
pub element_id: Option<String>,
|
|
pub classes: Option<String>,
|
|
pub tag: Option<String>,
|
|
pub text: Option<String>,
|
|
/// insert mode only
|
|
pub position: Option<String>,
|
|
}
|
|
|
|
fn s(v: Option<&Value>) -> Option<String> {
|
|
match v {
|
|
Some(Value::String(x)) if !x.is_empty() => Some(x.clone()),
|
|
Some(Value::Number(n)) => Some(n.to_string()),
|
|
Some(Value::Bool(true)) => Some("true".to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn normalize_target(target: &Map<String, Value>) -> Target {
|
|
let classes = match target.get("classes") {
|
|
Some(Value::Array(a)) => a
|
|
.iter()
|
|
.map(|c| match c {
|
|
Value::String(x) => x.clone(),
|
|
Value::Null => String::new(),
|
|
other => other.to_string(),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" "),
|
|
Some(Value::String(x)) => x.trim().to_string(),
|
|
_ => String::new(),
|
|
};
|
|
let text = match target.get("textContent") {
|
|
Some(Value::String(t)) => t.trim().chars().take(80).collect::<String>(),
|
|
_ => String::new(),
|
|
};
|
|
Target {
|
|
element_id: s(target.get("id")).or_else(|| s(target.get("elementId"))),
|
|
classes: if classes.is_empty() {
|
|
None
|
|
} else {
|
|
Some(classes)
|
|
},
|
|
tag: s(target.get("tagName")).or_else(|| s(target.get("tag"))),
|
|
text: if text.is_empty() { None } else { Some(text) },
|
|
position: None,
|
|
}
|
|
}
|
|
|
|
fn empty() -> Map<String, Value> {
|
|
Map::new()
|
|
}
|
|
|
|
/// JS: replaceTarget / insertTarget
|
|
pub fn event_target(event: &Map<String, Value>) -> (bool, Target) {
|
|
let is_insert = event.get("mode").and_then(|m| m.as_str()) == Some("insert");
|
|
if is_insert {
|
|
let insert = event.get("insert").and_then(|i| i.as_object());
|
|
let anchor = insert
|
|
.and_then(|i| i.get("anchor"))
|
|
.and_then(|a| a.as_object())
|
|
.cloned()
|
|
.unwrap_or_else(empty);
|
|
let mut t = normalize_target(&anchor);
|
|
t.position = Some(
|
|
if insert
|
|
.and_then(|i| i.get("position"))
|
|
.and_then(|p| p.as_str())
|
|
== Some("before")
|
|
{
|
|
"before"
|
|
} else {
|
|
"after"
|
|
}
|
|
.to_string(),
|
|
);
|
|
(true, t)
|
|
} else {
|
|
let el = event
|
|
.get("element")
|
|
.and_then(|e| e.as_object())
|
|
.cloned()
|
|
.unwrap_or_else(empty);
|
|
(false, normalize_target(&el))
|
|
}
|
|
}
|
|
|
|
/// JS: targetSignature(event): the cache key, as its JSON string.
|
|
pub fn target_signature(event: &Map<String, Value>) -> String {
|
|
let (is_insert, t) = event_target(event);
|
|
let page_url = match event.get("pageUrl") {
|
|
Some(v) if crate::inject::detect_utils::truthy(v) => v.clone(),
|
|
_ => Value::Null,
|
|
};
|
|
serde_json::to_string(&json!({
|
|
"mode": if is_insert { "insert" } else { "replace" },
|
|
"position": if is_insert { t.position.clone().map(Value::String).unwrap_or(Value::Null) } else { Value::Null },
|
|
"elementId": t.element_id.clone().map(Value::String).unwrap_or(Value::Null),
|
|
"classes": t.classes.clone().map(Value::String).unwrap_or(Value::Null),
|
|
"tag": t.tag.clone().map(Value::String).unwrap_or(Value::Null),
|
|
"pageUrl": page_url,
|
|
}))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// A built preflight command: the verb (`live-wrap` | `live-insert`), its
|
|
/// args, mode, and cache signature.
|
|
#[derive(Debug, Clone)]
|
|
pub struct PreflightCommand {
|
|
pub verb: &'static str,
|
|
pub args: Vec<String>,
|
|
pub mode: &'static str,
|
|
pub signature: String,
|
|
}
|
|
|
|
/// JS: buildGenerationPreflight(event, scriptsDir, { cache }). None when the
|
|
/// event is not a generate with an id, or carries neither id nor classes
|
|
/// (`insufficient_locator`).
|
|
pub fn build_generation_preflight(
|
|
event: &Map<String, Value>,
|
|
cached_file: Option<&str>,
|
|
) -> Option<PreflightCommand> {
|
|
if event.get("type").and_then(|t| t.as_str()) != Some("generate") {
|
|
return None;
|
|
}
|
|
let id = event
|
|
.get("id")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())?;
|
|
let (is_insert, target) = event_target(event);
|
|
if target.element_id.is_none() && target.classes.is_none() {
|
|
return None;
|
|
}
|
|
let count = match event.get("count") {
|
|
Some(v) if crate::inject::detect_utils::truthy(v) => match v {
|
|
Value::Number(n) => n.to_string(),
|
|
Value::String(s) => s.clone(),
|
|
other => other.to_string(),
|
|
},
|
|
_ => "3".to_string(),
|
|
};
|
|
let mut args = vec![
|
|
"--id".to_string(),
|
|
id.to_string(),
|
|
"--count".to_string(),
|
|
count,
|
|
"--defer-source-write".to_string(),
|
|
];
|
|
if is_insert {
|
|
args.push("--position".to_string());
|
|
args.push(
|
|
target
|
|
.position
|
|
.clone()
|
|
.unwrap_or_else(|| "after".to_string()),
|
|
);
|
|
}
|
|
if let Some(v) = &target.element_id {
|
|
args.push("--element-id".to_string());
|
|
args.push(v.clone());
|
|
}
|
|
if let Some(v) = &target.classes {
|
|
args.push("--classes".to_string());
|
|
args.push(v.clone());
|
|
}
|
|
if let Some(v) = &target.tag {
|
|
args.push("--tag".to_string());
|
|
args.push(v.clone());
|
|
}
|
|
if let Some(v) = &target.text {
|
|
args.push("--text".to_string());
|
|
args.push(v.clone());
|
|
}
|
|
if !is_insert {
|
|
if let Some(u) = event
|
|
.get("pageUrl")
|
|
.filter(|v| crate::inject::detect_utils::truthy(v))
|
|
{
|
|
args.push("--page-url".to_string());
|
|
args.push(match u {
|
|
Value::String(s) => s.clone(),
|
|
other => other.to_string(),
|
|
});
|
|
}
|
|
}
|
|
if let Some(f) = cached_file {
|
|
args.push("--file".to_string());
|
|
args.push(f.to_string());
|
|
}
|
|
Some(PreflightCommand {
|
|
verb: if is_insert {
|
|
"live-insert"
|
|
} else {
|
|
"live-wrap"
|
|
},
|
|
args,
|
|
mode: if is_insert { "insert" } else { "replace" },
|
|
signature: target_signature(event),
|
|
})
|
|
}
|
|
|
|
/// JS: compactError(error): last non-empty stderr line, else the message,
|
|
/// else `preflight failed`, capped at 500 chars.
|
|
pub fn compact_error(stderr: &str, message: Option<&str>) -> String {
|
|
let last = stderr
|
|
.trim()
|
|
.split('\n')
|
|
.filter(|l| !l.is_empty())
|
|
.last()
|
|
.map(String::from);
|
|
let msg = last
|
|
.or_else(|| message.map(String::from))
|
|
.unwrap_or_else(|| "preflight failed".to_string());
|
|
msg.chars().take(500).collect()
|
|
}
|