use std::collections::BTreeSet;
use std::path::Path;
use keel_core_api::policy::Policy;
use serde::Serialize;
use crate::diff::{PolicyOp, PolicyPath, Proposal, propose, resolve_dotted_path};
use crate::render::to_json;
use crate::scan::{ScanResult, TransportClass};
use crate::{EXIT_OK, EXIT_USAGE, Rendered, agents_cli, evidence, scan};
#[derive(Debug, Clone, Copy, Serialize)]
struct Adapter {
best_effort: bool,
lang: &'static str,
lib: &'static str,
target: &'static str,
}
const REGISTRY: &[Adapter] = &[
Adapter {
lib: "httpx",
lang: "python",
target: "host",
best_effort: false,
},
Adapter {
lib: "requests",
lang: "python",
target: "host",
best_effort: false,
},
Adapter {
lib: "aiohttp",
lang: "python",
target: "host",
best_effort: true,
},
Adapter {
lib: "urllib3",
lang: "python",
target: "host",
best_effort: true,
},
Adapter {
lib: "urllib.request",
lang: "python",
target: "host",
best_effort: false,
},
Adapter {
lib: "boto3",
lang: "python",
target: "tool:aws.*",
best_effort: true,
},
Adapter {
lib: "psycopg",
lang: "python",
target: "host",
best_effort: true,
},
Adapter {
lib: "openai",
lang: "python+node",
target: "llm:openai",
best_effort: false,
},
Adapter {
lib: "anthropic",
lang: "python+node",
target: "llm:anthropic",
best_effort: false,
},
Adapter {
lib: "google-adk",
lang: "python",
target: "tool:<name>",
best_effort: false,
},
Adapter {
lib: "google-genai",
lang: "python",
target: "llm:google-genai",
best_effort: false,
},
Adapter {
lib: "pydantic-ai",
lang: "python",
target: "tool:<name>",
best_effort: false,
},
Adapter {
lib: "openai-agents",
lang: "python",
target: "tool:<name>",
best_effort: false,
},
Adapter {
lib: "crewai",
lang: "python",
target: "tool:<name>",
best_effort: false,
},
Adapter {
lib: "langgraph",
lang: "python",
target: "tool:<name>",
best_effort: false,
},
Adapter {
lib: "mcp",
lang: "python+node",
target: "mcp:<server>",
best_effort: false,
},
Adapter {
lib: "fetch",
lang: "node",
target: "host",
best_effort: false,
},
Adapter {
lib: "undici",
lang: "node",
target: "host",
best_effort: true,
},
Adapter {
lib: "http",
lang: "node",
target: "host",
best_effort: true,
},
Adapter {
lib: "ai-sdk",
lang: "node",
target: "llm:*",
best_effort: false,
},
Adapter {
lib: "ioredis",
lang: "node",
target: "host",
best_effort: true,
},
Adapter {
lib: "mysql2",
lang: "node",
target: "host",
best_effort: true,
},
Adapter {
lib: "pg",
lang: "node",
target: "host",
best_effort: true,
},
];
pub(crate) fn registry_libs() -> BTreeSet<&'static str> {
REGISTRY.iter().map(|a| a.lib).collect()
}
#[derive(Debug, Serialize)]
struct AdapterStatus {
detected: bool,
lib: &'static str,
status: &'static str,
target: &'static str,
}
#[derive(Debug, Serialize)]
struct Coverage {
invisible: Vec<String>,
visible_unwrapped: Vec<String>,
wrapped: Vec<String>,
}
#[derive(Debug, Serialize)]
struct PolicyCheck {
field: Option<String>,
message: Option<String>,
present: bool,
valid: bool,
}
#[derive(Debug, Serialize)]
struct Finding {
action: String,
detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
fix: Option<Proposal>,
level: &'static str,
topic: &'static str,
}
#[derive(Debug, Serialize)]
struct FollowUp {
code: &'static str,
detail: String,
rank: u32,
subject: String,
}
#[derive(Debug, Serialize)]
struct JournalReport {
backend: &'static str,
location: String,
source: &'static str,
supported: bool,
}
impl JournalReport {
fn from_resolved(resolved: &evidence::ResolvedJournal) -> Self {
Self {
backend: resolved.backend.as_str(),
location: resolved.display.clone(),
source: if resolved.from_policy {
"keel.toml"
} else {
"default"
},
supported: resolved.backend == evidence::JournalBackendKind::Sqlite,
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct TopologyEntry {
pub(crate) host: String,
pub(crate) reason: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct ExternalProcess {
pub(crate) command: String,
pub(crate) file: String,
pub(crate) launcher: String,
pub(crate) line: u32,
}
#[derive(Debug, Serialize)]
pub(crate) struct Topology {
pub(crate) excluded: Vec<TopologyEntry>,
pub(crate) external_processes: Vec<ExternalProcess>,
pub(crate) unreachable: Vec<TopologyEntry>,
pub(crate) wrappable: Vec<String>,
}
#[derive(Debug, Serialize)]
struct DoctorReport {
adapters: Vec<AdapterStatus>,
coverage: Coverage,
findings: Vec<Finding>,
follow_ups: Vec<FollowUp>,
journal: JournalReport,
ok: bool,
policy: PolicyCheck,
topology: Topology,
}
#[derive(Debug)]
struct PolicyValidation {
check: PolicyCheck,
fix: Option<Proposal>,
}
#[must_use]
pub fn preflight_advisory(project: &Path) -> Option<String> {
let scan = scan::scan(project);
let registry_libs: BTreeSet<&str> = REGISTRY.iter().map(|a| a.lib).collect();
let finding = resilience_finding(&scan, ®istry_libs)?;
Some(format!(
"keel \u{25b8} {}\n next: {} (run `keel doctor --json` for detail; skip this check with \
--no-preflight or KEEL_SKIP_PREFLIGHT=1)",
finding.detail, finding.action
))
}
pub fn run(project: &Path) -> Rendered {
let scan = scan::scan(project);
let discovery = match evidence::read_discovery(project) {
Ok(d) => d.into_iter().map(|s| s.target).collect(),
Err(e) => {
return Rendered {
human: format!("keel \u{25b8} doctor unavailable: {e}"),
json: to_json(&serde_json::json!({ "error": e })),
exit: crate::EXIT_FAILURE,
to_stderr: true,
};
}
};
let policy = validate_policy(&evidence::keel_toml(project));
let journal = JournalReport::from_resolved(&evidence::resolved_journal(project));
let agents_cli_finding = agents_cli_placement_finding(project);
let stale_flows = crate::flows::stale_code_hash_flows(project);
let report = build_report(
&scan,
&discovery,
policy,
journal,
agents_cli_finding,
&stale_flows,
);
let exit = if report.ok { EXIT_OK } else { EXIT_USAGE };
let human = human(&report);
Rendered::ok(human, to_json(&report)).with_exit(exit)
}
fn resilience_finding(scan: &ScanResult, registry_libs: &BTreeSet<&str>) -> Option<Finding> {
let compounds_with = scan
.libs
.iter()
.any(|lib| registry_libs.contains(lib.as_str()));
if scan.resilience_libs.is_empty() || !compounds_with {
return None;
}
let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
Some(Finding {
action: "Delete the old retry/backoff code if Keel's policy now covers it, or scope \
Keel's policy to skip this target (e.g. `attempts = 1`) if you want to keep \
relying on it — don't leave both running unconfigured against each other."
.to_owned(),
detail: format!(
"This project imports {} alongside at least one library Keel wraps — Keel cannot \
see whether {} is actually configured to retry the same calls, so retries may be \
silently compounding.",
libs.join(", "),
if libs.len() == 1 { "it" } else { "they" }
),
fix: None,
level: "warn",
topic: "preexisting-resilience",
})
}
fn topology_findings(topology: &Topology) -> Vec<Finding> {
let mut findings = Vec::new();
for entry in &topology.unreachable {
findings.push(Finding {
action: "Trace how this request is actually dispatched before proposing policy; \
Python's stdlib `urllib.request` is adapted — importing it directly in \
that file makes the host wrappable."
.to_owned(),
detail: format!("`{}` — {}.", entry.host, entry.reason),
fix: None,
level: "warn",
topic: "url-no-transport",
});
}
if !topology.external_processes.is_empty() {
let cmds: Vec<String> = topology
.external_processes
.iter()
.map(|p| format!("`{}` ({} at {}:{})", p.command, p.launcher, p.file, p.line))
.collect();
findings.push(Finding {
action: "Confirm none of these processes carry traffic you care about; Keel must be \
installed inside a process to see it."
.to_owned(),
detail: format!(
"Keel cannot see traffic inside {} externally-launched process(es): {}.",
topology.external_processes.len(),
cmds.join(", ")
),
fix: None,
level: "warn",
topic: "subprocess-blind-spot",
});
}
for entry in &topology.excluded {
findings.push(Finding {
action: "Confirm the exclusion is intended; add `# keel: include` to the file to \
override."
.to_owned(),
detail: format!("`{}` — {}.", entry.host, entry.reason),
fix: None,
level: "info",
topic: "dependency-averse-excluded",
});
}
findings
}
fn simplification_findings(scan: &ScanResult, topology: &Topology) -> Vec<Finding> {
let wrappable: BTreeSet<&str> = topology.wrappable.iter().map(String::as_str).collect();
let mut findings = Vec::new();
for s in &scan.simplifications {
let targets = s.targets.join(", ");
let actionable_now = s.targets.iter().any(|t| wrappable.contains(t.as_str()));
let (level, when) = if actionable_now {
("warn", "Keel can wrap this target now")
} else {
("info", "once Keel can wrap this target")
};
let (topic, what, action): (&'static str, String, String) = match s.kind.as_str() {
"hand-rolled-poll" => (
"hand-rolled-poll",
format!(
"`{}` ({}:{}) hand-rolls poll-until-terminal against `{}` — {}, a `poll` \
policy (interval / deadline / until) replaces the whole loop",
s.function, s.file, s.line, targets, when
),
"Wrap the target, then replace the loop with a `poll` policy — the poll \
primitive (CCR-3) is the designated replacement for submit-then-poll loops."
.to_owned(),
),
"silent-swallow" => (
"silent-swallow",
format!(
"`{}` ({}:{}) silences failures from `{}` with a broad `except` returning a \
default — Keel replaces silence with retry + observability",
s.function, s.file, s.line, targets
),
"Wrap the target so Keel's policy owns the failure (retry, breaker, journal), \
then narrow or remove the broad except."
.to_owned(),
),
_ => (
"hand-rolled-retry",
format!(
"`{}` ({}:{}) hand-rolls retry around `{}` — {}, this loop becomes redundant",
s.function, s.file, s.line, targets, when
),
"Wrap the target with Keel retry/backoff policy, then delete the hand-rolled \
loop — don't run both."
.to_owned(),
),
};
findings.push(Finding {
action,
detail: format!("{what}."),
fix: None,
level,
topic,
});
}
findings
}
fn follow_up_rank(code: &str) -> u32 {
match code {
"url-no-transport" => 1,
"subprocess-blind-spot" => 2,
"dependency-averse-excluded" => 3,
"preexisting-resilience" => 4,
_ => 5, }
}
fn build_follow_ups(
topology: &Topology,
resilience: Option<&Finding>,
scan: &ScanResult,
stale_flows: &[crate::flows::StaleFlow],
) -> Vec<FollowUp> {
let mut ups = Vec::new();
for entry in &topology.unreachable {
ups.push(FollowUp {
code: "url-no-transport",
detail: format!(
"Trace how requests to `{}` are actually dispatched — {}.",
entry.host, entry.reason
),
rank: follow_up_rank("url-no-transport"),
subject: entry.host.clone(),
});
}
if !topology.external_processes.is_empty() {
let cmds: Vec<String> = topology
.external_processes
.iter()
.map(|p| format!("`{}` ({}:{})", p.command, p.file, p.line))
.collect();
ups.push(FollowUp {
code: "subprocess-blind-spot",
detail: format!(
"Keel cannot see traffic inside externally-launched processes; confirm none of \
these carry traffic you care about: {}.",
cmds.join(", ")
),
rank: follow_up_rank("subprocess-blind-spot"),
subject: format!(
"{} externally-launched process(es)",
topology.external_processes.len()
),
});
}
for entry in &topology.excluded {
ups.push(FollowUp {
code: "dependency-averse-excluded",
detail: format!(
"`{}` was excluded from proposed policy — {}. Confirm the exclusion is intended.",
entry.host, entry.reason
),
rank: follow_up_rank("dependency-averse-excluded"),
subject: entry.host.clone(),
});
}
if resilience.is_some() {
let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
ups.push(FollowUp {
code: "preexisting-resilience",
detail: format!(
"Decide whether {} still needs its own retry/backoff now that Keel wraps the \
same calls — delete the old code or scope Keel's policy, not both.",
libs.join(", ")
),
rank: follow_up_rank("preexisting-resilience"),
subject: libs.join(", "),
});
}
for flow in stale_flows {
ups.push(FollowUp {
code: "code-hash-stale",
detail: format!(
"Flow `{}` ({}) was recorded under a different code hash than its current \
script; resuming would replay recorded steps against changed code (the resume \
fence downgrades nondeterminism fail->warn). Inspect with `keel replay {}` \
before resuming.",
flow.flow_id, flow.entrypoint, flow.flow_id
),
rank: follow_up_rank("code-hash-stale"),
subject: flow.flow_id.clone(),
});
}
ups.sort_by(|a, b| {
(a.rank, a.code, a.subject.as_str()).cmp(&(b.rank, b.code, b.subject.as_str()))
});
ups
}
fn agents_cli_placement_finding(project: &Path) -> Option<Finding> {
let layout = agents_cli::find_agents_cli_layout(project)?;
if layout.agent_dir == project || !evidence::keel_toml(project).exists() {
return None;
}
let agent_dir = relative_display(project, &layout.agent_dir);
let moved_to = relative_display(project, &layout.agent_dir.join("keel.toml"));
Some(Finding {
action: format!(
"Move keel.toml to {moved_to} (or add a `COPY keel.toml` line to the Dockerfile)."
),
detail: format!(
"This is an agents-cli project — its generated Dockerfile only COPYs \
pyproject.toml, README.md, uv.lock*, and {agent_dir} into the image, so the \
keel.toml at the project root never ships to the container."
),
fix: None,
level: "warn",
topic: "agents-cli-config-placement",
})
}
fn relative_display(base: &Path, target: &Path) -> String {
target.strip_prefix(base).map_or_else(
|_| target.display().to_string(),
|rel| rel.display().to_string(),
)
}
fn journal_finding(journal: &JournalReport) -> Option<Finding> {
(!journal.supported).then(|| Finding {
action: "Use a `file:` location (or drop the key for the default .keel/journal.db); Postgres support is future work — see docs.".to_owned(),
detail: format!(
"keel.toml sets `journal` to a {} location, but this build has no {} backend — the app will fail to configure with KEEL-E005.",
journal.backend, journal.backend
),
fix: None,
level: "error",
topic: "journal",
})
}
fn build_report(
scan: &ScanResult,
wrapped_targets: &BTreeSet<String>,
policy: PolicyValidation,
journal: JournalReport,
agents_cli_finding: Option<Finding>,
stale_flows: &[crate::flows::StaleFlow],
) -> DoctorReport {
let PolicyValidation { check: policy, fix } = policy;
let registry_libs: BTreeSet<&str> = REGISTRY.iter().map(|a| a.lib).collect();
let visible: BTreeSet<&String> = scan.targets.keys().collect();
let wrapped: Vec<String> = wrapped_targets.iter().cloned().collect();
let visible_unwrapped: Vec<String> = visible
.iter()
.filter(|t| !wrapped_targets.contains(**t))
.map(|t| (*t).clone())
.collect();
let invisible: Vec<String> = scan
.libs
.iter()
.filter(|lib| !registry_libs.contains(lib.as_str()))
.cloned()
.collect();
let topology = classify_topology(scan, wrapped_targets);
let adapters: Vec<AdapterStatus> = REGISTRY
.iter()
.map(|a| AdapterStatus {
detected: scan.libs.contains(a.lib),
lib: a.lib,
status: if a.best_effort {
"best-effort"
} else {
"pinned"
},
target: a.target,
})
.collect();
let mut findings = Vec::new();
for target in &visible_unwrapped {
findings.push(Finding {
action:
"Run `keel run <script>` so Keel can confirm this target is wrapped at runtime."
.to_owned(),
detail: format!(
"`{target}` is visible in your code but has no observed runtime evidence."
),
fix: None,
level: "warn",
topic: "visible-unwrapped",
});
}
for lib in &invisible {
findings.push(Finding {
action: format!("No adapter for `{lib}` yet — its calls are invisible to Keel. Track adapter support or wrap manually."),
detail: format!("`{lib}` is imported but has no adapter in the registry."),
fix: None,
level: "warn",
topic: "invisible",
});
}
findings.push(Finding {
action: "If a dependency makes calls Keel never reports, file an adapter request.".to_owned(),
detail: "Raw sockets and unknown native libraries are invisible to static and adapter-based interception.".to_owned(),
fix: None,
level: "info",
topic: "invisible",
});
findings.extend(topology_findings(&topology));
findings.extend(simplification_findings(scan, &topology));
if !policy.valid && policy.present {
let field = policy.field.clone().unwrap_or_default();
let mut action = "Fix the field above, then re-run `keel doctor`; validate against contracts/policy.schema.json.".to_owned();
if fix.is_some() {
action.push_str(
" Or apply the attached patch (`git apply`) to remove the invalid entry — defaults cover it.",
);
}
findings.push(Finding {
action,
detail: format!(
"keel.toml failed validation at `{field}`: {}",
policy.message.clone().unwrap_or_default()
),
fix,
level: "error",
topic: "policy",
});
}
let resilience = resilience_finding(scan, ®istry_libs);
let follow_ups = build_follow_ups(&topology, resilience.as_ref(), scan, stale_flows);
findings.extend(resilience);
findings.extend(journal_finding(&journal));
findings.extend(agents_cli_finding);
let ok = (policy.valid || !policy.present) && journal.supported;
DoctorReport {
adapters,
coverage: Coverage {
invisible,
visible_unwrapped,
wrapped,
},
findings,
follow_ups,
journal,
ok,
policy,
topology,
}
}
pub(crate) fn classify_topology(scan: &ScanResult, wrapped_targets: &BTreeSet<String>) -> Topology {
let dep_files: BTreeSet<&str> = scan
.dependency_averse
.iter()
.map(|d| d.file.as_str())
.collect();
let mut wrappable = Vec::new();
let mut unreachable = Vec::new();
let mut excluded = Vec::new();
for (target, ev) in &scan.targets {
if wrapped_targets.contains(target) || target.starts_with("llm:") {
wrappable.push(target.clone());
continue;
}
let only_dep_averse = !ev.sightings.is_empty()
&& ev
.sightings
.iter()
.all(|s| dep_files.contains(s.file.as_str()));
if only_dep_averse {
let files: BTreeSet<&str> = ev.sightings.iter().map(|s| s.file.as_str()).collect();
excluded.push(TopologyEntry {
host: target.clone(),
reason: format!(
"seen only in dependency-averse file(s) {} — excluded from proposed policy; \
add `# keel: include` to override",
files.into_iter().collect::<Vec<_>>().join(", ")
),
});
continue;
}
match scan
.host_transports
.get(target)
.copied()
.unwrap_or(TransportClass::Unknown)
{
TransportClass::Tracked => wrappable.push(target.clone()),
TransportClass::UntrackedKnown => unreachable.push(TopologyEntry {
host: target.clone(),
reason: "reached via a stdlib transport Keel does not adapt (http.client, or \
urllib without urllib.request; Python's urllib.request itself is \
adapted)"
.to_owned(),
}),
TransportClass::Unknown => unreachable.push(TopologyEntry {
host: target.clone(),
reason: "URL literal with no tracked transport in reach — trace how this request \
is dispatched"
.to_owned(),
}),
}
}
let external_processes: Vec<ExternalProcess> = scan
.subprocesses
.iter()
.map(|s| ExternalProcess {
command: s.command.clone(),
file: s.file.clone(),
launcher: s.launcher.clone(),
line: s.line,
})
.collect();
Topology {
excluded,
external_processes,
unreachable,
wrappable,
}
}
fn validate_policy(path: &Path) -> PolicyValidation {
if !path.exists() {
return PolicyValidation {
check: PolicyCheck {
field: None,
message: None,
present: false,
valid: true,
},
fix: None,
};
}
let Ok(text) = std::fs::read_to_string(path) else {
return invalid(None, "keel.toml exists but could not be read", None);
};
let toml_value: toml::Value = match text.parse() {
Ok(v) => v,
Err(e) => return invalid(None, &format!("keel.toml is not valid TOML: {e}"), None),
};
let json_value = match serde_json::to_value(&toml_value) {
Ok(v) => v,
Err(e) => {
return invalid(
None,
&format!("keel.toml could not be normalized: {e}"),
None,
);
}
};
match serde_path_to_error::deserialize::<_, Policy>(&json_value) {
Ok(_) => PolicyValidation {
check: PolicyCheck {
field: None,
message: None,
present: true,
valid: true,
},
fix: None,
},
Err(e) => {
let field = e.path().to_string();
let fix = suggest_removal(&text, &field);
invalid(Some(field), &e.inner().to_string(), fix)
}
}
}
fn invalid(field: Option<String>, message: &str, fix: Option<Proposal>) -> PolicyValidation {
PolicyValidation {
check: PolicyCheck {
field,
message: Some(message.to_owned()),
present: true,
valid: false,
},
fix,
}
}
const MAX_FIX_DEPTH: usize = 3;
fn suggest_removal(text: &str, field: &str) -> Option<Proposal> {
let resolved = resolve_dotted_path(text, field)?;
let segments = resolved.segments();
let cut = segments.len().min(MAX_FIX_DEPTH);
let path = PolicyPath::new(segments[..cut].iter().cloned());
let proposal = propose(Some(text), &[PolicyOp::Remove { path }]).ok()?;
if proposal.patch.is_empty() {
None
} else {
Some(proposal)
}
}
fn human(r: &DoctorReport) -> String {
let mut out = String::from("keel \u{25b8} doctor\n");
out.push_str("\ncoverage\n");
line_list(&mut out, " wrapped: ", &r.coverage.wrapped);
line_list(
&mut out,
" visible-unwrapped:",
&r.coverage.visible_unwrapped,
);
line_list(&mut out, " invisible: ", &r.coverage.invisible);
out.push_str("\ntopology\n");
line_list(&mut out, " wrap it: ", &r.topology.wrappable);
for e in &r.topology.unreachable {
let line = format!(" can't reach: {} — {}\n", e.host, e.reason);
out.push_str(&line);
}
for e in &r.topology.excluded {
let line = format!(" shouldn't reach: {} — {}\n", e.host, e.reason);
out.push_str(&line);
}
for p in &r.topology.external_processes {
let line = format!(
" external process: {} ({} at {}:{})\n",
p.command, p.launcher, p.file, p.line
);
out.push_str(&line);
}
out.push_str("\nadapters\n");
for a in &r.adapters {
let mark = if a.detected { "\u{2713}" } else { " " };
let line = format!(
" [{mark}] {lib:<10} {status:<12} -> {target}\n",
lib = a.lib,
status = a.status,
target = a.target,
);
out.push_str(&line);
}
out.push_str("\npolicy\n");
if !r.policy.present {
out.push_str(" no keel.toml — smart defaults apply. `keel init` to customize.\n");
} else if r.policy.valid {
out.push_str(" keel.toml is valid.\n");
} else {
let line = format!(
" keel.toml INVALID at `{}`: {}\n",
r.policy.field.clone().unwrap_or_default(),
r.policy.message.clone().unwrap_or_default(),
);
out.push_str(&line);
}
out.push_str("\njournal\n");
let journal_line = if r.journal.supported {
format!(
" {} at {} ({})\n",
r.journal.backend, r.journal.location, r.journal.source
)
} else {
format!(
" {} at {} ({}) — NOT supported in this build (KEEL-E005)\n",
r.journal.backend, r.journal.location, r.journal.source
)
};
out.push_str(&journal_line);
if !r.findings.is_empty() {
out.push_str("\nfindings\n");
for f in &r.findings {
let line = format!(
" [{}] {}\n \u{2192} {}\n",
f.level, f.detail, f.action
);
out.push_str(&line);
if let Some(fix) = &f.fix {
out.push_str(" patch (apply with `git apply`):\n");
out.push_str(&fix.patch);
}
}
}
if !r.follow_ups.is_empty() {
out.push_str("\nfollow-ups (work top-down; 1 = Keel knows least)\n");
for f in &r.follow_ups {
let line = format!(" {}. [{}] {} — {}\n", f.rank, f.code, f.subject, f.detail);
out.push_str(&line);
}
}
let tail = format!(
"\n{}\n",
if r.ok {
"ok"
} else {
"configuration error (exit 2)"
}
);
out.push_str(&tail);
out
}
fn line_list(out: &mut String, label: &str, items: &[String]) {
let line = if items.is_empty() {
format!("{label} (none)\n")
} else {
format!("{label} {}\n", items.join(", "))
};
out.push_str(&line);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scan::{Sighting, TargetClass, TargetEvidence};
fn default_journal() -> JournalReport {
JournalReport {
backend: "sqlite",
location: ".keel/journal.db".to_owned(),
source: "default",
supported: true,
}
}
fn scan_with(target: &str, class: TargetClass, libs: &[&str]) -> ScanResult {
let mut s = ScanResult {
files_scanned: 1,
python_available: true,
..ScanResult::default()
};
s.targets.insert(
target.to_owned(),
TargetEvidence {
class,
sightings: [Sighting {
file: "app.py".into(),
line: 1,
}]
.into_iter()
.collect(),
},
);
s.libs = libs.iter().map(|l| (*l).to_owned()).collect();
s
}
#[test]
fn wrapped_visible_and_invisible_are_classified() {
let scan = scan_with("llm:openai", TargetClass::Llm, &["openai", "django"]);
let wrapped: BTreeSet<String> = ["api.observed.com".to_owned()].into_iter().collect();
let policy = PolicyValidation {
check: PolicyCheck {
field: None,
message: None,
present: false,
valid: true,
},
fix: None,
};
let r = build_report(&scan, &wrapped, policy, default_journal(), None, &[]);
assert_eq!(r.coverage.wrapped, vec!["api.observed.com"]);
assert_eq!(r.coverage.visible_unwrapped, vec!["llm:openai"]);
assert_eq!(
r.coverage.invisible,
vec!["django"],
"django has no adapter"
);
assert!(r.ok, "no policy present → ok");
let openai = r.adapters.iter().find(|a| a.lib == "openai").unwrap();
assert!(openai.detected);
assert_eq!(openai.status, "pinned");
}
#[test]
fn topology_buckets_classify_hosts_honestly() {
use crate::scan::{DepAverseFile, SubprocessSighting, TransportClass};
let mut scan = ScanResult {
files_scanned: 3,
python_available: true,
..ScanResult::default()
};
scan.targets.insert(
"api.ok.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "app.py".into(),
line: 3,
}]
.into_iter()
.collect(),
},
);
scan.host_transports
.insert("api.ok.com".into(), TransportClass::Tracked);
scan.targets.insert(
"api.stdlib.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "screen.py".into(),
line: 9,
}]
.into_iter()
.collect(),
},
);
scan.host_transports
.insert("api.stdlib.com".into(), TransportClass::UntrackedKnown);
scan.targets.insert(
"api.broker.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "risk_gate.py".into(),
line: 20,
}]
.into_iter()
.collect(),
},
);
scan.host_transports
.insert("api.broker.com".into(), TransportClass::UntrackedKnown);
scan.dependency_averse.push(DepAverseFile {
file: "risk_gate.py".into(),
reason: "stdlib-only + name/docstring signal: risk".into(),
});
scan.subprocesses.push(SubprocessSighting {
file: "mcp.sh.py".into(),
line: 12,
launcher: "subprocess.run".into(),
command: "uvx alpaca-mcp-server".into(),
});
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
assert_eq!(r.topology.wrappable, vec!["api.ok.com"]);
assert_eq!(r.topology.unreachable.len(), 1);
assert_eq!(r.topology.unreachable[0].host, "api.stdlib.com");
assert_eq!(r.topology.excluded.len(), 1);
assert_eq!(r.topology.excluded[0].host, "api.broker.com");
assert!(r.topology.excluded[0].reason.contains("risk_gate.py"));
assert_eq!(r.topology.external_processes.len(), 1);
assert_eq!(
r.topology.external_processes[0].command,
"uvx alpaca-mcp-server"
);
assert!(
r.findings
.iter()
.any(|f| f.topic == "url-no-transport" && f.level == "warn")
);
assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
&& f.level == "warn"
&& f.detail.contains("uvx alpaca-mcp-server")));
assert!(
r.findings
.iter()
.any(|f| f.topic == "dependency-averse-excluded" && f.level == "info")
);
assert!(r.ok);
}
#[test]
fn simplification_findings_pair_with_topology_buckets() {
use crate::scan::{SimplificationSighting, TransportClass};
let mut scan = ScanResult {
files_scanned: 2,
python_available: true,
..ScanResult::default()
};
scan.targets.insert(
"api.ok.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "app.py".into(),
line: 3,
}]
.into_iter()
.collect(),
},
);
scan.host_transports
.insert("api.ok.com".into(), TransportClass::Tracked);
scan.simplifications.push(SimplificationSighting {
file: "app.py".into(),
line: 12,
kind: "hand-rolled-retry".into(),
function: "caller".into(),
targets: vec!["api.ok.com".into()],
});
scan.targets.insert(
"api.tavily.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "fetch_short_metrics.py".into(),
line: 39,
}]
.into_iter()
.collect(),
},
);
scan.host_transports
.insert("api.tavily.com".into(), TransportClass::UntrackedKnown);
scan.simplifications.push(SimplificationSighting {
file: "fetch_short_metrics.py".into(),
line: 83,
kind: "hand-rolled-poll".into(),
function: "_poll_research".into(),
targets: vec!["api.tavily.com".into()],
});
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
let retry = r
.findings
.iter()
.find(|f| f.topic == "hand-rolled-retry")
.expect("retry finding");
assert_eq!(retry.level, "warn", "wrappable target → actionable now");
assert!(retry.detail.contains("api.ok.com"));
assert!(retry.detail.contains("app.py:12"));
assert!(retry.detail.contains("caller"));
let poll = r
.findings
.iter()
.find(|f| f.topic == "hand-rolled-poll")
.expect("poll finding");
assert_eq!(poll.level, "info", "unreachable target → once-wrapped lead");
assert!(poll.detail.contains("fetch_short_metrics.py:83"));
assert!(poll.action.contains("poll"), "names the poll primitive");
assert!(
r.follow_ups
.iter()
.all(|f| !f.code.starts_with("hand-rolled") && f.code != "silent-swallow"),
"{:?}",
r.follow_ups
);
assert!(r.ok);
}
#[test]
fn follow_ups_are_ranked_closed_vocabulary_and_sorted() {
use crate::scan::{DepAverseFile, SubprocessSighting, TransportClass};
let mut scan = ScanResult {
files_scanned: 4,
python_available: true,
..ScanResult::default()
};
for (host, file) in [("api.zeta.com", "z.py"), ("api.alpha.com", "a.py")] {
scan.targets.insert(
host.into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: file.into(),
line: 1,
}]
.into_iter()
.collect(),
},
);
scan.host_transports
.insert(host.into(), TransportClass::UntrackedKnown);
}
scan.subprocesses.push(SubprocessSighting {
file: "launch.py".into(),
line: 12,
launcher: "subprocess.run".into(),
command: "uvx alpaca-mcp-server".into(),
});
scan.targets.insert(
"api.broker.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "risk_gate.py".into(),
line: 20,
}]
.into_iter()
.collect(),
},
);
scan.dependency_averse.push(DepAverseFile {
file: "risk_gate.py".into(),
reason: "stdlib-only + name/docstring signal: risk".into(),
});
scan.libs.insert("httpx".to_owned());
scan.resilience_libs.insert("tenacity".to_owned());
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
let got: Vec<(u32, &str, &str)> = r
.follow_ups
.iter()
.map(|f| (f.rank, f.code, f.subject.as_str()))
.collect();
assert_eq!(
got,
vec![
(1, "url-no-transport", "api.alpha.com"),
(1, "url-no-transport", "api.zeta.com"),
(
2,
"subprocess-blind-spot",
"1 externally-launched process(es)"
),
(3, "dependency-averse-excluded", "api.broker.com"),
(4, "preexisting-resilience", "tenacity"),
]
);
assert!(r.follow_ups.iter().all(|f| !f.detail.is_empty()));
assert!(r.ok);
let text = human(&r);
assert!(text.contains("follow-ups"));
assert!(text.contains("[url-no-transport] api.alpha.com"));
}
#[test]
fn no_signals_means_empty_follow_ups() {
use crate::scan::TransportClass;
let scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
let mut scan = scan;
scan.host_transports
.insert("api.example.com".into(), TransportClass::Tracked);
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
assert!(r.follow_ups.is_empty(), "{:?}", r.follow_ups);
}
#[test]
#[allow(clippy::too_many_lines)] fn wrapped_and_llm_targets_are_always_wrappable() {
use crate::scan::{DepAverseFile, TransportClass};
let mut scan = ScanResult {
files_scanned: 3,
python_available: true,
..ScanResult::default()
};
scan.targets.insert(
"api.wrapped-but-unknown.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "app.py".into(),
line: 1,
}]
.into_iter()
.collect(),
},
);
scan.host_transports.insert(
"api.wrapped-but-unknown.com".into(),
TransportClass::Unknown,
);
scan.targets.insert(
"api.wrapped-but-excluded.com".into(),
TargetEvidence {
class: TargetClass::Host,
sightings: [Sighting {
file: "risk_gate.py".into(),
line: 5,
}]
.into_iter()
.collect(),
},
);
scan.host_transports.insert(
"api.wrapped-but-excluded.com".into(),
TransportClass::UntrackedKnown,
);
scan.dependency_averse.push(DepAverseFile {
file: "risk_gate.py".into(),
reason: "stdlib-only + name/docstring signal: risk".into(),
});
scan.targets.insert(
"llm:some-model".into(),
TargetEvidence {
class: TargetClass::Llm,
sightings: [Sighting {
file: "agent.py".into(),
line: 7,
}]
.into_iter()
.collect(),
},
);
let wrapped: BTreeSet<String> = [
"api.wrapped-but-unknown.com".to_owned(),
"api.wrapped-but-excluded.com".to_owned(),
]
.into_iter()
.collect();
let r = build_report(
&scan,
&wrapped,
default_policy(),
default_journal(),
None,
&[],
);
assert!(
r.topology
.wrappable
.contains(&"api.wrapped-but-unknown.com".to_owned()),
"a wrapped-at-runtime target must be wrappable even with an Unknown transport class: \
{:?}",
r.topology
);
assert!(
!r.topology
.unreachable
.iter()
.any(|e| e.host == "api.wrapped-but-unknown.com"),
"must not also land in unreachable"
);
assert!(
r.topology
.wrappable
.contains(&"api.wrapped-but-excluded.com".to_owned()),
"a wrapped-at-runtime target must be wrappable even when sighted only in a \
dependency-averse file: {:?}",
r.topology
);
assert!(
!r.topology
.excluded
.iter()
.any(|e| e.host == "api.wrapped-but-excluded.com"),
"must not also land in excluded"
);
assert!(
r.topology.wrappable.contains(&"llm:some-model".to_owned()),
"an llm:* target must be wrappable with zero transport evidence: {:?}",
r.topology
);
}
#[test]
fn agent_pack_adapters_are_registered_pinned_and_detected() {
let scan = scan_with(
"llm:google-genai",
TargetClass::Llm,
&[
"google-adk",
"google-genai",
"pydantic-ai",
"openai-agents",
"crewai",
"langgraph",
"mcp",
],
);
let policy = PolicyValidation {
check: PolicyCheck {
field: None,
message: None,
present: false,
valid: true,
},
fix: None,
};
let r = build_report(
&scan,
&BTreeSet::new(),
policy,
default_journal(),
None,
&[],
);
assert!(
r.coverage.invisible.is_empty(),
"every imported agent-pack lib has a registry adapter: {:?}",
r.coverage.invisible
);
for (lib, target) in [
("google-adk", "tool:<name>"),
("google-genai", "llm:google-genai"),
("pydantic-ai", "tool:<name>"),
("openai-agents", "tool:<name>"),
("crewai", "tool:<name>"),
("langgraph", "tool:<name>"),
("mcp", "mcp:<server>"),
] {
let a = r
.adapters
.iter()
.find(|a| a.lib == lib && a.target == target)
.unwrap_or_else(|| panic!("missing REGISTRY entry for {lib} -> {target}"));
assert!(a.detected, "{lib} should be detected");
assert_eq!(a.status, "pinned");
}
}
fn default_policy() -> PolicyValidation {
PolicyValidation {
check: PolicyCheck {
field: None,
message: None,
present: false,
valid: true,
},
fix: None,
}
}
#[test]
fn urllib_request_is_a_registered_tracked_adapter() {
let mut scan = scan_with("api.tavily.com", TargetClass::Host, &["urllib.request"]);
scan.host_transports
.insert("api.tavily.com".into(), TransportClass::Tracked);
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
let row = r
.adapters
.iter()
.find(|a| a.lib == "urllib.request")
.expect("REGISTRY row for urllib.request");
assert!(row.detected);
assert_eq!(row.status, "pinned");
assert_eq!(row.target, "host");
assert!(
r.coverage.invisible.is_empty(),
"{:?}",
r.coverage.invisible
);
assert!(r.topology.wrappable.contains(&"api.tavily.com".to_owned()));
assert!(
r.topology.unreachable.is_empty(),
"{:?}",
r.topology.unreachable
);
}
#[test]
fn resilience_lib_alongside_a_wrapped_effect_is_a_finding() {
let mut scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
scan.resilience_libs.insert("tenacity".to_owned());
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
let finding = r
.findings
.iter()
.find(|f| f.topic == "preexisting-resilience")
.expect("tenacity + httpx should raise a finding");
assert_eq!(finding.level, "warn");
assert!(finding.detail.contains("tenacity"));
}
#[test]
fn resilience_lib_with_no_wrapped_effect_is_not_a_finding() {
let mut scan = ScanResult {
files_scanned: 1,
python_available: true,
..ScanResult::default()
};
scan.resilience_libs.insert("tenacity".to_owned());
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
assert!(
!r.findings
.iter()
.any(|f| f.topic == "preexisting-resilience")
);
}
#[test]
fn no_resilience_libs_is_not_a_finding() {
let scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
let r = build_report(
&scan,
&BTreeSet::new(),
default_policy(),
default_journal(),
None,
&[],
);
assert!(
!r.findings
.iter()
.any(|f| f.topic == "preexisting-resilience")
);
}
#[test]
fn invalid_policy_is_a_finding_and_not_ok() {
let scan = ScanResult::default();
let wrapped = BTreeSet::new();
let policy = PolicyValidation {
check: PolicyCheck {
field: Some("target.x.retry.attempts".to_owned()),
message: Some("invalid value: integer `0`".to_owned()),
present: true,
valid: false,
},
fix: None,
};
let r = build_report(&scan, &wrapped, policy, default_journal(), None, &[]);
assert!(!r.ok);
assert!(
r.findings
.iter()
.any(|f| f.topic == "policy" && f.level == "error")
);
}
#[test]
fn unsupported_journal_backend_is_an_error_finding_and_not_ok() {
let scan = ScanResult::default();
let wrapped = BTreeSet::new();
let policy = PolicyValidation {
check: PolicyCheck {
field: None,
message: None,
present: true,
valid: true,
},
fix: None,
};
let journal = JournalReport {
backend: "postgres",
location: "postgres://\u{2026}@db.internal/keel".to_owned(),
source: "keel.toml",
supported: false,
};
let r = build_report(&scan, &wrapped, policy, journal, None, &[]);
assert!(!r.ok, "an unbootable configuration must not be ok");
let finding = r
.findings
.iter()
.find(|f| f.topic == "journal")
.expect("journal finding present");
assert_eq!(finding.level, "error");
assert!(finding.detail.contains("KEEL-E005"));
assert!(finding.action.contains("file:"));
let text = human(&r);
assert!(text.contains("postgres"));
assert!(text.contains("NOT supported"));
}
#[test]
fn agents_cli_placement_finding_fires_for_a_root_keel_toml() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("app")).unwrap();
std::fs::write(
dir.path().join("agents-cli-manifest.yaml"),
"agent_directory: app\n",
)
.unwrap();
std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
let finding =
agents_cli_placement_finding(dir.path()).expect("root keel.toml should be flagged");
assert_eq!(finding.level, "warn");
assert_eq!(finding.topic, "agents-cli-config-placement");
assert!(finding.detail.contains("Dockerfile"));
assert!(finding.detail.contains("pyproject.toml"));
assert!(finding.detail.contains("app"));
assert!(
finding.action.contains("app/keel.toml") || finding.action.contains("app\\keel.toml"),
"action names the relative move-to path: {}",
finding.action
);
}
#[test]
fn agents_cli_placement_finding_is_none_without_a_manifest() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
assert!(agents_cli_placement_finding(dir.path()).is_none());
}
#[test]
fn agents_cli_placement_finding_is_none_when_keel_toml_is_already_in_the_agent_dir() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("app")).unwrap();
std::fs::write(
dir.path().join("agents-cli-manifest.yaml"),
"agent_directory: app\n",
)
.unwrap();
std::fs::write(dir.path().join("app").join("keel.toml"), "[target.\"x\"]\n").unwrap();
assert!(agents_cli_placement_finding(dir.path()).is_none());
}
#[test]
fn agents_cli_placement_finding_is_none_when_agent_dir_is_the_project_root() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("agents-cli-manifest.yaml"),
"agent_directory: .\n",
)
.unwrap();
std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
assert!(agents_cli_placement_finding(dir.path()).is_none());
}
#[test]
fn doctor_run_surfaces_the_agents_cli_placement_finding() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("app")).unwrap();
std::fs::write(
dir.path().join("agents-cli-manifest.yaml"),
"agent_directory: app\n",
)
.unwrap();
std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
let r = run(dir.path());
assert_eq!(r.exit, EXIT_OK);
assert_eq!(r.json["ok"], true);
let findings = r.json["findings"].as_array().unwrap();
assert!(
findings
.iter()
.any(|f| f["topic"] == "agents-cli-config-placement" && f["level"] == "warn")
);
}
#[test]
fn doctor_reports_the_policy_selected_journal_location() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("keel.toml"),
"journal = \"file:custom/j.db\"\n",
)
.unwrap();
let r = run(dir.path());
assert_eq!(r.exit, EXIT_OK);
assert_eq!(r.json["journal"]["backend"], "sqlite");
assert_eq!(r.json["journal"]["location"], "custom/j.db");
assert_eq!(r.json["journal"]["source"], "keel.toml");
assert_eq!(r.json["journal"]["supported"], true);
assert!(r.human.contains("custom/j.db"));
}
#[test]
fn doctor_flags_a_postgres_journal_and_redacts_credentials() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("keel.toml"),
"journal = \"postgres://keel:sekrit@db.internal/keel\"\n",
)
.unwrap();
let r = run(dir.path());
assert_eq!(r.exit, EXIT_USAGE);
assert_eq!(r.json["journal"]["backend"], "postgres");
assert_eq!(r.json["journal"]["supported"], false);
assert_eq!(r.json["ok"], false);
let json_text = crate::render::json_string(&r.json);
assert!(!json_text.contains("sekrit"), "credentials never printed");
assert!(!r.human.contains("sekrit"), "credentials never printed");
}
#[test]
fn validate_policy_reports_exact_field_path() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("keel.toml");
std::fs::write(&path, "[target.\"x\"]\nretry = { attempts = 0 }\n").unwrap();
let v = validate_policy(&path);
assert!(!v.check.valid);
assert_eq!(v.check.field.as_deref(), Some("target.x.retry.attempts"));
}
#[test]
fn validate_policy_accepts_a_good_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("keel.toml");
std::fs::write(
&path,
"[target.\"api.x\"]\nretry = { attempts = 5, schedule = \"exp(200ms, x2, max 30s, jitter)\" }\n",
)
.unwrap();
let v = validate_policy(&path);
assert!(
v.check.valid,
"field={:?} msg={:?}",
v.check.field, v.check.message
);
assert!(v.fix.is_none(), "a valid policy needs no fix");
}
#[test]
fn absent_policy_is_valid_and_ok() {
let v = validate_policy(Path::new("/nonexistent/keel.toml"));
assert!(v.check.valid);
assert!(!v.check.present);
}
#[test]
fn invalid_policy_finding_carries_an_applyable_removal_fix() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("keel.toml");
std::fs::write(
&path,
"# my tuning\n[target.\"api.example.com\"]\ntimeout = \"30s\" # keep\nretry = { attempts = 0 }\n",
)
.unwrap();
let v = validate_policy(&path);
assert!(!v.check.valid);
assert_eq!(
v.check.field.as_deref(),
Some("target.api.example.com.retry.attempts"),
"dotted host key resolves"
);
let fix = v.fix.expect("fix proposal attached");
assert!(fix.patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"));
let applied =
crate::diff::apply_unified(&std::fs::read_to_string(&path).unwrap(), &fix.patch)
.unwrap();
assert_eq!(applied, fix.new_text);
std::fs::write(&path, &fix.new_text).unwrap();
let after = validate_policy(&path);
assert!(after.check.valid, "removal fix yields a valid policy");
assert!(fix.new_text.contains("# my tuning"));
assert!(fix.new_text.contains("timeout = \"30s\" # keep"));
assert!(
!fix.new_text.contains("retry"),
"whole invalid entry removed"
);
assert_eq!(fix.changes.len(), 1);
assert_eq!(fix.changes[0].path, "target.\"api.example.com\".retry");
assert!(fix.changes[0].after.is_none());
}
#[test]
fn unparseable_policy_has_no_fix() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("keel.toml");
std::fs::write(&path, "not [valid toml\n").unwrap();
let v = validate_policy(&path);
assert!(!v.check.valid);
assert!(v.fix.is_none());
}
fn python3_present() -> bool {
std::process::Command::new("python3")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[test]
fn code_hash_stale_flow_emits_the_rank5_follow_up() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
let dir = tempfile::TempDir::new().unwrap();
let keel = dir.path().join(".keel");
std::fs::create_dir_all(&keel).unwrap();
let conn = rusqlite::Connection::open(keel.join("journal.db")).unwrap();
conn.execute_batch(&schema).unwrap();
let t0: i64 = 1_783_728_000_000;
conn.execute(
"INSERT INTO flows (flow_id, entrypoint, args_hash, code_hash, status, created_at, \
updated_at) VALUES ('01STALEFLOW', 'py:pipeline.ingest:main', 'ah-1', \
'deadbeefdeadbeef', 'running', ?1, ?1)",
rusqlite::params![t0],
)
.unwrap();
let script_dir = dir.path().join("pipeline");
std::fs::create_dir_all(&script_dir).unwrap();
std::fs::write(script_dir.join("ingest.py"), "def main():\n pass\n").unwrap();
let r = run(dir.path());
let f = r.json["follow_ups"]
.as_array()
.unwrap()
.iter()
.find(|f| f["code"] == "code-hash-stale")
.expect("code-hash-stale emitted");
assert_eq!(f["rank"], 5);
assert!(f["detail"].as_str().unwrap().contains("keel replay"));
}
#[test]
fn doctor_and_init_diff_never_leak_raw_source() {
const CANARIES: [&str; 5] = [
"CANARY_COMMENT_9f31",
"CANARY_SECRET_9f31",
"CANARY_QUERY_9f31",
"CANARY_DOCSTRING_9f31",
"CANARY_QUERY2_9f31",
];
if !python3_present() {
eprintln!("skip: python3 not available");
return;
}
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(
dir.path().join("app.py"),
r#"import time
import urllib.request
import httpx
import tenacity
# CANARY_COMMENT_9f31 must never appear in any report
TOKEN = "CANARY_SECRET_9f31"
U = "https://api.leak.example/v1?key=CANARY_QUERY_9f31"
def caller():
attempt = 0
while True:
try:
return httpx.get(U)
except Exception:
# CANARY_COMMENT_9f31 must never appear in any report
local_secret = "CANARY_QUERY2_9f31"
attempt += 1
time.sleep(1)
"#,
)
.unwrap();
std::fs::write(
dir.path().join("risk_gate.py"),
"\"\"\"risk gate. CANARY_DOCSTRING_9f31 stdlib only.\"\"\"\n\
import json\n\
G = \"https://api.gateonly.example/v2?tok=CANARY_QUERY2_9f31\"\n",
)
.unwrap();
let doctor = run(dir.path());
let doctor_json = crate::render::json_string(&doctor.json);
let init = crate::init::run(
dir.path(),
crate::init::InitOptions {
diff: true,
stamp: false,
agents: false,
},
);
let init_json = crate::render::json_string(&init.json);
for canary in CANARIES {
assert!(!doctor_json.contains(canary), "doctor json leaks {canary}");
assert!(
!doctor.human.contains(canary),
"doctor human leaks {canary}"
);
assert!(
!init_json.contains(canary),
"init --diff json leaks {canary}"
);
assert!(
!init.human.contains(canary),
"init --diff human leaks {canary}"
);
}
assert!(doctor_json.contains("api.leak.example"));
assert!(
init_json.contains("hand-rolled-retry"),
"init --diff notes should surface the hand-rolled retry loop: {init_json}"
);
}
}