mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +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
513 lines
16 KiB
Rust
513 lines
16 KiB
Rust
//! JS: live/roots.mjs (resolution half). Root resolution for a live session:
|
|
//! appRoot / repoRoot / contextRoot resolved once, persisted by the boot, and
|
|
//! re-entered by every helper through `enter_live_root`.
|
|
|
|
use crate::manifests::{read_manifest_at, read_pointer_entries, RootsManifest};
|
|
use crate::server;
|
|
use crate::util::{
|
|
exists, inside_or_equal, is_dir, jsp, kill0, read_dir_names_raw, read_dir_raw, read_json,
|
|
rel_fwd, Env,
|
|
};
|
|
use impeccable_common::Io;
|
|
use impeccable_context::context::resolve_project_root;
|
|
use impeccable_context::target_args::TargetOptions;
|
|
use impeccable_context::util::homedir;
|
|
|
|
pub const PRODUCT_NAMES: [&str; 3] = ["PRODUCT.md", "Product.md", "product.md"];
|
|
pub const DESIGN_NAMES: [&str; 3] = ["DESIGN.md", "Design.md", "design.md"];
|
|
const CONTEXT_FALLBACK_DIRS: [&str; 2] = [".agents/context", "docs"];
|
|
|
|
pub const DEV_CONFIG_MARKERS: &[&str] = &[
|
|
"vite.config.js",
|
|
"vite.config.ts",
|
|
"vite.config.mjs",
|
|
"vite.config.mts",
|
|
"vite.config.cjs",
|
|
"svelte.config.js",
|
|
"svelte.config.mjs",
|
|
"svelte.config.ts",
|
|
"next.config.js",
|
|
"next.config.mjs",
|
|
"next.config.ts",
|
|
"astro.config.mjs",
|
|
"astro.config.js",
|
|
"astro.config.ts",
|
|
"astro.config.cjs",
|
|
"nuxt.config.ts",
|
|
"nuxt.config.js",
|
|
"nuxt.config.mjs",
|
|
"remix.config.js",
|
|
"react-router.config.ts",
|
|
"angular.json",
|
|
"webpack.config.js",
|
|
"webpack.config.ts",
|
|
];
|
|
|
|
const CANDIDATE_SCAN_IGNORED: &[&str] = &[
|
|
"node_modules",
|
|
".git",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
"vendor",
|
|
"vendors",
|
|
".next",
|
|
".nuxt",
|
|
".svelte-kit",
|
|
".astro",
|
|
".turbo",
|
|
".cache",
|
|
".vercel",
|
|
];
|
|
const CANDIDATE_SCAN_DEPTH: usize = 2;
|
|
|
|
fn first_existing(dir: &str, names: &[&str]) -> Option<String> {
|
|
for name in names {
|
|
let abs = jsp::join(&[dir, name]);
|
|
if exists(&abs) {
|
|
return Some(abs);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// JS: hasDevConfig(dir)
|
|
pub fn has_dev_config(dir: &str) -> bool {
|
|
if DEV_CONFIG_MARKERS
|
|
.iter()
|
|
.any(|name| exists(&jsp::join(&[dir, name])))
|
|
{
|
|
return true;
|
|
}
|
|
exists(&jsp::join(&[dir, "index.html"])) && exists(&jsp::join(&[dir, "package.json"]))
|
|
}
|
|
|
|
/// JS: isAppRoot(dir)
|
|
pub fn is_app_root(dir: &str) -> bool {
|
|
has_dev_config(dir) || exists(&jsp::join(&[dir, ".impeccable", "live", "config.json"]))
|
|
}
|
|
|
|
/// JS: findContextFile(dir, names)
|
|
pub fn find_context_file(dir: &str, names: &[&str]) -> Option<String> {
|
|
if let Some(direct) = first_existing(dir, names) {
|
|
return Some(direct);
|
|
}
|
|
for rel in CONTEXT_FALLBACK_DIRS {
|
|
if let Some(nested) = first_existing(&jsp::join(&[dir, rel]), names) {
|
|
return Some(nested);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// JS: findGitRoot(startDir)
|
|
pub fn find_git_root(start_dir: &str, env: &Env) -> Option<String> {
|
|
let mut dir = jsp::resolve(start_dir, &[]);
|
|
let home = jsp::resolve(&homedir(env), &[]);
|
|
loop {
|
|
if dir == home {
|
|
return None;
|
|
}
|
|
if exists(&jsp::join(&[&dir, ".git"])) {
|
|
return Some(dir);
|
|
}
|
|
let parent = jsp::dirname(&dir);
|
|
if parent == dir {
|
|
return None;
|
|
}
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
/// JS: walkUp(startDir, upperBound, visit)
|
|
fn walk_up<T>(
|
|
start_dir: &str,
|
|
upper_bound: &str,
|
|
env: &Env,
|
|
mut visit: impl FnMut(&str) -> Option<T>,
|
|
) -> Option<T> {
|
|
let mut dir = jsp::resolve(start_dir, &[]);
|
|
let stop = jsp::resolve(upper_bound, &[]);
|
|
let home = jsp::resolve(&homedir(env), &[]);
|
|
loop {
|
|
if dir == home {
|
|
return None;
|
|
}
|
|
if let Some(hit) = visit(&dir) {
|
|
return Some(hit);
|
|
}
|
|
if dir == stop {
|
|
return None;
|
|
}
|
|
let parent = jsp::dirname(&dir);
|
|
if parent == dir {
|
|
return None;
|
|
}
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
/// JS: discoverAppCandidates(rootDir, depth)
|
|
pub fn discover_app_candidates(root_dir: &str, depth: usize) -> Vec<String> {
|
|
let mut found: Vec<String> = Vec::new();
|
|
fn scan(dir: &str, remaining: usize, found: &mut Vec<String>) {
|
|
let Some(entries) = read_dir_raw(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries {
|
|
if !entry.is_dir {
|
|
continue;
|
|
}
|
|
if entry.name.starts_with('.') || CANDIDATE_SCAN_IGNORED.contains(&entry.name.as_str())
|
|
{
|
|
continue;
|
|
}
|
|
let abs = jsp::join(&[dir, &entry.name]);
|
|
if is_app_root(&abs) {
|
|
found.push(abs);
|
|
continue;
|
|
}
|
|
if remaining > 1 {
|
|
scan(&abs, remaining - 1, found);
|
|
}
|
|
}
|
|
}
|
|
scan(&jsp::resolve(root_dir, &[]), depth, &mut found);
|
|
found.sort();
|
|
found
|
|
}
|
|
|
|
/// JS: `{ name, path }` selection candidates.
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct SelectionCandidate {
|
|
pub name: String,
|
|
pub path: String,
|
|
}
|
|
|
|
pub enum RootsResult {
|
|
Manifest(RootsManifest),
|
|
Selection(Vec<SelectionCandidate>),
|
|
}
|
|
|
|
/// JS: resolveRoots({ cwd, targetPath })
|
|
pub fn resolve_roots(cwd: &str, target_path: Option<&str>, env: &Env) -> RootsResult {
|
|
let abs_cwd = jsp::resolve(cwd, &[]);
|
|
let abs_target: Option<String> = target_path.map(|t| {
|
|
if jsp::is_absolute(t) {
|
|
t.to_string()
|
|
} else {
|
|
jsp::resolve(&abs_cwd, &[t])
|
|
}
|
|
});
|
|
let target_dir = match &abs_target {
|
|
Some(t) => {
|
|
if is_dir(t) {
|
|
t.clone()
|
|
} else {
|
|
jsp::dirname(t)
|
|
}
|
|
}
|
|
None => abs_cwd.clone(),
|
|
};
|
|
|
|
let target_git_root = find_git_root(&target_dir, env);
|
|
let cwd_git_root = if target_git_root.is_some() {
|
|
None
|
|
} else {
|
|
find_git_root(&abs_cwd, env)
|
|
};
|
|
let repo_root: Option<String> = target_git_root.or_else(|| match cwd_git_root {
|
|
Some(r) if inside_or_equal(&target_dir, &r) => Some(r),
|
|
_ => None,
|
|
});
|
|
let upper_bound = repo_root.clone().unwrap_or_else(|| target_dir.clone());
|
|
|
|
let legacy_root = resolve_project_root(
|
|
&abs_cwd,
|
|
&TargetOptions {
|
|
target_path: abs_target.clone(),
|
|
},
|
|
env,
|
|
);
|
|
let marker_bound = if abs_target.is_some()
|
|
&& inside_or_equal(&target_dir, &legacy_root)
|
|
&& inside_or_equal(&legacy_root, &upper_bound)
|
|
{
|
|
legacy_root.clone()
|
|
} else {
|
|
upper_bound.clone()
|
|
};
|
|
|
|
let mut app_root = walk_up(&target_dir, &marker_bound, env, |dir| {
|
|
if is_app_root(dir) {
|
|
Some(dir.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
let mut resolved_from: Option<String> = app_root.as_ref().map(|_| match &abs_target {
|
|
Some(t) => {
|
|
let rel = jsp::relative("/", &abs_cwd, t);
|
|
format!("target:{}", if rel.is_empty() { "." } else { &rel })
|
|
}
|
|
None => "cwd".to_string(),
|
|
});
|
|
|
|
if app_root.is_none() && abs_target.is_none() {
|
|
let candidates = discover_app_candidates(&abs_cwd, CANDIDATE_SCAN_DEPTH);
|
|
if candidates.len() == 1 {
|
|
app_root = Some(candidates[0].clone());
|
|
resolved_from = Some(format!(
|
|
"candidate:{}",
|
|
jsp::relative("/", &abs_cwd, &candidates[0])
|
|
));
|
|
} else if candidates.len() > 1 {
|
|
return RootsResult::Selection(
|
|
candidates
|
|
.iter()
|
|
.map(|abs| SelectionCandidate {
|
|
name: jsp::basename(abs),
|
|
path: rel_fwd(&abs_cwd, abs),
|
|
})
|
|
.collect(),
|
|
);
|
|
}
|
|
}
|
|
|
|
let app_root = match app_root {
|
|
Some(r) => r,
|
|
None => {
|
|
resolved_from = Some("fallback".to_string());
|
|
if inside_or_equal(&target_dir, &legacy_root) {
|
|
legacy_root.clone()
|
|
} else {
|
|
target_dir.clone()
|
|
}
|
|
}
|
|
};
|
|
|
|
let effective_repo_root = match &repo_root {
|
|
Some(r) if inside_or_equal(&app_root, r) => r.clone(),
|
|
_ => app_root.clone(),
|
|
};
|
|
|
|
let product_path = walk_up(&app_root, &effective_repo_root, env, |dir| {
|
|
find_context_file(dir, &PRODUCT_NAMES)
|
|
});
|
|
let design_path = walk_up(&app_root, &effective_repo_root, env, |dir| {
|
|
find_context_file(dir, &DESIGN_NAMES)
|
|
});
|
|
let context_root = product_path
|
|
.as_deref()
|
|
.map(jsp::dirname)
|
|
.or_else(|| design_path.as_deref().map(jsp::dirname));
|
|
|
|
RootsResult::Manifest(RootsManifest {
|
|
version: crate::manifests::ROOTS_MANIFEST_VERSION,
|
|
session_root: jsp::join(&[&app_root, ".impeccable", "live"]),
|
|
app_root,
|
|
repo_root: effective_repo_root,
|
|
context_root,
|
|
product_path,
|
|
design_path,
|
|
resolved_from,
|
|
})
|
|
}
|
|
|
|
/// JS: hasLiveServer(appRoot). The identity probe (authenticated `/status`)
|
|
/// runs natively (see `server::probe_status`) instead of via `node -e`.
|
|
fn has_live_server(app_root: &str) -> bool {
|
|
let Some(info) = read_json(&jsp::join(&[
|
|
app_root,
|
|
".impeccable",
|
|
"live",
|
|
"server.json",
|
|
])) else {
|
|
return false;
|
|
};
|
|
let Some(pid) = info.get("pid").and_then(|p| p.as_i64()) else {
|
|
return false;
|
|
};
|
|
if !matches!(info.get("pid"), Some(serde_json::Value::Number(_))) {
|
|
return false;
|
|
}
|
|
let port = crate::util::js_number(info.get("port"));
|
|
let token = info.get("token").and_then(|t| t.as_str());
|
|
match kill0(pid) {
|
|
Ok(()) => {}
|
|
Err("EPERM") => {}
|
|
Err(_) => return false,
|
|
}
|
|
match (port, token) {
|
|
(Some(p), Some(t)) if p.fract() == 0.0 && p > 0.0 => {
|
|
server::probe_status(p as u16, t, 1200)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
const TERMINAL_SESSION_PHASES: [&str; 2] = ["completed", "discarded"];
|
|
|
|
/// JS: hasActiveDurableSession(appRoot)
|
|
fn has_active_durable_session(app_root: &str) -> bool {
|
|
let dir = jsp::join(&[app_root, ".impeccable", "live", "sessions"]);
|
|
let Some(entries) = read_dir_names_raw(&dir) else {
|
|
return false;
|
|
};
|
|
for name in entries {
|
|
if !name.ends_with(".snapshot.json") {
|
|
continue;
|
|
}
|
|
if let Some(snap) = read_json(&jsp::join(&[&dir, &name])) {
|
|
if let Some(phase) = snap.get("phase").and_then(|p| p.as_str()) {
|
|
if !phase.is_empty() && !TERMINAL_SESSION_PHASES.contains(&phase) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
pub struct LiveRootsResolution {
|
|
pub manifest: Option<RootsManifest>,
|
|
pub selection: Option<Vec<SelectionCandidate>>,
|
|
pub source: &'static str,
|
|
}
|
|
|
|
/// JS: resolveLiveRoots(cwd, { targetPath }). Writes the multi-app warning
|
|
/// to `io.stderr`.
|
|
pub fn resolve_live_roots(
|
|
cwd: &str,
|
|
target_path: Option<&str>,
|
|
io: &mut Io,
|
|
) -> LiveRootsResolution {
|
|
let abs_cwd = jsp::resolve(cwd, &[]);
|
|
let env = io.env.clone();
|
|
if target_path.is_none() {
|
|
let bound = find_git_root(&abs_cwd, &env).unwrap_or_else(|| abs_cwd.clone());
|
|
if let Some(persisted) = walk_up(&abs_cwd, &bound, &env, read_manifest_at) {
|
|
return LiveRootsResolution {
|
|
manifest: Some(persisted),
|
|
selection: None,
|
|
source: "persisted",
|
|
};
|
|
}
|
|
if let Some(git_root) = find_git_root(&abs_cwd, &env) {
|
|
let candidates: Vec<RootsManifest> = read_pointer_entries(&git_root)
|
|
.iter()
|
|
.filter_map(|e| read_manifest_at(&e.app_root))
|
|
.collect();
|
|
if !candidates.is_empty() {
|
|
let live_apps: Vec<RootsManifest> = candidates
|
|
.iter()
|
|
.filter(|m| has_live_server(&m.app_root))
|
|
.cloned()
|
|
.collect();
|
|
let recovering: Vec<RootsManifest> = if !live_apps.is_empty() {
|
|
live_apps
|
|
} else {
|
|
candidates
|
|
.iter()
|
|
.filter(|m| has_active_durable_session(&m.app_root))
|
|
.cloned()
|
|
.collect()
|
|
};
|
|
let tier = if !recovering.is_empty() {
|
|
recovering
|
|
} else {
|
|
candidates
|
|
};
|
|
if tier.len() > 1 {
|
|
let chosen = &tier[0].app_root;
|
|
let others: Vec<&str> = tier[1..].iter().map(|m| m.app_root.as_str()).collect();
|
|
io.err(&format!(
|
|
"[impeccable live] Multiple apps in this repo have live state; using {}. Other candidate(s): {}. Run from the app directory (or pass --target) to address a specific app.\n",
|
|
chosen,
|
|
others.join(", ")
|
|
));
|
|
}
|
|
return LiveRootsResolution {
|
|
manifest: Some(tier[0].clone()),
|
|
selection: None,
|
|
source: "pointer",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
match resolve_roots(&abs_cwd, target_path, &env) {
|
|
RootsResult::Selection(c) => LiveRootsResolution {
|
|
manifest: None,
|
|
selection: Some(c),
|
|
source: "fresh",
|
|
},
|
|
RootsResult::Manifest(m) => LiveRootsResolution {
|
|
manifest: Some(m),
|
|
selection: None,
|
|
source: "fresh",
|
|
},
|
|
}
|
|
}
|
|
|
|
pub const TARGET_ARG_ERROR: &str =
|
|
"--target requires a path value (use --target <path> or --target=<path>)";
|
|
|
|
/// JS: consumeTargetArg(argv). Removes the pair from `argv`.
|
|
pub fn consume_target_arg(argv: &mut Vec<String>) -> Result<Option<String>, String> {
|
|
let mut i = 0;
|
|
while i < argv.len() {
|
|
let arg = argv[i].clone();
|
|
if arg == "--target" {
|
|
let value = argv.get(i + 1).cloned();
|
|
match value {
|
|
Some(v) if !v.is_empty() && !v.starts_with("--") => {
|
|
argv.drain(i..i + 2);
|
|
return Ok(Some(v));
|
|
}
|
|
_ => return Err(TARGET_ARG_ERROR.to_string()),
|
|
}
|
|
}
|
|
if let Some(v) = arg.strip_prefix("--target=") {
|
|
if v.is_empty() {
|
|
return Err(TARGET_ARG_ERROR.to_string());
|
|
}
|
|
let v = v.to_string();
|
|
argv.remove(i);
|
|
return Ok(Some(v));
|
|
}
|
|
i += 1;
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// JS: enterLiveRoot(cwd). Consumes `--target` from `args`, resolves the
|
|
/// governing roots, and moves `io.cwd` onto the appRoot. `Err(code)` means
|
|
/// the process must exit with that code (the message is already on stderr);
|
|
/// `Ok(None)` is the selection-ambiguity case (stay put).
|
|
pub fn enter_live_root(args: &mut Vec<String>, io: &mut Io) -> Result<Option<RootsManifest>, i32> {
|
|
let target = match consume_target_arg(args) {
|
|
Ok(t) => t,
|
|
Err(msg) => {
|
|
io.err(&format!("[impeccable live] {}\n", msg));
|
|
return Err(1);
|
|
}
|
|
};
|
|
let cwd = io.cwd.to_string_lossy().into_owned();
|
|
let resolved = resolve_live_roots(&cwd, target.as_deref(), io);
|
|
let Some(manifest) = resolved.manifest else {
|
|
return Ok(None);
|
|
};
|
|
let app_root = manifest.app_root.clone();
|
|
if jsp::resolve(&cwd, &[]) != jsp::resolve(&app_root, &[]) {
|
|
if !is_dir(&app_root) {
|
|
io.err(&format!(
|
|
"[impeccable live] resolved app root does not exist: {} (stale roots manifest? re-run the live boot, or pass --target <path>)\n",
|
|
app_root
|
|
));
|
|
return Err(1);
|
|
}
|
|
io.cwd = std::path::PathBuf::from(&app_root);
|
|
}
|
|
Ok(Some(manifest))
|
|
}
|