Files
pbakaus_impeccable/crates/live/src/live_boot.rs
T
Paul BakausandClaude Fable 5.1 0547ed6a63 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
2026-09-01 15:31:26 -07:00

386 lines
14 KiB
Rust

//! JS: live.mjs -> `impeccable live` (boot). Resolve roots, gate on context,
//! persist the roots manifest, check config, start/reuse the helper server,
//! inject, scan for config drift, print the boot payload.
use crate::config::{glob_to_regex, resolve_files, LiveConfig};
use crate::instructions::boot_instructions;
use crate::live_target::resolve_live_target;
use crate::manifests::write_roots_manifest;
use crate::paths::read_live_server_info;
use crate::roots::{resolve_roots, RootsResult};
use crate::server;
use crate::util::{
exists, is_dir, json_compact, json_pretty, jsp, pid_reachable, println, read_dir_raw, safe_read,
};
use impeccable_common::Io;
use impeccable_context::context::resolve_target_selection;
use impeccable_context::surface_briefs::resolve_surface_brief;
use serde_json::{json, Map, Value};
const HELP: &str = "Usage: impeccable live
Prepare everything for live variant mode in a single command:
- Checks .impeccable/live/config.json (required, created once per project)
- Starts (or reuses) the live server in the background
- Injects the browser script tag
- Reads PRODUCT.md / DESIGN.md for project context
- Prepares the harness-native foreground/background poll loop
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
On success, prints a JSON blob with:
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
On target_selection_required, prints:
{ ok: false, error: \"target_selection_required\", targetCandidates }
On config_missing, prints:
{ ok: false, error: \"config_missing\", configPath, hint }
The agent should then:
1. If target_selection_required, ask which app to use and rerun from that child cwd
2. If config_missing, create the config and re-run this script
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
4. Enter the poll loop: impeccable live-poll";
fn rel_or_null(base: &str, p: Option<&str>) -> Value {
match p {
Some(p) => json!(jsp::relative("/", base, p)),
None => Value::Null,
}
}
/// JS: safeRead(p): file text or null (empty text is falsy in the gate).
fn read_nonempty(p: Option<&str>) -> Option<String> {
p.and_then(safe_read)
}
pub fn run(args: &[String], io: &mut Io) -> i32 {
let cwd = io.cwd.to_string_lossy().into_owned();
let env = io.env.clone();
let live_target = match resolve_live_target(&cwd, args, &env) {
Ok(t) => t,
Err(msg) => {
io.err(&format!("{}\n", msg));
return 1;
}
};
if args.iter().any(|a| a == "--help" || a == "-h") {
println(io, HELP);
return 0;
}
if let Some(sel) =
resolve_target_selection(&live_target.original_cwd, &live_target.target_options, &env)
{
let payload = json!({
"ok": false,
"error": "target_selection_required",
"targetPath": null,
"projectRoot": sel.project_root,
"repoRoot": sel.repo_root,
"targetCandidates": sel.target_candidates,
"hint": "Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.",
});
println(io, &json_pretty(&payload));
return 0;
}
let roots = match resolve_roots(
&live_target.original_cwd,
live_target.absolute_target_path.as_deref(),
&env,
) {
RootsResult::Selection(candidates) => {
let payload = json!({
"ok": false,
"error": "target_selection_required",
"targetCandidates": candidates,
"hint": "Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target <path into that app>.",
});
println(io, &json_pretty(&payload));
return 0;
}
RootsResult::Manifest(m) => m,
};
let active_cwd = roots.app_root.clone();
let output_target_path: Value = live_target
.target_path
.clone()
.map(Value::String)
.unwrap_or(Value::Null);
let product = read_nonempty(roots.product_path.as_deref()).filter(|s| !s.is_empty());
let design = read_nonempty(roots.design_path.as_deref()).filter(|s| !s.is_empty());
let mut missing: Vec<&str> = Vec::new();
if product.is_none() {
missing.push("PRODUCT.md");
}
if design.is_none() {
missing.push("DESIGN.md");
}
if !missing.is_empty() {
let payload = json!({
"ok": false,
"error": "context_missing",
"missing": missing,
"nextCommand": if missing.contains(&"PRODUCT.md") { "init" } else { "document" },
"targetPath": output_target_path,
"projectRoot": roots.app_root,
"repoRoot": roots.repo_root,
"productPath": rel_or_null(&live_target.original_cwd, roots.product_path.as_deref()),
"designPath": rel_or_null(&live_target.original_cwd, roots.design_path.as_deref()),
});
println(io, &json_pretty(&payload));
return 0;
}
write_roots_manifest(&roots);
// 1. Check config (in-process `live-inject --check` with cwd = appRoot).
let check_out = run_inject(&["--check".to_string()], &active_cwd, io);
let check_result: Option<Value> = serde_json::from_str(check_out.trim()).ok();
let check_ok = check_result
.as_ref()
.and_then(|r| r.get("ok"))
.map(crate::inject::detect_utils::truthy)
.unwrap_or(false);
if !check_ok {
let mut out: Map<String, Value> = match check_result {
Some(Value::Object(o)) => o,
Some(other) => {
// JS: `{ ...nonObject }` spreads nothing useful (a string spreads its chars).
let mut m = Map::new();
if let Value::String(s) = other {
for (i, c) in s.chars().enumerate() {
m.insert(i.to_string(), json!(c.to_string()));
}
}
m
}
None => {
let mut m = Map::new();
m.insert("ok".into(), json!(false));
m.insert("error".into(), json!("check_failed"));
m.insert("raw".into(), json!(check_out));
m
}
};
out.insert("targetPath".into(), output_target_path);
out.insert("projectRoot".into(), json!(roots.app_root));
out.insert("repoRoot".into(), json!(roots.repo_root));
println(io, &json_compact(&Value::Object(out)));
return 0;
}
let check_result = check_result.unwrap_or(Value::Null);
// 2. Start (or reuse) the server.
let Some(server_info) = ensure_server_running(&active_cwd, io) else {
println(
io,
&json_compact(&json!({ "ok": false, "error": "server_start_failed" })),
);
return 1;
};
let port_str = match server_info.get("port") {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => "undefined".to_string(),
};
let token_str = match server_info.get("token") {
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => "undefined".to_string(),
};
// 3. Inject at the current port.
let inject_out = run_inject(
&[
"--port".to_string(),
port_str,
"--token".to_string(),
token_str,
],
&active_cwd,
io,
);
let inject_result: Option<Value> = serde_json::from_str(inject_out.trim()).ok();
let inject_ok = inject_result
.as_ref()
.and_then(|r| r.get("ok"))
.map(crate::inject::detect_utils::truthy)
.unwrap_or(false);
if !inject_ok {
let payload = json!({
"ok": false,
"error": "inject_failed",
"detail": inject_result.unwrap_or_else(|| json!(inject_out)),
"serverPort": server_info.get("port").cloned().unwrap_or(Value::Null),
});
println(io, &json_compact(&payload));
return 1;
}
// 4. Drift scan.
let config = LiveConfig {
raw: check_result.get("config").cloned().unwrap_or(Value::Null),
};
let resolved_files = resolve_files(&active_cwd, &config);
let drift = scan_for_drift(&active_cwd, &resolved_files, &config);
// 5. Surface brief.
let mut surface_brief: Option<String> = None;
let mut surface_brief_path: Option<String> = None;
let mut brief_roots: Vec<String> = Vec::new();
for dir in [
Some(roots.app_root.clone()),
roots.context_root.clone(),
Some(roots.repo_root.clone()),
]
.into_iter()
.flatten()
{
if !brief_roots
.iter()
.any(|d| jsp::resolve(d, &[]) == jsp::resolve(&dir, &[]))
{
brief_roots.push(dir);
}
}
for root in &brief_roots {
let resolved = resolve_surface_brief(root, live_target.absolute_target_path.as_deref());
let Some(brief) = resolved.brief else {
continue;
};
surface_brief = Some(brief.text.clone());
surface_brief_path = brief
.path
.as_deref()
.map(|p| jsp::relative("/", &live_target.original_cwd, p));
break;
}
let self_cmd = impeccable_context::provider::detect(&env, &cwd).self_cmd;
let payload = json!({
"ok": true,
"serverPort": server_info.get("port").cloned().unwrap_or(Value::Null),
"serverToken": server_info.get("token").cloned().unwrap_or(Value::Null),
"pageFiles": resolved_files,
"liveConfigPath": check_result.get("path").cloned().unwrap_or(Value::Null),
"configDrift": drift,
"targetPath": output_target_path,
"projectRoot": roots.app_root,
"repoRoot": roots.repo_root,
"roots": roots.to_value(),
"hasProduct": product.is_some(),
"product": product,
"productPath": rel_or_null(&live_target.original_cwd, roots.product_path.as_deref()),
"hasDesign": design.is_some(),
"design": design,
"designPath": rel_or_null(&live_target.original_cwd, roots.design_path.as_deref()),
"hasSurfaceBrief": surface_brief.is_some(),
"surfaceBrief": surface_brief,
"surfaceBriefPath": surface_brief_path,
"_instructions": boot_instructions(&self_cmd),
});
println(io, &json_pretty(&payload));
0
}
/// JS: runScript('live-inject.mjs', args, { cwd }): the inject verb run
/// in-process against `cwd`, its stdout returned (stderr discarded, like
/// `execFileSync` with a non-zero exit returning `err.stdout`).
fn run_inject(args: &[String], cwd: &str, io: &Io) -> String {
let (mut child, captured) = Io::captured("", std::path::PathBuf::from(cwd), io.env.clone());
let _ = crate::live_inject::run(args, &mut child);
let bytes = captured.stdout.borrow().clone();
String::from_utf8_lossy(&bytes).into_owned()
}
/// JS: ensureServerRunning(cwd): reuse a live `server.json` record, else
/// spawn `live-server --background` (part 3) and parse its output.
fn ensure_server_running(cwd: &str, io: &Io) -> Option<Value> {
if let Some((info, _)) = read_live_server_info(cwd, &io.env) {
if let Some(pid) = info.pid {
if pid_reachable(pid) && crate::util::kill0(pid).is_ok() {
return Some(info.raw);
}
}
}
server::spawn_detached(cwd, &io.env)
}
/// JS: scanForDrift(rootDir, resolvedFiles, config)
fn scan_for_drift(root_dir: &str, resolved_files: &[String], config: &LiveConfig) -> Value {
const SCAN_ROOTS: [&str; 4] = ["public", "src", "app", "pages"];
const IGNORE_DIRS: [&str; 12] = [
"node_modules",
".git",
".next",
".nuxt",
".svelte-kit",
".astro",
".turbo",
".vercel",
".cache",
"coverage",
"dist",
"build",
];
let excludes: Vec<regex::Regex> = config.exclude().iter().map(|p| glob_to_regex(p)).collect();
let mut orphans: Vec<String> = Vec::new();
fn walk(
dir: &str,
rel_base: &str,
resolved: &[String],
excludes: &[regex::Regex],
orphans: &mut Vec<String>,
) {
let Some(entries) = read_dir_raw(dir) else {
return;
};
for e in entries {
let rel = if rel_base.is_empty() {
e.name.clone()
} else {
format!("{}/{}", rel_base, e.name)
};
if e.is_dir {
if IGNORE_DIRS.contains(&e.name.as_str()) || e.name.starts_with('.') {
continue;
}
walk(
&jsp::join(&[dir, &e.name]),
&rel,
resolved,
excludes,
orphans,
);
} else if e.is_file && e.name.ends_with(".html") {
if resolved.iter().any(|f| jsp::to_posix(f) == rel) {
continue;
}
if excludes.iter().any(|re| re.is_match(&rel)) {
continue;
}
orphans.push(rel);
}
}
}
for root in SCAN_ROOTS {
let abs = jsp::join(&[root_dir, root]);
if exists(&abs) && is_dir(&abs) {
walk(&abs, root, resolved_files, &excludes, &mut orphans);
}
}
if orphans.is_empty() {
return Value::Null;
}
let count = orphans.len();
let capped: Vec<String> = orphans.into_iter().take(20).collect();
json!({
"orphans": capped,
"orphanCount": count,
"hint": format!("{} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\".", count),
})
}