Skip to main content

keel_cli/
doctor.rs

1//! `keel doctor` — the honesty report (dx-spec §2).
2//!
3//! Three questions, answered from files (no program run):
4//! 1. **Coverage.** What's *wrapped* (observed in `.keel/discovery.db`), what's
5//!    *visible-but-unwrapped* (found by the static scan, never seen at runtime),
6//!    and what's *invisible* (an effect library with no adapter — Keel can't
7//!    wrap what it can't see).
8//! 2. **Adapters.** A registry of the known adapter set, each pinned (contract-
9//!    tested against a version) or best-effort, annotated with what was detected.
10//! 3. **Policy.** `keel.toml` validated against the typed model
11//!    ([`keel_core_api::policy::Policy`]); on error, the exact field path.
12//! 4. **Journal.** Where the journal lives, resolved the way the engine
13//!    resolves it at configure time (`journal` key, else `.keel/journal.db`).
14//!    A location this build has no backend for (`postgres://`) is an error
15//!    finding: the app will fail to configure with KEEL-E005.
16//!
17//! Every finding carries a suggested action, and the whole thing has a `--json`
18//! twin. An invalid policy — or a journal backend this build cannot provide —
19//! exits [`EXIT_USAGE`](crate::EXIT_USAGE); otherwise 0.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::fmt::Write as _;
23use std::path::Path;
24
25use keel_core_api::policy::{FlowMatchRule, Policy};
26use serde::Serialize;
27
28use crate::cmd_match::{compile_cmd_rules, match_argv};
29use crate::diff::{PolicyOp, PolicyPath, Proposal, propose, resolve_dotted_path};
30use crate::render::to_json;
31use crate::scan::{ScanResult, TransportClass};
32use crate::{EXIT_OK, EXIT_USAGE, Rendered, agents_cli, evidence, scan};
33
34/// One known adapter/pack: its library, the language(s), the semantic target
35/// class it exposes, and whether it is version-pinned or best-effort.
36#[derive(Debug, Clone, Copy, Serialize)]
37struct Adapter {
38    best_effort: bool,
39    lang: &'static str,
40    lib: &'static str,
41    target: &'static str,
42}
43
44/// The compiled adapter registry (dx-spec §2/§4). "data compiled from the known
45/// adapter set"; the front ends register these at import time, but the CLI knows
46/// the set statically so `doctor` works without running the program.
47const REGISTRY: &[Adapter] = &[
48    Adapter {
49        lib: "httpx",
50        lang: "python",
51        target: "host",
52        best_effort: false,
53    },
54    Adapter {
55        lib: "requests",
56        lang: "python",
57        target: "host",
58        best_effort: false,
59    },
60    Adapter {
61        lib: "aiohttp",
62        lang: "python",
63        target: "host",
64        best_effort: true,
65    },
66    Adapter {
67        lib: "urllib3",
68        lang: "python",
69        target: "host",
70        best_effort: true,
71    },
72    // The stdlib urllib.request pack (WS4). Convention exception, documented
73    // here on the registry itself: stdlib has no pip version to pin, so this
74    // row is keyed to the PYTHON RUNTIME version — the pack's detect()
75    // reports platform.python_version() and certifies the interpreter lines
76    // in urllib_pack._PINNED (CI pins 3.11). "pinned", not best-effort: the
77    // seam is a stable stdlib API certified per interpreter line by the farm.
78    Adapter {
79        lib: "urllib.request",
80        lang: "python",
81        target: "host",
82        best_effort: false,
83    },
84    Adapter {
85        lib: "boto3",
86        lang: "python",
87        target: "tool:aws.*",
88        best_effort: true,
89    },
90    Adapter {
91        lib: "psycopg",
92        lang: "python",
93        target: "host",
94        best_effort: true,
95    },
96    Adapter {
97        lib: "openai",
98        lang: "python+node",
99        target: "llm:openai",
100        best_effort: false,
101    },
102    Adapter {
103        lib: "anthropic",
104        lang: "python+node",
105        target: "llm:anthropic",
106        best_effort: false,
107    },
108    // The six agent-framework packs (dx-spec agent-first-class work) plus the
109    // google-genai LLM provider pack. Farm-certification alone isn't the
110    // `best_effort` discriminator — aiohttp/boto3 below are farm-tested too,
111    // yet stay best-effort because each carries a documented fidelity gap
112    // (aiohttp's cache-hit replay is a duck-typed stand-in response, not a
113    // real `aiohttp.ClientResponse`; boto3 infers retry-safety from an
114    // operation-name-prefix heuristic, not a guaranteed contract). These
115    // packs instead wrap official, stable extension points with no such gap
116    // (ADK's plugin API, AI-SDK-style documented seams) and are ALSO
117    // farm-certified (.github/workflows/adapter-farm.yml) — so pinned like
118    // httpx/openai. `target` mirrors each pack's own declared
119    // `TargetDecl.pattern` exactly (adk_pack.py, pydantic_ai_pack.py,
120    // openai_agents_pack.py, crewai_pack.py, langgraph_pack.py:
121    // `"tool:<name>"`; mcp_pack.py / mcp.mjs: `"mcp:<server>"`).
122    Adapter {
123        lib: "google-adk",
124        lang: "python",
125        target: "tool:<name>",
126        best_effort: false,
127    },
128    Adapter {
129        lib: "google-genai",
130        lang: "python",
131        target: "llm:google-genai",
132        best_effort: false,
133    },
134    Adapter {
135        lib: "pydantic-ai",
136        lang: "python",
137        target: "tool:<name>",
138        best_effort: false,
139    },
140    Adapter {
141        lib: "openai-agents",
142        lang: "python",
143        target: "tool:<name>",
144        best_effort: false,
145    },
146    Adapter {
147        lib: "crewai",
148        lang: "python",
149        target: "tool:<name>",
150        best_effort: false,
151    },
152    Adapter {
153        lib: "langgraph",
154        lang: "python",
155        target: "tool:<name>",
156        best_effort: false,
157    },
158    // The `mcp` client SDK — one row shared by Python (mcp_pack) and Node
159    // (mcp.mjs), like the openai/anthropic rows above: same import/package
160    // name in both runtimes, and both packs declare the identical
161    // per-server target grammar (`TargetDecl.pattern == "mcp:<server>"` in
162    // both mcp_pack.py and mcp.mjs). Farm-certified in both languages
163    // (tests/test_farm_mcp.py, node/keel/test/mcp-farm.test.mjs).
164    Adapter {
165        lib: "mcp",
166        lang: "python+node",
167        target: "mcp:<server>",
168        best_effort: false,
169    },
170    Adapter {
171        lib: "fetch",
172        lang: "node",
173        target: "host",
174        best_effort: false,
175    },
176    Adapter {
177        lib: "undici",
178        lang: "node",
179        target: "host",
180        best_effort: true,
181    },
182    Adapter {
183        lib: "http",
184        lang: "node",
185        target: "host",
186        best_effort: true,
187    },
188    Adapter {
189        lib: "ai-sdk",
190        lang: "node",
191        target: "llm:*",
192        best_effort: false,
193    },
194    Adapter {
195        lib: "ioredis",
196        lang: "node",
197        target: "host",
198        best_effort: true,
199    },
200    Adapter {
201        lib: "mysql2",
202        lang: "node",
203        target: "host",
204        best_effort: true,
205    },
206    Adapter {
207        lib: "pg",
208        lang: "node",
209        target: "host",
210        best_effort: true,
211    },
212];
213
214/// The registry's library names, for cross-module gates that must not drift
215/// from doctor's own (init's pre-existing-resilience annotation uses the
216/// same "imports at least one lib Keel wraps" test as [`resilience_finding`]).
217pub(crate) fn registry_libs() -> BTreeSet<&'static str> {
218    REGISTRY.iter().map(|a| a.lib).collect()
219}
220
221/// One line in the adapter section: a registry entry plus whether this project
222/// uses it.
223#[derive(Debug, Serialize)]
224struct AdapterStatus {
225    detected: bool,
226    lib: &'static str,
227    status: &'static str,
228    target: &'static str,
229}
230
231/// The three coverage classes.
232#[derive(Debug, Serialize)]
233struct Coverage {
234    invisible: Vec<String>,
235    visible_unwrapped: Vec<String>,
236    wrapped: Vec<String>,
237}
238
239/// A policy-validation outcome.
240#[derive(Debug, Serialize)]
241struct PolicyCheck {
242    field: Option<String>,
243    message: Option<String>,
244    present: bool,
245    valid: bool,
246}
247
248/// One actionable finding. Where the finding implies a policy edit, `fix`
249/// carries the applyable form (dx-spec §5, diffs as the lingua franca): a
250/// unified `patch` for `git apply` plus structured `changes`.
251#[derive(Debug, Serialize)]
252struct Finding {
253    action: String,
254    detail: String,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    fix: Option<Proposal>,
257    level: &'static str,
258    topic: &'static str,
259}
260
261/// One ranked follow-up: a lead Keel cannot chase itself, phrased for the
262/// agent/human reading the report to work top-down. `code` is a CLOSED set —
263/// url-no-transport | orchestration-blind-spot | subprocess-blind-spot |
264/// dependency-averse-excluded | preexisting-resilience | code-hash-stale —
265/// ranked lowest-Keel-confidence first (rank 1 = Keel knows least, investigate
266/// first). Text is entirely keel-authored; only hostnames, file paths, and lib
267/// names are interpolated.
268#[derive(Debug, Serialize)]
269struct FollowUp {
270    code: &'static str,
271    detail: String,
272    rank: u32,
273    subject: String,
274}
275
276/// Where the journal lives, as resolved for this project — the same selection
277/// the engine makes at configure time.
278#[derive(Debug, Serialize)]
279struct JournalReport {
280    /// `"sqlite"` (default and `file:` locations) or `"postgres"`.
281    backend: &'static str,
282    /// The location as users should read it: a `file:` path as written, the
283    /// default relative path, or a credential-redacted `postgres://` form.
284    location: String,
285    /// `"keel.toml"` when the `journal` key set it, else `"default"`.
286    source: &'static str,
287    /// `false` when this build has no backend for the location — the app will
288    /// fail to configure with KEEL-E005.
289    supported: bool,
290}
291
292impl JournalReport {
293    fn from_resolved(resolved: &evidence::ResolvedJournal) -> Self {
294        Self {
295            backend: resolved.backend.as_str(),
296            location: resolved.display.clone(),
297            source: if resolved.from_policy {
298                "keel.toml"
299            } else {
300                "default"
301            },
302            supported: resolved.backend == evidence::JournalBackendKind::Sqlite,
303        }
304    }
305}
306
307/// One host `keel doctor` judged excluded or unreachable, with the honest
308/// reason why — see [`Topology`]. `pub(crate)`: `init.rs` reuses
309/// [`classify_topology`] to skip proposals for excluded hosts and print why.
310#[derive(Debug, Serialize)]
311pub(crate) struct TopologyEntry {
312    pub(crate) host: String,
313    pub(crate) reason: String,
314}
315
316/// One externally-launched process the scan saw — traffic inside it is
317/// outside Keel's visibility regardless of policy (dx-spec's "shouldn't/
318/// can't/wrap it" honesty triad, the "external process" leg). `pub(crate)`
319/// alongside [`Topology`] for the same cross-module reuse.
320#[derive(Debug, Serialize)]
321pub(crate) struct ExternalProcess {
322    pub(crate) command: String,
323    /// The `cmd:<name>` entrypoint this sighting's argv matches under the
324    /// project's declared `[flows.match."cmd:*"]` rules (issue #41), or
325    /// `None` when unmatched (or the launcher isn't one the runtime pack
326    /// ever intercepts, or its argv is not a genuine positional literal —
327    /// see [`scan::SubprocessSighting::argv`]). A match means "wrapped WHEN
328    /// Keel is active in the process that runs it" — doctor cannot know
329    /// activation from a static scan, so a match downgrades this finding
330    /// rather than dropping it; see [`topology_findings`]/[`build_follow_ups`].
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub(crate) covered_by: Option<String>,
333    pub(crate) file: String,
334    pub(crate) launcher: String,
335    pub(crate) line: u32,
336}
337
338/// The three-bucket honesty topology (dx-spec §2): every host Keel's static
339/// scan saw, sorted into exactly one of "wrap it" (a tracked transport is in
340/// reach, or the target is wrapped-at-runtime/`llm:*` by construction),
341/// "can't reach it" (no adapted transport in reach — Keel is blind here
342/// regardless of policy), or "shouldn't reach it" (sighted only inside a
343/// file the scan judged dependency-averse — excluded from proposed policy on
344/// purpose). `external_processes` is the adjacent, host-independent honesty
345/// signal: traffic inside an externally-launched process Keel cannot see at
346/// all, no matter which bucket its host would otherwise land in.
347///
348/// `pub(crate)`: `init.rs` reuses [`classify_topology`] to skip proposing
349/// policy for excluded hosts and print why.
350#[derive(Debug, Serialize)]
351pub(crate) struct Topology {
352    pub(crate) excluded: Vec<TopologyEntry>,
353    pub(crate) external_processes: Vec<ExternalProcess>,
354    pub(crate) unreachable: Vec<TopologyEntry>,
355    pub(crate) wrappable: Vec<String>,
356}
357
358/// What this report could and could not read — the honest frame around every
359/// other field, and the one place a consumer that called `keel doctor --json`
360/// (or the `get_doctor_report` MCP tool) *alone* learns that the report is
361/// evidence, not a verdict.
362///
363/// Deliberately NOT a [`Finding`]: findings are things to act on, they feed the
364/// human findings list and (via structured evidence) `follow_ups`, and an
365/// unconditional advisory in that list is exactly the kind of false positive
366/// [`resilience_finding`] argues erodes trust in the real ones. These are
367/// standing properties of the tool instead. The MCP surface is contractually
368/// byte-identical to `keel doctor --json`, so there is no agent-only channel —
369/// anything an agent must read has to live here, in the shared report.
370///
371/// `governance_files` is filesystem-dependent, so the whole object is built in
372/// [`run`] and passed into the pure, golden-pinned [`build_report`] — the same
373/// pattern `agents_cli_finding`/`stale_flows` already use.
374#[derive(Debug, Serialize)]
375struct Boundaries {
376    /// Root files carrying project constraints this report cannot parse —
377    /// `CLAUDE.md`, `AGENTS.md`. Empty when neither exists. Edit deny-lists,
378    /// fail-closed contracts and "never touch this file" rules live in that
379    /// prose, so a call site that looks wrappable may be deliberately locked.
380    governance_files: Vec<&'static str>,
381    /// The languages the static scan parses into an AST.
382    parsed_languages: &'static [&'static str],
383    /// One line naming the protocol that turns this evidence into a verdict,
384    /// for an agent that reached the tool without the skill.
385    protocol: &'static str,
386    /// File classes this report never parses. Shell/`Makefile`/CI files are
387    /// sighted coarsely by substring (see the `orchestration-blind-spot`
388    /// finding); governance prose is not read at all.
389    unparsed: &'static [&'static str],
390}
391
392/// The whole doctor report.
393#[derive(Debug, Serialize)]
394struct DoctorReport {
395    adapters: Vec<AdapterStatus>,
396    boundaries: Boundaries,
397    coverage: Coverage,
398    findings: Vec<Finding>,
399    follow_ups: Vec<FollowUp>,
400    journal: JournalReport,
401    ok: bool,
402    policy: PolicyCheck,
403    topology: Topology,
404}
405
406/// A policy validation outcome plus, when it failed on a specific field, the
407/// applyable fix: remove the offending entry. Keel's documented semantics make
408/// removal always safe — "delete anything; defaults still apply" — so the
409/// suggested patch drops the invalid entry rather than guessing a value.
410#[derive(Debug)]
411struct PolicyValidation {
412    check: PolicyCheck,
413    /// The declared `[flows.match."cmd:*"]` table (issue #41), so
414    /// `classify_topology` can cross-reference subprocess sightings — empty
415    /// when `keel.toml` is absent, invalid, or simply declares no rules
416    /// (the honest default: no rules means no sighting is ever "covered").
417    cmd_match: BTreeMap<String, FlowMatchRule>,
418    fix: Option<Proposal>,
419}
420
421/// A one-line advisory for `keel run`'s pre-exec preflight step (dx-spec's
422/// "before any calls fire" — a static scan of the whole source tree, run by
423/// the Rust CLI before the target process even starts, sees this more
424/// faithfully than hooking the Python/Node in-process bootstrap could: that
425/// bootstrap runs before the target script's own imports execute, so it
426/// could not actually see them yet). `None` when there's nothing to warn
427/// about — never runs `keel doctor`'s full report, just the one check that's
428/// cheap and relevant at this point.
429#[must_use]
430pub fn preflight_advisory(project: &Path) -> Option<String> {
431    let scan = scan::scan(project);
432    let registry_libs = registry_libs();
433    let finding = resilience_finding(&scan, &registry_libs)?;
434    Some(format!(
435        "keel \u{25b8} {}\n  next: {} (run `keel doctor --json` for detail; skip this check with \
436         --no-preflight or KEEL_SKIP_PREFLIGHT=1)",
437        finding.detail, finding.action
438    ))
439}
440
441/// Run `keel doctor` for `project`.
442pub fn run(project: &Path) -> Rendered {
443    let scan = scan::scan(project);
444    let discovery = match evidence::read_discovery(project) {
445        Ok(d) => d.into_iter().map(|s| s.target).collect(),
446        Err(e) => {
447            return Rendered {
448                human: format!("keel \u{25b8} doctor unavailable: {e}"),
449                json: to_json(&serde_json::json!({ "error": e })),
450                exit: crate::EXIT_FAILURE,
451                to_stderr: true,
452            };
453        }
454    };
455    let policy = validate_policy(&evidence::keel_toml(project));
456    let journal = JournalReport::from_resolved(&evidence::resolved_journal(project));
457    let agents_cli_finding = agents_cli_placement_finding(project);
458    let boundaries = boundaries(project);
459    let stale_flows = crate::flows::stale_code_hash_flows(project);
460    let report = build_report(
461        &scan,
462        &discovery,
463        policy,
464        journal,
465        agents_cli_finding,
466        boundaries,
467        &stale_flows,
468    );
469    let exit = if report.ok { EXIT_OK } else { EXIT_USAGE };
470    let human = human(&report);
471    Rendered::ok(human, to_json(&report)).with_exit(exit)
472}
473
474/// A pre-existing resilience library (e.g. `tenacity`, `backoff`) risks
475/// silently compounding with Keel's own retry/backoff/breaker — Keel patches
476/// at the transport layer, below this kind of user code, so it has no
477/// visibility into whether the library is actually configured to retry the
478/// same calls Keel wraps. Emitted only when the project ALSO uses at least
479/// one adapter library Keel wraps (`registry_libs`): a resilience library
480/// imported for something unrelated to any Keel-wrapped effect is not
481/// evidence of compounding, and flagging it anyway would be a false
482/// positive that erodes trust in doctor's other findings. Detected in both
483/// languages: `scan::python`'s `RESILIENCE_LIBS` (tenacity/backoff/
484/// retrying/stamina) and `scan::js`'s equivalent (p-retry/async-retry) —
485/// `got`'s built-in `retry` option is a different, non-import-based signal
486/// and not covered here.
487fn resilience_finding(scan: &ScanResult, registry_libs: &BTreeSet<&str>) -> Option<Finding> {
488    let compounds_with = scan
489        .libs
490        .iter()
491        .any(|lib| registry_libs.contains(lib.as_str()));
492    if scan.resilience_libs.is_empty() || !compounds_with {
493        return None;
494    }
495    let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
496    Some(Finding {
497        action: "Delete the old retry/backoff code if Keel's policy now covers it, or scope \
498                 Keel's policy to skip this target (e.g. `attempts = 1`) if you want to keep \
499                 relying on it — don't leave both running unconfigured against each other."
500            .to_owned(),
501        detail: format!(
502            "This project imports {} alongside at least one library Keel wraps — Keel cannot \
503             see whether {} is actually configured to retry the same calls, so retries may be \
504             silently compounding.",
505            libs.join(", "),
506            if libs.len() == 1 { "it" } else { "they" }
507        ),
508        fix: None,
509        level: "warn",
510        topic: "preexisting-resilience",
511    })
512}
513
514/// The three honesty findings that carry [`Topology`]'s buckets into the
515/// findings list: one `url-no-transport` warning per unreachable host, one
516/// `subprocess-blind-spot` warning naming every externally-launched process
517/// (if any), and one `dependency-averse-excluded` info per excluded host.
518/// None of these are configuration errors — they never affect `ok`.
519fn topology_findings(topology: &Topology) -> Vec<Finding> {
520    let mut findings = Vec::new();
521    for entry in &topology.unreachable {
522        findings.push(Finding {
523            action: "Trace how this request is actually dispatched before proposing policy; \
524                      Python's stdlib `urllib.request` is adapted — importing it directly in \
525                      that file makes the host wrappable."
526                .to_owned(),
527            detail: format!("`{}` — {}.", entry.host, entry.reason),
528            fix: None,
529            level: "warn",
530            topic: "url-no-transport",
531        });
532    }
533    let (covered, uncovered): (Vec<_>, Vec<_>) = topology
534        .external_processes
535        .iter()
536        .partition(|p| p.covered_by.is_some());
537    if !uncovered.is_empty() {
538        let cmds: Vec<String> = uncovered
539            .iter()
540            .map(|p| format!("`{}` ({} at {}:{})", p.command, p.launcher, p.file, p.line))
541            .collect();
542        findings.push(Finding {
543            action: "Confirm none of these processes carry traffic you care about; Keel must be \
544                      installed inside a process to see it."
545                .to_owned(),
546            detail: format!(
547                "Keel cannot see traffic inside {} externally-launched process(es): {}.",
548                uncovered.len(),
549                cmds.join(", ")
550            ),
551            fix: None,
552            level: "warn",
553            topic: "subprocess-blind-spot",
554        });
555    }
556    if !covered.is_empty() {
557        // Issue #41: a sighting matching a declared `[flows.match."cmd:*"]`
558        // rule is wrapped WHEN Keel is active in the process that runs it —
559        // a static scan cannot confirm activation, so this downgrades to
560        // `info` rather than dropping the sighting (overclaiming coverage
561        // doctor can't verify would be its own honesty violation).
562        let cmds: Vec<String> = covered
563            .iter()
564            .map(|p| {
565                format!(
566                    "`{}` ({} at {}:{}, matches `{}`)",
567                    p.command,
568                    p.launcher,
569                    p.file,
570                    p.line,
571                    p.covered_by.as_deref().unwrap_or_default()
572                )
573            })
574            .collect();
575        findings.push(Finding {
576            action: "No action needed unless the matching `[flows.match]` rule is wrong, or Keel \
577                      is not actually active in the process that runs this command."
578                .to_owned(),
579            detail: format!(
580                "{} externally-launched process(es) match a declared `[flows.match.\"cmd:*\"]` \
581                 rule and are wrapped when Keel is active in that process: {}.",
582                covered.len(),
583                cmds.join(", ")
584            ),
585            fix: None,
586            level: "info",
587            topic: "subprocess-blind-spot",
588        });
589    }
590    for entry in &topology.excluded {
591        findings.push(Finding {
592            action: "Confirm the exclusion is intended; add `# keel: include` to the file to \
593                      override."
594                .to_owned(),
595            detail: format!("`{}` — {}.", entry.host, entry.reason),
596            fix: None,
597            level: "info",
598            topic: "dependency-averse-excluded",
599        });
600    }
601    findings
602}
603
604/// The WS3 simplification findings: each hand-rolled pattern the scan
605/// sighted inside a target-reaching function becomes one paired finding —
606/// "here is the target; once Keel wraps it, the code at file:line is
607/// redundant". The level pairs with the topology bucket: `warn` when any of
608/// the sighting's targets is already wrappable (deleting the pattern is
609/// actionable now), `info` when wrapping itself is still pending (e.g. a
610/// stdlib-urllib transport before the urllib pack lands). Never affects
611/// `ok`. Interpolates only hosts, file paths, line numbers, and function
612/// names (the no-raw-source hardening rule).
613fn simplification_findings(scan: &ScanResult, topology: &Topology) -> Vec<Finding> {
614    let wrappable: BTreeSet<&str> = topology.wrappable.iter().map(String::as_str).collect();
615    let mut findings = Vec::new();
616    for s in &scan.simplifications {
617        let targets = s.targets.join(", ");
618        let actionable_now = s.targets.iter().any(|t| wrappable.contains(t.as_str()));
619        let (level, when) = if actionable_now {
620            ("warn", "Keel can wrap this target now")
621        } else {
622            ("info", "once Keel can wrap this target")
623        };
624        let (topic, what, action): (&'static str, String, String) = match s.kind.as_str() {
625            "hand-rolled-poll" => (
626                "hand-rolled-poll",
627                format!(
628                    "`{}` ({}:{}) hand-rolls poll-until-terminal against `{}` — {}, a `poll` \
629                     policy (interval / deadline / until) replaces the whole loop",
630                    s.function, s.file, s.line, targets, when
631                ),
632                "Wrap the target, then replace the loop with a `poll` policy — the poll \
633                 primitive (CCR-3) is the designated replacement for submit-then-poll loops."
634                    .to_owned(),
635            ),
636            "silent-swallow" => (
637                "silent-swallow",
638                format!(
639                    "`{}` ({}:{}) silences failures from `{}` with a broad `except` returning a \
640                     default — Keel replaces silence with retry + observability",
641                    s.function, s.file, s.line, targets
642                ),
643                "Wrap the target so Keel's policy owns the failure (retry, breaker, journal), \
644                 then narrow or remove the broad except."
645                    .to_owned(),
646            ),
647            "hand-rolled-retry" => (
648                "hand-rolled-retry",
649                format!(
650                    "`{}` ({}:{}) hand-rolls retry around `{}` — {}, this loop becomes redundant",
651                    s.function, s.file, s.line, targets, when
652                ),
653                "Wrap the target with Keel retry/backoff policy, then delete the hand-rolled \
654                 loop — don't run both."
655                    .to_owned(),
656            ),
657            other => {
658                // The scanner is the only producer of `simplifications`, and it emits
659                // exactly the three kinds matched above — an unrecognized kind is a
660                // scanner/doctor drift bug, not a real finding to mislabel and report.
661                // Fail loud where it's cheap to catch (debug/test builds); in release,
662                // skip rather than surface a wrong action.
663                debug_assert!(false, "unknown simplification kind: {other}");
664                continue;
665            }
666        };
667        findings.push(Finding {
668            action,
669            detail: format!("{what}."),
670            fix: None,
671            level,
672            topic,
673        });
674    }
675    findings
676}
677
678/// The rank table: ascending Keel-confidence. Rank 1 (url-no-transport) is
679/// the claim Keel knows least about — it saw a URL but cannot even name the
680/// dispatch path — so it is investigated first. Rank 2
681/// (orchestration-blind-spot) is a coarse substring match on a file Keel
682/// cannot parse at all — strictly less verifiable than rank 3
683/// (subprocess-blind-spot), which comes from an AST sighting of a real call
684/// — so it sorts above it. Rank 6 (code-hash-stale, reserved for the WS6
685/// emitter) is a mechanical, fully-verified fact that merely awaits a human
686/// decision.
687fn follow_up_rank(code: &str) -> u32 {
688    match code {
689        "url-no-transport" => 1,
690        "orchestration-blind-spot" => 2,
691        "subprocess-blind-spot" => 3,
692        "dependency-averse-excluded" => 4,
693        "preexisting-resilience" => 5,
694        _ => 6, // code-hash-stale (WS6)
695    }
696}
697
698/// The ranked follow-up list (WS2): computed from the same structured
699/// evidence as the findings — never by parsing finding text — then sorted by
700/// the stable key (rank, code, subject).
701fn build_follow_ups(
702    topology: &Topology,
703    resilience: Option<&Finding>,
704    scan: &ScanResult,
705    stale_flows: &[crate::flows::StaleFlow],
706) -> Vec<FollowUp> {
707    let mut ups = Vec::new();
708    for entry in &topology.unreachable {
709        ups.push(FollowUp {
710            code: "url-no-transport",
711            detail: format!(
712                "Trace how requests to `{}` are actually dispatched — {}.",
713                entry.host, entry.reason
714            ),
715            rank: follow_up_rank("url-no-transport"),
716            subject: entry.host.clone(),
717        });
718    }
719    if !scan.orchestration.is_empty() {
720        let mut files: Vec<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
721        files.dedup();
722        ups.push(FollowUp {
723            code: "orchestration-blind-spot",
724            detail: "Keel cannot parse shell/Makefile/CI files; these carry a coarse \
725                     at-most-once-dispatch signature (lockfile/guard/PID check). Confirm \
726                     whether each is real dispatch gating a `cmd:` flow could replace."
727                .to_owned(),
728            rank: follow_up_rank("orchestration-blind-spot"),
729            subject: format!("{} orchestration file(s)", files.len()),
730        });
731    }
732    // Issue #41: a sighting matching a declared `[flows.match."cmd:*"]` rule
733    // is covered when Keel is active, so it drops out of this "investigate
734    // top-down" list entirely — it needs no chasing, only the lower-priority
735    // `info` finding `topology_findings` still emits for it.
736    let uncovered: Vec<&ExternalProcess> = topology
737        .external_processes
738        .iter()
739        .filter(|p| p.covered_by.is_none())
740        .collect();
741    if !uncovered.is_empty() {
742        let cmds: Vec<String> = uncovered
743            .iter()
744            .map(|p| format!("`{}` ({}:{})", p.command, p.file, p.line))
745            .collect();
746        ups.push(FollowUp {
747            code: "subprocess-blind-spot",
748            detail: format!(
749                "Keel cannot see traffic inside externally-launched processes; confirm none of \
750                 these carry traffic you care about: {}.",
751                cmds.join(", ")
752            ),
753            rank: follow_up_rank("subprocess-blind-spot"),
754            subject: format!("{} externally-launched process(es)", uncovered.len()),
755        });
756    }
757    for entry in &topology.excluded {
758        ups.push(FollowUp {
759            code: "dependency-averse-excluded",
760            detail: format!(
761                "`{}` was excluded from proposed policy — {}. Confirm the exclusion is intended.",
762                entry.host, entry.reason
763            ),
764            rank: follow_up_rank("dependency-averse-excluded"),
765            subject: entry.host.clone(),
766        });
767    }
768    if resilience.is_some() {
769        let libs: Vec<&str> = scan.resilience_libs.iter().map(String::as_str).collect();
770        ups.push(FollowUp {
771            code: "preexisting-resilience",
772            detail: format!(
773                "Decide whether {} still needs its own retry/backoff now that Keel wraps the \
774                 same calls — delete the old code or scope Keel's policy, not both.",
775                libs.join(", ")
776            ),
777            rank: follow_up_rank("preexisting-resilience"),
778            subject: libs.join(", "),
779        });
780    }
781    for flow in stale_flows {
782        ups.push(FollowUp {
783            code: "code-hash-stale",
784            detail: format!(
785                "Flow `{}` ({}) was recorded under a different code hash than its current \
786                 script; resuming would replay recorded steps against changed code (the resume \
787                 fence downgrades nondeterminism fail->warn). Inspect with `keel replay {}` \
788                 before resuming.",
789                flow.flow_id, flow.entrypoint, flow.flow_id
790            ),
791            rank: follow_up_rank("code-hash-stale"),
792            subject: flow.flow_id.clone(),
793        });
794    }
795    ups.sort_by(|a, b| {
796        (a.rank, a.code, a.subject.as_str()).cmp(&(b.rank, b.code, b.subject.as_str()))
797    });
798    ups
799}
800
801/// A root `keel.toml` in a Google `agents-cli` project (an
802/// `agents-cli-manifest.yaml` naming an `agent_directory`) never reaches the
803/// container: the generated Dockerfile only `COPY`s `pyproject.toml`,
804/// `README.md`, `uv.lock*`, and the agent directory itself. Emitted only when
805/// a manifest is found, `<project>/keel.toml` actually exists, and the agent
806/// directory is not `project` itself — when `agent_directory` names the
807/// project root, the root `keel.toml` already sits inside the one directory
808/// the Dockerfile ships, so there is no placement problem to report.
809fn agents_cli_placement_finding(project: &Path) -> Option<Finding> {
810    let layout = agents_cli::find_agents_cli_layout(project)?;
811    if layout.agent_dir == project || !evidence::keel_toml(project).exists() {
812        return None;
813    }
814    // Display paths relative to `project` (the common case: `agent_directory`
815    // names a subdirectory of the project it's declared in) rather than the
816    // absolute filesystem path — keeps the finding's text, and therefore
817    // `--json`, reproducible across checkouts instead of embedding wherever
818    // this particular clone happens to sit on disk.
819    let agent_dir = relative_display(project, &layout.agent_dir);
820    let moved_to = relative_display(project, &layout.agent_dir.join("keel.toml"));
821    Some(Finding {
822        action: format!(
823            "Move keel.toml to {moved_to} (or add a `COPY keel.toml` line to the Dockerfile)."
824        ),
825        detail: format!(
826            "This is an agents-cli project — its generated Dockerfile only COPYs \
827             pyproject.toml, README.md, uv.lock*, and {agent_dir} into the image, so the \
828             keel.toml at the project root never ships to the container."
829        ),
830        fix: None,
831        level: "warn",
832        topic: "agents-cli-config-placement",
833    })
834}
835
836/// `target` relative to `base` when it is actually nested under `base`, else
837/// the absolute path unchanged (a manifest found above `project`, or on a
838/// different mount — pathological, but must not panic or produce nonsense
839/// like `../../../../tmp/x`).
840fn relative_display(base: &Path, target: &Path) -> String {
841    target.strip_prefix(base).map_or_else(
842        |_| target.display().to_string(),
843        |rel| rel.display().to_string(),
844    )
845}
846
847/// Build the [`Boundaries`] frame for `project`. Only `governance_files` touches
848/// the filesystem; the rest are standing properties of this tool, kept in one
849/// place so there is a single edit when the scan learns a new language or file
850/// class. The `protocol` line enumerates the skill's five phases verbatim — if
851/// `skills/keel/SKILL.md`'s protocol changes, change this with it.
852fn boundaries(project: &Path) -> Boundaries {
853    let mut governance_files = Vec::new();
854    if project.join("CLAUDE.md").exists() {
855        governance_files.push("CLAUDE.md");
856    }
857    if project.join("AGENTS.md").exists() {
858        governance_files.push("AGENTS.md");
859    }
860    Boundaries {
861        governance_files,
862        parsed_languages: &["python", "js-ts"],
863        protocol: "Static + adapter-interception evidence, not a verdict. Drive an \
864                   evaluate/adopt/review task through the keel skill's five phases: Scope every \
865                   I/O process (including shell/CI launchers) -> Explore how each call is \
866                   dispatched -> Collect this report -> Baseline real failure classes in observe \
867                   mode (`keel record run`) -> Analyze & propose. Retry only helps \
868                   genuinely-transient classes (conn/timeout/5xx/429).",
869        unparsed: &["shell", "makefile", "ci-workflow", "governance-prose"],
870    }
871}
872
873/// An unsupported journal backend is an error finding: the app would fail to
874/// configure with KEEL-E005, so doctor must not read clean.
875fn journal_finding(journal: &JournalReport) -> Option<Finding> {
876    (!journal.supported).then(|| Finding {
877        action: "Use a `file:` location (or drop the key for the default .keel/journal.db); Postgres support is future work — see docs.".to_owned(),
878        detail: format!(
879            "keel.toml sets `journal` to a {} location, but this build has no {} backend — the app will fail to configure with KEEL-E005.",
880            journal.backend, journal.backend
881        ),
882        fix: None,
883        level: "error",
884        topic: "journal",
885    })
886}
887
888/// Assemble the report from the seven evidence inputs. Pure, so the golden test
889/// pins it without a filesystem or `python3` — the filesystem-dependent
890/// inputs (`agents_cli_finding`, since it needs to walk for a manifest and
891/// check for a root `keel.toml`; `boundaries`, since it stats the project root
892/// for governance files; `stale_flows`, since it needs to read
893/// `.keel/journal.db` and stat scripts on disk) are computed by the caller
894/// and passed in already resolved, the same pattern `policy`/`journal`
895/// already use.
896#[allow(clippy::too_many_lines)] // straight-line report assembly, one section per
897// DoctorReport field; issue #41 added the cmd_match plumbing, not new complexity.
898fn build_report(
899    scan: &ScanResult,
900    wrapped_targets: &BTreeSet<String>,
901    policy: PolicyValidation,
902    journal: JournalReport,
903    agents_cli_finding: Option<Finding>,
904    boundaries: Boundaries,
905    stale_flows: &[crate::flows::StaleFlow],
906) -> DoctorReport {
907    let PolicyValidation {
908        check: policy,
909        cmd_match,
910        fix,
911    } = policy;
912    let registry_libs = registry_libs();
913
914    // Coverage from the target sets.
915    let visible: BTreeSet<&String> = scan.targets.keys().collect();
916    let wrapped: Vec<String> = wrapped_targets.iter().cloned().collect();
917    let visible_unwrapped: Vec<String> = visible
918        .iter()
919        .filter(|t| !wrapped_targets.contains(**t))
920        .map(|t| (*t).clone())
921        .collect();
922    let invisible: Vec<String> = scan
923        .libs
924        .iter()
925        .filter(|lib| !registry_libs.contains(lib.as_str()))
926        .cloned()
927        .collect();
928
929    // Topology: sort every sighted host into exactly one of the three honesty
930    // buckets, plus the host-independent external-process signal — see
931    // [`classify_topology`].
932    let topology = classify_topology(scan, wrapped_targets, &cmd_match);
933
934    // Adapter registry annotated with detection.
935    let adapters: Vec<AdapterStatus> = REGISTRY
936        .iter()
937        .map(|a| AdapterStatus {
938            detected: scan.libs.contains(a.lib),
939            lib: a.lib,
940            status: if a.best_effort {
941                "best-effort"
942            } else {
943                "pinned"
944            },
945            target: a.target,
946        })
947        .collect();
948
949    // Findings + suggested actions.
950    let mut findings = Vec::new();
951    for target in &visible_unwrapped {
952        findings.push(Finding {
953            action:
954                "Run `keel run <script>` so Keel can confirm this target is wrapped at runtime."
955                    .to_owned(),
956            detail: format!(
957                "`{target}` is visible in your code but has no observed runtime evidence."
958            ),
959            fix: None,
960            level: "warn",
961            topic: "visible-unwrapped",
962        });
963    }
964    for lib in &invisible {
965        findings.push(Finding {
966            action: format!("No adapter for `{lib}` yet — its calls are invisible to Keel. Track adapter support or wrap manually."),
967            detail: format!("`{lib}` is imported but has no adapter in the registry."),
968            fix: None,
969            level: "warn",
970            topic: "invisible",
971        });
972    }
973    // Always: the honest advisory about what static + adapter interception can't see.
974    findings.push(Finding {
975        action: "If a dependency makes calls Keel never reports, file an adapter request.".to_owned(),
976        detail: "Raw sockets and unknown native libraries are invisible to static and adapter-based interception.".to_owned(),
977        fix: None,
978        level: "info",
979        topic: "invisible",
980    });
981    // Conditional: unparsed orchestration files that hand-roll at-most-once
982    // dispatch. A lead, not a verdict — the scan cannot parse these files, so
983    // the finding names where to look and never claims what it found.
984    if !scan.orchestration.is_empty() {
985        const MAX_LISTED: usize = 5;
986        let mut files: Vec<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
987        // `scan.orchestration` is sorted by (file, line, kind), so same-file
988        // entries are adjacent and `dedup` is exact.
989        files.dedup();
990        let shown = files.len().min(MAX_LISTED);
991        let mut list = files[..shown]
992            .iter()
993            .map(|f| format!("`{f}`"))
994            .collect::<Vec<_>>()
995            .join(", ");
996        if files.len() > shown {
997            let rest = files.len() - shown;
998            let _ = write!(list, " and {rest} more");
999        }
1000        findings.push(Finding {
1001            action: "Inspect these files for hand-rolled at-most-once dispatch (lockfile/guard/\
1002                     PID checks). A durable `cmd:` flow replaces it crash-safely: `keel exec \
1003                     --flow` for a standalone launcher, or `[flows.match.\"cmd:<name>\"]` when \
1004                     the call is made from inside an already-Keel-active process."
1005                .to_owned(),
1006            detail: format!(
1007                "Static scan cannot parse these orchestration files, but sighted the \
1008                 at-most-once-dispatch signature in: {list}."
1009            ),
1010            fix: None,
1011            level: "warn",
1012            topic: "orchestration-blind-spot",
1013        });
1014    }
1015    findings.extend(topology_findings(&topology));
1016    findings.extend(simplification_findings(scan, &topology));
1017    if !policy.valid && policy.present {
1018        let field = policy.field.clone().unwrap_or_default();
1019        let mut action = "Fix the field above, then re-run `keel doctor`; validate against contracts/policy.schema.json.".to_owned();
1020        if fix.is_some() {
1021            action.push_str(
1022                " Or apply the attached patch (`git apply`) to remove the invalid entry — defaults cover it.",
1023            );
1024        }
1025        findings.push(Finding {
1026            action,
1027            detail: format!(
1028                "keel.toml failed validation at `{field}`: {}",
1029                policy.message.clone().unwrap_or_default()
1030            ),
1031            fix,
1032            level: "error",
1033            topic: "policy",
1034        });
1035    }
1036    let resilience = resilience_finding(scan, &registry_libs);
1037    let follow_ups = build_follow_ups(&topology, resilience.as_ref(), scan, stale_flows);
1038    findings.extend(resilience);
1039    findings.extend(journal_finding(&journal));
1040    findings.extend(agents_cli_finding);
1041
1042    let ok = (policy.valid || !policy.present) && journal.supported;
1043    DoctorReport {
1044        adapters,
1045        boundaries,
1046        coverage: Coverage {
1047            invisible,
1048            visible_unwrapped,
1049            wrapped,
1050        },
1051        findings,
1052        follow_ups,
1053        journal,
1054        ok,
1055        policy,
1056        topology,
1057    }
1058}
1059
1060/// Sort every host the static scan saw into exactly one of the three honesty
1061/// buckets (dx-spec §2 — "wrap it" / "can't reach it" / "shouldn't reach
1062/// it"), plus the host-independent external-process signal. Precedence: a
1063/// wrapped-at-runtime target or an `llm:*` target is wrappable by
1064/// construction regardless of transport class (runtime evidence, or the LLM
1065/// pack's own wrapping, beats static doubt); otherwise a target seen ONLY
1066/// inside a dependency-averse file is excluded (shouldn't reach it) ahead of
1067/// any transport check; otherwise the transport class decides wrappable
1068/// (tracked) vs. unreachable (untracked-known/unknown). `pub(crate)`:
1069/// `init.rs` reuses this directly for `keel init --diff` to skip proposing
1070/// policy for excluded hosts and print why (passing an empty `cmd_match` —
1071/// `--diff` never touches `external_processes`, so cross-referencing it
1072/// there would be dead work).
1073pub(crate) fn classify_topology(
1074    scan: &ScanResult,
1075    wrapped_targets: &BTreeSet<String>,
1076    cmd_match: &BTreeMap<String, FlowMatchRule>,
1077) -> Topology {
1078    let dep_files: BTreeSet<&str> = scan
1079        .dependency_averse
1080        .iter()
1081        .map(|d| d.file.as_str())
1082        .collect();
1083    let mut wrappable = Vec::new();
1084    let mut unreachable = Vec::new();
1085    let mut excluded = Vec::new();
1086    for (target, ev) in &scan.targets {
1087        if wrapped_targets.contains(target) || target.starts_with("llm:") {
1088            wrappable.push(target.clone());
1089            continue;
1090        }
1091        let only_dep_averse = !ev.sightings.is_empty()
1092            && ev
1093                .sightings
1094                .iter()
1095                .all(|s| dep_files.contains(s.file.as_str()));
1096        if only_dep_averse {
1097            let files: BTreeSet<&str> = ev.sightings.iter().map(|s| s.file.as_str()).collect();
1098            excluded.push(TopologyEntry {
1099                host: target.clone(),
1100                reason: format!(
1101                    "seen only in dependency-averse file(s) {} — excluded from proposed policy; \
1102                     add `# keel: include` to override",
1103                    files.into_iter().collect::<Vec<_>>().join(", ")
1104                ),
1105            });
1106            continue;
1107        }
1108        match scan
1109            .host_transports
1110            .get(target)
1111            .copied()
1112            .unwrap_or(TransportClass::Unknown)
1113        {
1114            TransportClass::Tracked => wrappable.push(target.clone()),
1115            TransportClass::UntrackedKnown => unreachable.push(TopologyEntry {
1116                host: target.clone(),
1117                reason: "reached via a stdlib transport Keel does not adapt (http.client, or \
1118                         urllib without urllib.request; Python's urllib.request itself is \
1119                         adapted)"
1120                    .to_owned(),
1121            }),
1122            TransportClass::Unknown => unreachable.push(TopologyEntry {
1123                host: target.clone(),
1124                reason: "URL literal with no tracked transport in reach — trace how this request \
1125                          is dispatched"
1126                    .to_owned(),
1127            }),
1128        }
1129    }
1130    let cmd_rules = compile_cmd_rules(cmd_match);
1131    let external_processes: Vec<ExternalProcess> = scan
1132        .subprocesses
1133        .iter()
1134        .map(|s| ExternalProcess {
1135            command: s.command.clone(),
1136            covered_by: cmd_flow_covering(&cmd_rules, s),
1137            file: s.file.clone(),
1138            launcher: s.launcher.clone(),
1139            line: s.line,
1140        })
1141        .collect();
1142    Topology {
1143        excluded,
1144        external_processes,
1145        unreachable,
1146        wrappable,
1147    }
1148}
1149
1150/// The launcher names `python/keel/src/keel/adapters/subprocess_pack.py`'s
1151/// runtime interceptor actually wraps (issue #41's "Coverage" section):
1152/// `subprocess.run`/`check_output`/`call`/`check_call`, patched directly or
1153/// via a same-module call the patched name resolves. Deliberately excludes
1154/// `subprocess.Popen` (the scanner sights it — see `SUBPROC_NAMES` — but the
1155/// pack never patches it) and `os.system`/`os.popen` (a different launch
1156/// shape the pack's own docs say it never matches). Node's launchers are
1157/// never in this list: `SubprocessSighting::argv` is always `None` for a JS
1158/// sighting today (see `record_subprocess`'s doc), so the `argv.is_some()`
1159/// gate below already excludes them; this list is the second, explicit gate
1160/// so that invariant isn't the ONLY thing standing between a scanner change
1161/// and a false "covered" claim.
1162const INTERCEPTED_CMD_LAUNCHERS: &[&str] = &[
1163    "subprocess.run",
1164    "subprocess.check_output",
1165    "subprocess.call",
1166    "subprocess.check_call",
1167];
1168
1169/// The `cmd:<name>` entrypoint `sighting` is covered by, or `None` — issue
1170/// #41. A sighting is only ever a match candidate when its launcher is one
1171/// the runtime pack actually intercepts AND the scanner captured a genuine
1172/// positional argv (`argv.is_some()`; see [`scan::SubprocessSighting::argv`]'s
1173/// doc for the exact conditions — list/tuple of literals, no `shell=True`).
1174fn cmd_flow_covering(
1175    rules: &[crate::cmd_match::CompiledCmdRule],
1176    sighting: &scan::SubprocessSighting,
1177) -> Option<String> {
1178    if !INTERCEPTED_CMD_LAUNCHERS.contains(&sighting.launcher.as_str()) {
1179        return None;
1180    }
1181    let argv = sighting.argv.as_ref()?;
1182    match_argv(rules, argv).map(str::to_owned)
1183}
1184
1185/// Validate `keel.toml` against the typed [`Policy`] model, reporting the exact
1186/// field path on error (via `serde_path_to_error`) and, when a field is at
1187/// fault, attaching the applyable removal fix.
1188fn validate_policy(path: &Path) -> PolicyValidation {
1189    if !path.exists() {
1190        return PolicyValidation {
1191            check: PolicyCheck {
1192                field: None,
1193                message: None,
1194                present: false,
1195                valid: true,
1196            },
1197            cmd_match: BTreeMap::new(),
1198            fix: None,
1199        };
1200    }
1201    let Ok(text) = std::fs::read_to_string(path) else {
1202        return invalid(None, "keel.toml exists but could not be read", None);
1203    };
1204    let toml_value: toml::Value = match text.parse() {
1205        Ok(v) => v,
1206        Err(e) => return invalid(None, &format!("keel.toml is not valid TOML: {e}"), None),
1207    };
1208    let json_value = match serde_json::to_value(&toml_value) {
1209        Ok(v) => v,
1210        Err(e) => {
1211            return invalid(
1212                None,
1213                &format!("keel.toml could not be normalized: {e}"),
1214                None,
1215            );
1216        }
1217    };
1218    match serde_path_to_error::deserialize::<_, Policy>(&json_value) {
1219        Ok(policy) => PolicyValidation {
1220            check: PolicyCheck {
1221                field: None,
1222                message: None,
1223                present: true,
1224                valid: true,
1225            },
1226            cmd_match: policy.flows.and_then(|f| f.match_).unwrap_or_default(),
1227            fix: None,
1228        },
1229        Err(e) => {
1230            let field = e.path().to_string();
1231            let fix = suggest_removal(&text, &field);
1232            invalid(Some(field), &e.inner().to_string(), fix)
1233        }
1234    }
1235}
1236
1237fn invalid(field: Option<String>, message: &str, fix: Option<Proposal>) -> PolicyValidation {
1238    PolicyValidation {
1239        check: PolicyCheck {
1240            field,
1241            message: Some(message.to_owned()),
1242            present: true,
1243            valid: false,
1244        },
1245        cmd_match: BTreeMap::new(),
1246        fix,
1247    }
1248}
1249
1250/// The deepest path a removal fix targets: `target."…".<key>` — dropping the
1251/// whole top-level entry under the target keeps the remainder trivially valid,
1252/// where surgically deleting one nested field might leave an invalid stub.
1253const MAX_FIX_DEPTH: usize = 3;
1254
1255/// Synthesize the applyable fix for an invalid policy field: delete the
1256/// offending entry (truncated to its top-level key under the target). Returns
1257/// `None` when the field path cannot be resolved back into the document.
1258fn suggest_removal(text: &str, field: &str) -> Option<Proposal> {
1259    let resolved = resolve_dotted_path(text, field)?;
1260    let segments = resolved.segments();
1261    let cut = segments.len().min(MAX_FIX_DEPTH);
1262    let path = PolicyPath::new(segments[..cut].iter().cloned());
1263    let proposal = propose(Some(text), &[PolicyOp::Remove { path }]).ok()?;
1264    if proposal.patch.is_empty() {
1265        None
1266    } else {
1267        Some(proposal)
1268    }
1269}
1270
1271/// The human report, derived from [`DoctorReport`] so no fact escapes the JSON.
1272#[allow(clippy::too_many_lines)] // straight-line rendering, one section per
1273// DoctorReport field; the boundaries section added a few lines, not new complexity.
1274fn human(r: &DoctorReport) -> String {
1275    let mut out = String::from("keel \u{25b8} doctor\n");
1276
1277    out.push_str("\ncoverage\n");
1278    line_list(&mut out, "  wrapped:          ", &r.coverage.wrapped);
1279    line_list(
1280        &mut out,
1281        "  visible-unwrapped:",
1282        &r.coverage.visible_unwrapped,
1283    );
1284    line_list(&mut out, "  invisible:        ", &r.coverage.invisible);
1285
1286    out.push_str("\ntopology\n");
1287    line_list(&mut out, "  wrap it:          ", &r.topology.wrappable);
1288    for e in &r.topology.unreachable {
1289        let line = format!("  can't reach:       {} — {}\n", e.host, e.reason);
1290        out.push_str(&line);
1291    }
1292    for e in &r.topology.excluded {
1293        let line = format!("  shouldn't reach:   {} — {}\n", e.host, e.reason);
1294        out.push_str(&line);
1295    }
1296    for p in &r.topology.external_processes {
1297        let line = format!(
1298            "  external process:  {} ({} at {}:{})\n",
1299            p.command, p.launcher, p.file, p.line
1300        );
1301        out.push_str(&line);
1302    }
1303
1304    out.push_str("\nadapters\n");
1305    for a in &r.adapters {
1306        let mark = if a.detected { "\u{2713}" } else { " " };
1307        let line = format!(
1308            "  [{mark}] {lib:<10} {status:<12} -> {target}\n",
1309            lib = a.lib,
1310            status = a.status,
1311            target = a.target,
1312        );
1313        out.push_str(&line);
1314    }
1315
1316    out.push_str("\npolicy\n");
1317    if !r.policy.present {
1318        out.push_str("  no keel.toml — smart defaults apply. `keel init` to customize.\n");
1319    } else if r.policy.valid {
1320        out.push_str("  keel.toml is valid.\n");
1321    } else {
1322        let line = format!(
1323            "  keel.toml INVALID at `{}`: {}\n",
1324            r.policy.field.clone().unwrap_or_default(),
1325            r.policy.message.clone().unwrap_or_default(),
1326        );
1327        out.push_str(&line);
1328    }
1329
1330    out.push_str("\njournal\n");
1331    let journal_line = if r.journal.supported {
1332        format!(
1333            "  {} at {} ({})\n",
1334            r.journal.backend, r.journal.location, r.journal.source
1335        )
1336    } else {
1337        format!(
1338            "  {} at {} ({}) — NOT supported in this build (KEEL-E005)\n",
1339            r.journal.backend, r.journal.location, r.journal.source
1340        )
1341    };
1342    out.push_str(&journal_line);
1343
1344    if !r.findings.is_empty() {
1345        out.push_str("\nfindings\n");
1346        for f in &r.findings {
1347            let line = format!(
1348                "  [{}] {}\n        \u{2192} {}\n",
1349                f.level, f.detail, f.action
1350            );
1351            out.push_str(&line);
1352            if let Some(fix) = &f.fix {
1353                // Verbatim (unindented) so copy-paste into `git apply` works.
1354                out.push_str("        patch (apply with `git apply`):\n");
1355                out.push_str(&fix.patch);
1356            }
1357        }
1358    }
1359    if !r.follow_ups.is_empty() {
1360        out.push_str("\nfollow-ups (work top-down; 1 = Keel knows least)\n");
1361        for f in &r.follow_ups {
1362            let line = format!("  {}. [{}] {} — {}\n", f.rank, f.code, f.subject, f.detail);
1363            out.push_str(&line);
1364        }
1365    }
1366    out.push_str("\nboundaries\n");
1367    let parsed = format!(
1368        "  parsed:            {} — {} not parsed (shell/Makefile/CI sighted coarsely only)\n",
1369        r.boundaries.parsed_languages.join(", "),
1370        r.boundaries.unparsed.join(", "),
1371    );
1372    out.push_str(&parsed);
1373    if !r.boundaries.governance_files.is_empty() {
1374        let gov = format!(
1375            "  governance:        {} — read before applying policy; this report can't parse it\n",
1376            r.boundaries.governance_files.join(", "),
1377        );
1378        out.push_str(&gov);
1379    }
1380    out.push_str(
1381        "  next:              evidence, not a verdict — see the keel skill's evaluation protocol\n",
1382    );
1383
1384    let tail = format!(
1385        "\n{}\n",
1386        if r.ok {
1387            "ok"
1388        } else {
1389            "configuration error (exit 2)"
1390        }
1391    );
1392    out.push_str(&tail);
1393    out
1394}
1395
1396fn line_list(out: &mut String, label: &str, items: &[String]) {
1397    let line = if items.is_empty() {
1398        format!("{label} (none)\n")
1399    } else {
1400        format!("{label} {}\n", items.join(", "))
1401    };
1402    out.push_str(&line);
1403}
1404
1405#[cfg(test)]
1406mod tests {
1407    use super::*;
1408    use crate::scan::{Sighting, TargetClass, TargetEvidence};
1409
1410    /// The default journal report (no `journal` key in keel.toml).
1411    fn default_journal() -> JournalReport {
1412        JournalReport {
1413            backend: "sqlite",
1414            location: ".keel/journal.db".to_owned(),
1415            source: "default",
1416            supported: true,
1417        }
1418    }
1419
1420    fn scan_with(target: &str, class: TargetClass, libs: &[&str]) -> ScanResult {
1421        let mut s = ScanResult {
1422            files_scanned: 1,
1423            python_available: true,
1424            ..ScanResult::default()
1425        };
1426        s.targets.insert(
1427            target.to_owned(),
1428            TargetEvidence {
1429                class,
1430                sightings: [Sighting {
1431                    file: "app.py".into(),
1432                    line: 1,
1433                }]
1434                .into_iter()
1435                .collect(),
1436            },
1437        );
1438        s.libs = libs.iter().map(|l| (*l).to_owned()).collect();
1439        s
1440    }
1441
1442    #[test]
1443    fn wrapped_visible_and_invisible_are_classified() {
1444        // "django" stands in for any effect library with no adapter in the
1445        // registry (boto3/psycopg both gained one — see REGISTRY above).
1446        let scan = scan_with("llm:openai", TargetClass::Llm, &["openai", "django"]);
1447        // discovery observed a DIFFERENT target than the visible one.
1448        let wrapped: BTreeSet<String> = ["api.observed.com".to_owned()].into_iter().collect();
1449        let policy = PolicyValidation {
1450            check: PolicyCheck {
1451                field: None,
1452                message: None,
1453                present: false,
1454                valid: true,
1455            },
1456            cmd_match: BTreeMap::new(),
1457            fix: None,
1458        };
1459        let r = build_report(
1460            &scan,
1461            &wrapped,
1462            policy,
1463            default_journal(),
1464            None,
1465            empty_boundaries(),
1466            &[],
1467        );
1468
1469        assert_eq!(r.coverage.wrapped, vec!["api.observed.com"]);
1470        assert_eq!(r.coverage.visible_unwrapped, vec!["llm:openai"]);
1471        assert_eq!(
1472            r.coverage.invisible,
1473            vec!["django"],
1474            "django has no adapter"
1475        );
1476        assert!(r.ok, "no policy present → ok");
1477        // openai adapter detected + pinned.
1478        let openai = r.adapters.iter().find(|a| a.lib == "openai").unwrap();
1479        assert!(openai.detected);
1480        assert_eq!(openai.status, "pinned");
1481    }
1482
1483    #[test]
1484    fn topology_buckets_classify_hosts_honestly() {
1485        use crate::scan::{DepAverseFile, SubprocessSighting, TransportClass};
1486        let mut scan = ScanResult {
1487            files_scanned: 3,
1488            python_available: true,
1489            ..ScanResult::default()
1490        };
1491        // wrappable: tracked transport.
1492        scan.targets.insert(
1493            "api.ok.com".into(),
1494            TargetEvidence {
1495                class: TargetClass::Host,
1496                sightings: [Sighting {
1497                    file: "app.py".into(),
1498                    line: 3,
1499                }]
1500                .into_iter()
1501                .collect(),
1502            },
1503        );
1504        scan.host_transports
1505            .insert("api.ok.com".into(), TransportClass::Tracked);
1506        // unreachable: untracked-known transport.
1507        scan.targets.insert(
1508            "api.stdlib.com".into(),
1509            TargetEvidence {
1510                class: TargetClass::Host,
1511                sightings: [Sighting {
1512                    file: "screen.py".into(),
1513                    line: 9,
1514                }]
1515                .into_iter()
1516                .collect(),
1517            },
1518        );
1519        scan.host_transports
1520            .insert("api.stdlib.com".into(), TransportClass::UntrackedKnown);
1521        // excluded: sighted ONLY inside a dependency-averse file (transport
1522        // class irrelevant).
1523        scan.targets.insert(
1524            "api.broker.com".into(),
1525            TargetEvidence {
1526                class: TargetClass::Host,
1527                sightings: [Sighting {
1528                    file: "risk_gate.py".into(),
1529                    line: 20,
1530                }]
1531                .into_iter()
1532                .collect(),
1533            },
1534        );
1535        scan.host_transports
1536            .insert("api.broker.com".into(), TransportClass::UntrackedKnown);
1537        scan.dependency_averse.push(DepAverseFile {
1538            file: "risk_gate.py".into(),
1539            reason: "stdlib-only + name/docstring signal: risk".into(),
1540        });
1541        scan.subprocesses.push(SubprocessSighting {
1542            file: "mcp.sh.py".into(),
1543            line: 12,
1544            launcher: "subprocess.run".into(),
1545            command: "uvx alpaca-mcp-server".into(),
1546            argv: Some(vec!["uvx".into(), "alpaca-mcp-server".into()]),
1547        });
1548        let r = build_report(
1549            &scan,
1550            &BTreeSet::new(),
1551            default_policy(),
1552            default_journal(),
1553            None,
1554            empty_boundaries(),
1555            &[],
1556        );
1557        assert_eq!(r.topology.wrappable, vec!["api.ok.com"]);
1558        assert_eq!(r.topology.unreachable.len(), 1);
1559        assert_eq!(r.topology.unreachable[0].host, "api.stdlib.com");
1560        assert_eq!(r.topology.excluded.len(), 1);
1561        assert_eq!(r.topology.excluded[0].host, "api.broker.com");
1562        assert!(r.topology.excluded[0].reason.contains("risk_gate.py"));
1563        assert_eq!(r.topology.external_processes.len(), 1);
1564        assert_eq!(
1565            r.topology.external_processes[0].command,
1566            "uvx alpaca-mcp-server"
1567        );
1568        // findings carry the honesty.
1569        assert!(
1570            r.findings
1571                .iter()
1572                .any(|f| f.topic == "url-no-transport" && f.level == "warn")
1573        );
1574        assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
1575            && f.level == "warn"
1576            && f.detail.contains("uvx alpaca-mcp-server")));
1577        assert!(
1578            r.findings
1579                .iter()
1580                .any(|f| f.topic == "dependency-averse-excluded" && f.level == "info")
1581        );
1582        // ok is unaffected: honesty findings are not configuration errors.
1583        assert!(r.ok);
1584    }
1585
1586    /// Issue #41: a subprocess sighting whose launcher/argv the runtime pack
1587    /// actually intercepts, and whose argv matches a declared
1588    /// `[flows.match."cmd:*"]` rule, is downgraded (info, `covered_by` set,
1589    /// excluded from the rank-3 follow-up) rather than nagged about — while
1590    /// an unmatched sighting alongside it keeps the full `warn` + follow-up
1591    /// treatment `topology_buckets_classify_hosts_honestly` already pins.
1592    #[test]
1593    fn covered_subprocess_sighting_is_downgraded_not_dropped() {
1594        use crate::scan::SubprocessSighting;
1595        let mut scan = ScanResult {
1596            files_scanned: 1,
1597            python_available: true,
1598            ..ScanResult::default()
1599        };
1600        scan.subprocesses.push(SubprocessSighting {
1601            file: "etl.py".into(),
1602            line: 9,
1603            launcher: "subprocess.run".into(),
1604            command: "etl run".into(),
1605            argv: Some(vec!["etl".into(), "run".into()]),
1606        });
1607        scan.subprocesses.push(SubprocessSighting {
1608            file: "backup.py".into(),
1609            line: 20,
1610            launcher: "subprocess.run".into(),
1611            command: "backup now".into(),
1612            argv: Some(vec!["backup".into(), "now".into()]),
1613        });
1614        let mut policy = default_policy();
1615        policy.check.present = true;
1616        policy.cmd_match.insert(
1617            "cmd:etl".to_owned(),
1618            FlowMatchRule {
1619                argv: vec!["etl".into(), "run".into()],
1620            },
1621        );
1622        let r = build_report(
1623            &scan,
1624            &BTreeSet::new(),
1625            policy,
1626            default_journal(),
1627            None,
1628            empty_boundaries(),
1629            &[],
1630        );
1631
1632        assert_eq!(r.topology.external_processes.len(), 2);
1633        let etl = r
1634            .topology
1635            .external_processes
1636            .iter()
1637            .find(|p| p.command == "etl run")
1638            .expect("etl sighting present");
1639        assert_eq!(etl.covered_by.as_deref(), Some("cmd:etl"));
1640        let backup = r
1641            .topology
1642            .external_processes
1643            .iter()
1644            .find(|p| p.command == "backup now")
1645            .expect("backup sighting present");
1646        assert_eq!(backup.covered_by, None);
1647
1648        // The covered sighting gets an `info` finding naming the match...
1649        assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
1650            && f.level == "info"
1651            && f.detail.contains("etl run")
1652            && f.detail.contains("cmd:etl")));
1653        // ...the uncovered one keeps the full `warn` finding...
1654        assert!(r.findings.iter().any(|f| f.topic == "subprocess-blind-spot"
1655            && f.level == "warn"
1656            && f.detail.contains("backup now")
1657            && !f.detail.contains("etl run")));
1658        // ...and only the uncovered one counts toward the rank-3 follow-up.
1659        let follow_up = r
1660            .follow_ups
1661            .iter()
1662            .find(|u| u.code == "subprocess-blind-spot")
1663            .expect("one uncovered sighting still yields a follow-up");
1664        assert!(follow_up.detail.contains("backup now"));
1665        assert!(!follow_up.detail.contains("etl run"));
1666        assert_eq!(follow_up.subject, "1 externally-launched process(es)");
1667    }
1668
1669    /// Issue #41: `os.system`/`os.popen` sightings, and `subprocess.Popen`
1670    /// sightings, are NEVER match candidates even with argv text that would
1671    /// otherwise match a declared rule — the runtime pack never intercepts
1672    /// those launchers at all (`subprocess_pack.py`'s "Coverage" section).
1673    #[test]
1674    fn uncovered_launchers_never_match_even_with_matching_text() {
1675        use crate::scan::SubprocessSighting;
1676        let mut scan = ScanResult {
1677            files_scanned: 1,
1678            python_available: true,
1679            ..ScanResult::default()
1680        };
1681        scan.subprocesses.push(SubprocessSighting {
1682            file: "legacy.py".into(),
1683            line: 4,
1684            launcher: "os.system".into(),
1685            command: "etl run".into(),
1686            argv: None,
1687        });
1688        scan.subprocesses.push(SubprocessSighting {
1689            file: "legacy.py".into(),
1690            line: 8,
1691            launcher: "subprocess.Popen".into(),
1692            command: "etl run".into(),
1693            argv: Some(vec!["etl".into(), "run".into()]),
1694        });
1695        let mut policy = default_policy();
1696        policy.check.present = true;
1697        policy.cmd_match.insert(
1698            "cmd:etl".to_owned(),
1699            FlowMatchRule {
1700                argv: vec!["etl".into(), "run".into()],
1701            },
1702        );
1703        let r = build_report(
1704            &scan,
1705            &BTreeSet::new(),
1706            policy,
1707            default_journal(),
1708            None,
1709            empty_boundaries(),
1710            &[],
1711        );
1712        assert!(
1713            r.topology
1714                .external_processes
1715                .iter()
1716                .all(|p| p.covered_by.is_none()),
1717            "neither os.system nor subprocess.Popen is ever a match candidate"
1718        );
1719    }
1720
1721    /// WS3: each hand-rolled pattern the scan sighted becomes ONE paired
1722    /// finding. The pairing is with the topology bucket: a wrappable target
1723    /// makes the finding actionable now (warn); an unreachable one is a
1724    /// once-wrapped lead (info). The poll finding names the `poll` primitive
1725    /// as the replacement (WS5 pairing).
1726    #[test]
1727    fn simplification_findings_pair_with_topology_buckets() {
1728        use crate::scan::{SimplificationSighting, TransportClass};
1729        let mut scan = ScanResult {
1730            files_scanned: 2,
1731            python_available: true,
1732            ..ScanResult::default()
1733        };
1734        // Wrappable target (tracked transport) with a hand-rolled retry.
1735        scan.targets.insert(
1736            "api.ok.com".into(),
1737            TargetEvidence {
1738                class: TargetClass::Host,
1739                sightings: [Sighting {
1740                    file: "app.py".into(),
1741                    line: 3,
1742                }]
1743                .into_iter()
1744                .collect(),
1745            },
1746        );
1747        scan.host_transports
1748            .insert("api.ok.com".into(), TransportClass::Tracked);
1749        scan.simplifications.push(SimplificationSighting {
1750            file: "app.py".into(),
1751            line: 12,
1752            kind: "hand-rolled-retry".into(),
1753            function: "caller".into(),
1754            targets: vec!["api.ok.com".into()],
1755        });
1756        // Unreachable target (stdlib urllib) with a hand-rolled poll — the
1757        // claude-trader shape until WS4 flips urllib to tracked.
1758        scan.targets.insert(
1759            "api.tavily.com".into(),
1760            TargetEvidence {
1761                class: TargetClass::Host,
1762                sightings: [Sighting {
1763                    file: "fetch_short_metrics.py".into(),
1764                    line: 39,
1765                }]
1766                .into_iter()
1767                .collect(),
1768            },
1769        );
1770        scan.host_transports
1771            .insert("api.tavily.com".into(), TransportClass::UntrackedKnown);
1772        scan.simplifications.push(SimplificationSighting {
1773            file: "fetch_short_metrics.py".into(),
1774            line: 83,
1775            kind: "hand-rolled-poll".into(),
1776            function: "_poll_research".into(),
1777            targets: vec!["api.tavily.com".into()],
1778        });
1779        let r = build_report(
1780            &scan,
1781            &BTreeSet::new(),
1782            default_policy(),
1783            default_journal(),
1784            None,
1785            empty_boundaries(),
1786            &[],
1787        );
1788        let retry = r
1789            .findings
1790            .iter()
1791            .find(|f| f.topic == "hand-rolled-retry")
1792            .expect("retry finding");
1793        assert_eq!(retry.level, "warn", "wrappable target → actionable now");
1794        assert!(retry.detail.contains("api.ok.com"));
1795        assert!(retry.detail.contains("app.py:12"));
1796        assert!(retry.detail.contains("caller"));
1797        let poll = r
1798            .findings
1799            .iter()
1800            .find(|f| f.topic == "hand-rolled-poll")
1801            .expect("poll finding");
1802        assert_eq!(poll.level, "info", "unreachable target → once-wrapped lead");
1803        assert!(poll.detail.contains("fetch_short_metrics.py:83"));
1804        assert!(poll.action.contains("poll"), "names the poll primitive");
1805        // The WS2 closed follow-up vocabulary is NOT extended by WS3.
1806        assert!(
1807            r.follow_ups
1808                .iter()
1809                .all(|f| !f.code.starts_with("hand-rolled") && f.code != "silent-swallow"),
1810            "{:?}",
1811            r.follow_ups
1812        );
1813        // Simplification findings are honesty leads, never configuration errors.
1814        assert!(r.ok);
1815    }
1816
1817    /// The ranked follow-up list (WS2): every honesty signal that needs a human/
1818    /// agent to chase becomes one entry in a closed vocabulary, sorted by
1819    /// (rank, code, subject) with rank = ascending Keel-confidence.
1820    #[test]
1821    fn follow_ups_are_ranked_closed_vocabulary_and_sorted() {
1822        use crate::scan::{DepAverseFile, SubprocessSighting, TransportClass};
1823        let mut scan = ScanResult {
1824            files_scanned: 4,
1825            python_available: true,
1826            ..ScanResult::default()
1827        };
1828        // Two unreachable hosts (rank 1) — inserted in reverse order to prove
1829        // the sort, not the insertion order, decides.
1830        for (host, file) in [("api.zeta.com", "z.py"), ("api.alpha.com", "a.py")] {
1831            scan.targets.insert(
1832                host.into(),
1833                TargetEvidence {
1834                    class: TargetClass::Host,
1835                    sightings: [Sighting {
1836                        file: file.into(),
1837                        line: 1,
1838                    }]
1839                    .into_iter()
1840                    .collect(),
1841                },
1842            );
1843            scan.host_transports
1844                .insert(host.into(), TransportClass::UntrackedKnown);
1845        }
1846        // One external process (rank 3).
1847        scan.subprocesses.push(SubprocessSighting {
1848            file: "launch.py".into(),
1849            line: 12,
1850            launcher: "subprocess.run".into(),
1851            command: "uvx alpaca-mcp-server".into(),
1852            argv: Some(vec!["uvx".into(), "alpaca-mcp-server".into()]),
1853        });
1854        // One excluded host (rank 4).
1855        scan.targets.insert(
1856            "api.broker.com".into(),
1857            TargetEvidence {
1858                class: TargetClass::Host,
1859                sightings: [Sighting {
1860                    file: "risk_gate.py".into(),
1861                    line: 20,
1862                }]
1863                .into_iter()
1864                .collect(),
1865            },
1866        );
1867        scan.dependency_averse.push(DepAverseFile {
1868            file: "risk_gate.py".into(),
1869            reason: "stdlib-only + name/docstring signal: risk".into(),
1870        });
1871        // Pre-existing resilience alongside a wrapped lib (rank 5).
1872        scan.libs.insert("httpx".to_owned());
1873        scan.resilience_libs.insert("tenacity".to_owned());
1874
1875        let r = build_report(
1876            &scan,
1877            &BTreeSet::new(),
1878            default_policy(),
1879            default_journal(),
1880            None,
1881            empty_boundaries(),
1882            &[],
1883        );
1884
1885        let got: Vec<(u32, &str, &str)> = r
1886            .follow_ups
1887            .iter()
1888            .map(|f| (f.rank, f.code, f.subject.as_str()))
1889            .collect();
1890        assert_eq!(
1891            got,
1892            vec![
1893                (1, "url-no-transport", "api.alpha.com"),
1894                (1, "url-no-transport", "api.zeta.com"),
1895                (
1896                    3,
1897                    "subprocess-blind-spot",
1898                    "1 externally-launched process(es)"
1899                ),
1900                (4, "dependency-averse-excluded", "api.broker.com"),
1901                (5, "preexisting-resilience", "tenacity"),
1902            ]
1903        );
1904        // Every detail is non-empty keel-authored text.
1905        assert!(r.follow_ups.iter().all(|f| !f.detail.is_empty()));
1906        // follow_ups never affect ok.
1907        assert!(r.ok);
1908        // The human view carries the section, ranked.
1909        let text = human(&r);
1910        assert!(text.contains("follow-ups"));
1911        assert!(text.contains("[url-no-transport] api.alpha.com"));
1912    }
1913
1914    /// No signals → the field is present and empty (agents can rely on the key).
1915    #[test]
1916    fn no_signals_means_empty_follow_ups() {
1917        use crate::scan::TransportClass;
1918        let scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
1919        // httpx is a tracked transport for this host.
1920        let mut scan = scan;
1921        scan.host_transports
1922            .insert("api.example.com".into(), TransportClass::Tracked);
1923        let r = build_report(
1924            &scan,
1925            &BTreeSet::new(),
1926            default_policy(),
1927            default_journal(),
1928            None,
1929            empty_boundaries(),
1930            &[],
1931        );
1932        assert!(r.follow_ups.is_empty(), "{:?}", r.follow_ups);
1933    }
1934
1935    /// The override precedence documented on [`classify_topology`]: a
1936    /// wrapped-at-runtime target or an `llm:*` target is wrappable by
1937    /// construction, regardless of what the transport check or the
1938    /// dependency-averse check would otherwise conclude. Runtime evidence (or
1939    /// the LLM pack's own wrapping) beats static doubt.
1940    #[test]
1941    #[allow(clippy::too_many_lines)] // WS6 added a `build_report` arg; the fixture setup is
1942    // already the longest legitimate part of this test, not the new plumbing.
1943    fn wrapped_and_llm_targets_are_always_wrappable() {
1944        use crate::scan::{DepAverseFile, TransportClass};
1945        let mut scan = ScanResult {
1946            files_scanned: 3,
1947            python_available: true,
1948            ..ScanResult::default()
1949        };
1950        // Would otherwise be unreachable (Unknown transport) if not wrapped.
1951        scan.targets.insert(
1952            "api.wrapped-but-unknown.com".into(),
1953            TargetEvidence {
1954                class: TargetClass::Host,
1955                sightings: [Sighting {
1956                    file: "app.py".into(),
1957                    line: 1,
1958                }]
1959                .into_iter()
1960                .collect(),
1961            },
1962        );
1963        scan.host_transports.insert(
1964            "api.wrapped-but-unknown.com".into(),
1965            TransportClass::Unknown,
1966        );
1967        // Would otherwise be excluded (dependency-averse-only-sighted) if not
1968        // wrapped.
1969        scan.targets.insert(
1970            "api.wrapped-but-excluded.com".into(),
1971            TargetEvidence {
1972                class: TargetClass::Host,
1973                sightings: [Sighting {
1974                    file: "risk_gate.py".into(),
1975                    line: 5,
1976                }]
1977                .into_iter()
1978                .collect(),
1979            },
1980        );
1981        scan.host_transports.insert(
1982            "api.wrapped-but-excluded.com".into(),
1983            TransportClass::UntrackedKnown,
1984        );
1985        scan.dependency_averse.push(DepAverseFile {
1986            file: "risk_gate.py".into(),
1987            reason: "stdlib-only + name/docstring signal: risk".into(),
1988        });
1989        // An llm:* target with no transport evidence at all — wrappable by
1990        // construction, not by the transport map.
1991        scan.targets.insert(
1992            "llm:some-model".into(),
1993            TargetEvidence {
1994                class: TargetClass::Llm,
1995                sightings: [Sighting {
1996                    file: "agent.py".into(),
1997                    line: 7,
1998                }]
1999                .into_iter()
2000                .collect(),
2001            },
2002        );
2003        let wrapped: BTreeSet<String> = [
2004            "api.wrapped-but-unknown.com".to_owned(),
2005            "api.wrapped-but-excluded.com".to_owned(),
2006        ]
2007        .into_iter()
2008        .collect();
2009        let r = build_report(
2010            &scan,
2011            &wrapped,
2012            default_policy(),
2013            default_journal(),
2014            None,
2015            empty_boundaries(),
2016            &[],
2017        );
2018
2019        assert!(
2020            r.topology
2021                .wrappable
2022                .contains(&"api.wrapped-but-unknown.com".to_owned()),
2023            "a wrapped-at-runtime target must be wrappable even with an Unknown transport class: \
2024             {:?}",
2025            r.topology
2026        );
2027        assert!(
2028            !r.topology
2029                .unreachable
2030                .iter()
2031                .any(|e| e.host == "api.wrapped-but-unknown.com"),
2032            "must not also land in unreachable"
2033        );
2034        assert!(
2035            r.topology
2036                .wrappable
2037                .contains(&"api.wrapped-but-excluded.com".to_owned()),
2038            "a wrapped-at-runtime target must be wrappable even when sighted only in a \
2039             dependency-averse file: {:?}",
2040            r.topology
2041        );
2042        assert!(
2043            !r.topology
2044                .excluded
2045                .iter()
2046                .any(|e| e.host == "api.wrapped-but-excluded.com"),
2047            "must not also land in excluded"
2048        );
2049        assert!(
2050            r.topology.wrappable.contains(&"llm:some-model".to_owned()),
2051            "an llm:* target must be wrappable with zero transport evidence: {:?}",
2052            r.topology
2053        );
2054    }
2055
2056    /// The six agent-framework packs + google-genai are registered adapters:
2057    /// detected, pinned, and their `target` matches each pack's own declared
2058    /// `TargetDecl.pattern` — so importing them is coverage, not an
2059    /// "invisible" finding.
2060    #[test]
2061    fn agent_pack_adapters_are_registered_pinned_and_detected() {
2062        let scan = scan_with(
2063            "llm:google-genai",
2064            TargetClass::Llm,
2065            &[
2066                "google-adk",
2067                "google-genai",
2068                "pydantic-ai",
2069                "openai-agents",
2070                "crewai",
2071                "langgraph",
2072                "mcp",
2073            ],
2074        );
2075        let policy = PolicyValidation {
2076            check: PolicyCheck {
2077                field: None,
2078                message: None,
2079                present: false,
2080                valid: true,
2081            },
2082            cmd_match: BTreeMap::new(),
2083            fix: None,
2084        };
2085        let r = build_report(
2086            &scan,
2087            &BTreeSet::new(),
2088            policy,
2089            default_journal(),
2090            None,
2091            empty_boundaries(),
2092            &[],
2093        );
2094
2095        assert!(
2096            r.coverage.invisible.is_empty(),
2097            "every imported agent-pack lib has a registry adapter: {:?}",
2098            r.coverage.invisible
2099        );
2100        for (lib, target) in [
2101            ("google-adk", "tool:<name>"),
2102            ("google-genai", "llm:google-genai"),
2103            ("pydantic-ai", "tool:<name>"),
2104            ("openai-agents", "tool:<name>"),
2105            ("crewai", "tool:<name>"),
2106            ("langgraph", "tool:<name>"),
2107            // `mcp` is a single REGISTRY row shared by Python and Node (like
2108            // openai/anthropic above): one flat `scan.libs` detection covers
2109            // both, so it belongs in this same table-driven loop rather than
2110            // a separate assertion block.
2111            ("mcp", "mcp:<server>"),
2112        ] {
2113            let a = r
2114                .adapters
2115                .iter()
2116                .find(|a| a.lib == lib && a.target == target)
2117                .unwrap_or_else(|| panic!("missing REGISTRY entry for {lib} -> {target}"));
2118            assert!(a.detected, "{lib} should be detected");
2119            assert_eq!(a.status, "pinned");
2120        }
2121    }
2122
2123    /// Issue #17, end-to-end: a pure-Node project (no Python files at all) that
2124    /// imports `@modelcontextprotocol/sdk` must light up doctor's merged
2125    /// python+node `mcp` adapter row as `detected: true` — not be reported as an
2126    /// "invisible" unadapted library. Drives the real `scan()` over a temp dir
2127    /// so the whole JS-scan → cross-language `libs` merge → `build_report` path
2128    /// is exercised, reproducing the exact symptom the issue reported
2129    /// (`detected: false` for a Node-only MCP client). The `agent_pack_*` test
2130    /// above pins the doctor half from a synthetic `libs`; this pins the wiring
2131    /// from a real filesystem scan with zero Python.
2132    #[test]
2133    fn pure_node_mcp_project_lights_up_the_mcp_adapter() {
2134        use std::fs;
2135        use tempfile::TempDir;
2136
2137        let dir = TempDir::new().unwrap();
2138        fs::write(
2139            dir.path().join("server.ts"),
2140            "import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\n",
2141        )
2142        .unwrap();
2143        let scan = scan::scan(dir.path());
2144        assert!(
2145            scan.libs.contains("mcp"),
2146            "a Node-only scan must carry `mcp` into ScanResult.libs: {:?}",
2147            scan.libs
2148        );
2149
2150        let r = build_report(
2151            &scan,
2152            &BTreeSet::new(),
2153            default_policy(),
2154            default_journal(),
2155            None,
2156            empty_boundaries(),
2157            &[],
2158        );
2159        let mcp = r
2160            .adapters
2161            .iter()
2162            .find(|a| a.lib == "mcp")
2163            .expect("mcp REGISTRY row");
2164        assert!(
2165            mcp.detected,
2166            "pure-Node MCP project must show the mcp adapter detected"
2167        );
2168        assert_eq!(mcp.target, "mcp:<server>");
2169        assert!(
2170            !r.coverage.invisible.iter().any(|l| l == "mcp"),
2171            "a registered adapter is coverage, not an invisible finding: {:?}",
2172            r.coverage.invisible
2173        );
2174    }
2175
2176    fn default_policy() -> PolicyValidation {
2177        PolicyValidation {
2178            check: PolicyCheck {
2179                field: None,
2180                message: None,
2181                present: false,
2182                valid: true,
2183            },
2184            cmd_match: BTreeMap::new(),
2185            fix: None,
2186        }
2187    }
2188
2189    /// WS4: the stdlib urllib.request pack has a REGISTRY row (keyed to the
2190    /// Python runtime version — the documented convention exception), a
2191    /// scanned `urllib.request` import counts as detected/not-invisible, and
2192    /// its hosts are wrappable, not unreachable.
2193    #[test]
2194    fn urllib_request_is_a_registered_tracked_adapter() {
2195        let mut scan = scan_with("api.tavily.com", TargetClass::Host, &["urllib.request"]);
2196        scan.host_transports
2197            .insert("api.tavily.com".into(), TransportClass::Tracked);
2198        let r = build_report(
2199            &scan,
2200            &BTreeSet::new(),
2201            default_policy(),
2202            default_journal(),
2203            None,
2204            empty_boundaries(),
2205            &[],
2206        );
2207        let row = r
2208            .adapters
2209            .iter()
2210            .find(|a| a.lib == "urllib.request")
2211            .expect("REGISTRY row for urllib.request");
2212        assert!(row.detected);
2213        assert_eq!(row.status, "pinned");
2214        assert_eq!(row.target, "host");
2215        assert!(
2216            r.coverage.invisible.is_empty(),
2217            "{:?}",
2218            r.coverage.invisible
2219        );
2220        assert!(r.topology.wrappable.contains(&"api.tavily.com".to_owned()));
2221        assert!(
2222            r.topology.unreachable.is_empty(),
2223            "{:?}",
2224            r.topology.unreachable
2225        );
2226    }
2227
2228    #[test]
2229    fn resilience_lib_alongside_a_wrapped_effect_is_a_finding() {
2230        let mut scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
2231        scan.resilience_libs.insert("tenacity".to_owned());
2232        let r = build_report(
2233            &scan,
2234            &BTreeSet::new(),
2235            default_policy(),
2236            default_journal(),
2237            None,
2238            empty_boundaries(),
2239            &[],
2240        );
2241        let finding = r
2242            .findings
2243            .iter()
2244            .find(|f| f.topic == "preexisting-resilience")
2245            .expect("tenacity + httpx should raise a finding");
2246        assert_eq!(finding.level, "warn");
2247        assert!(finding.detail.contains("tenacity"));
2248    }
2249
2250    #[test]
2251    fn resilience_lib_with_no_wrapped_effect_is_not_a_finding() {
2252        // tenacity imported, but nothing Keel would ever wrap alongside it —
2253        // no evidence of compounding, so no finding (avoids the false
2254        // positive of flagging an unrelated/unused import).
2255        let mut scan = ScanResult {
2256            files_scanned: 1,
2257            python_available: true,
2258            ..ScanResult::default()
2259        };
2260        scan.resilience_libs.insert("tenacity".to_owned());
2261        let r = build_report(
2262            &scan,
2263            &BTreeSet::new(),
2264            default_policy(),
2265            default_journal(),
2266            None,
2267            empty_boundaries(),
2268            &[],
2269        );
2270        assert!(
2271            !r.findings
2272                .iter()
2273                .any(|f| f.topic == "preexisting-resilience")
2274        );
2275    }
2276
2277    #[test]
2278    fn no_resilience_libs_is_not_a_finding() {
2279        let scan = scan_with("api.example.com", TargetClass::Host, &["httpx"]);
2280        let r = build_report(
2281            &scan,
2282            &BTreeSet::new(),
2283            default_policy(),
2284            default_journal(),
2285            None,
2286            empty_boundaries(),
2287            &[],
2288        );
2289        assert!(
2290            !r.findings
2291                .iter()
2292                .any(|f| f.topic == "preexisting-resilience")
2293        );
2294    }
2295
2296    #[test]
2297    fn invalid_policy_is_a_finding_and_not_ok() {
2298        let scan = ScanResult::default();
2299        let wrapped = BTreeSet::new();
2300        let policy = PolicyValidation {
2301            check: PolicyCheck {
2302                field: Some("target.x.retry.attempts".to_owned()),
2303                message: Some("invalid value: integer `0`".to_owned()),
2304                present: true,
2305                valid: false,
2306            },
2307            cmd_match: BTreeMap::new(),
2308            fix: None,
2309        };
2310        let r = build_report(
2311            &scan,
2312            &wrapped,
2313            policy,
2314            default_journal(),
2315            None,
2316            empty_boundaries(),
2317            &[],
2318        );
2319        assert!(!r.ok);
2320        assert!(
2321            r.findings
2322                .iter()
2323                .any(|f| f.topic == "policy" && f.level == "error")
2324        );
2325    }
2326
2327    /// A `postgres://` journal has no backend in this build: doctor reports it,
2328    /// raises an error finding naming KEEL-E005, and exits non-ok — the app
2329    /// would fail to configure, so CI must not pass silently.
2330    #[test]
2331    fn unsupported_journal_backend_is_an_error_finding_and_not_ok() {
2332        let scan = ScanResult::default();
2333        let wrapped = BTreeSet::new();
2334        let policy = PolicyValidation {
2335            check: PolicyCheck {
2336                field: None,
2337                message: None,
2338                present: true,
2339                valid: true,
2340            },
2341            cmd_match: BTreeMap::new(),
2342            fix: None,
2343        };
2344        let journal = JournalReport {
2345            backend: "postgres",
2346            location: "postgres://\u{2026}@db.internal/keel".to_owned(),
2347            source: "keel.toml",
2348            supported: false,
2349        };
2350        let r = build_report(
2351            &scan,
2352            &wrapped,
2353            policy,
2354            journal,
2355            None,
2356            empty_boundaries(),
2357            &[],
2358        );
2359        assert!(!r.ok, "an unbootable configuration must not be ok");
2360        let finding = r
2361            .findings
2362            .iter()
2363            .find(|f| f.topic == "journal")
2364            .expect("journal finding present");
2365        assert_eq!(finding.level, "error");
2366        assert!(finding.detail.contains("KEEL-E005"));
2367        assert!(finding.action.contains("file:"));
2368        // Human output carries the journal facts.
2369        let text = human(&r);
2370        assert!(text.contains("postgres"));
2371        assert!(text.contains("NOT supported"));
2372    }
2373
2374    // ---- agents-cli config placement ----
2375
2376    /// A manifest naming an agent directory other than the project root, plus
2377    /// a root `keel.toml`, is exactly the layout that never ships: the finding
2378    /// fires with the Dockerfile explanation and a move-it action.
2379    #[test]
2380    fn agents_cli_placement_finding_fires_for_a_root_keel_toml() {
2381        let dir = tempfile::TempDir::new().unwrap();
2382        std::fs::create_dir(dir.path().join("app")).unwrap();
2383        std::fs::write(
2384            dir.path().join("agents-cli-manifest.yaml"),
2385            "agent_directory: app\n",
2386        )
2387        .unwrap();
2388        std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2389
2390        let finding =
2391            agents_cli_placement_finding(dir.path()).expect("root keel.toml should be flagged");
2392        assert_eq!(finding.level, "warn");
2393        assert_eq!(finding.topic, "agents-cli-config-placement");
2394        assert!(finding.detail.contains("Dockerfile"));
2395        assert!(finding.detail.contains("pyproject.toml"));
2396        assert!(finding.detail.contains("app"));
2397        assert!(
2398            finding.action.contains("app/keel.toml") || finding.action.contains("app\\keel.toml"),
2399            "action names the relative move-to path: {}",
2400            finding.action
2401        );
2402    }
2403
2404    /// No manifest at all: never a finding, regardless of a root `keel.toml`.
2405    #[test]
2406    fn agents_cli_placement_finding_is_none_without_a_manifest() {
2407        let dir = tempfile::TempDir::new().unwrap();
2408        std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2409        assert!(agents_cli_placement_finding(dir.path()).is_none());
2410    }
2411
2412    /// The `keel.toml` already lives in the agent directory (the correct
2413    /// place) and the project root has none: nothing to flag.
2414    #[test]
2415    fn agents_cli_placement_finding_is_none_when_keel_toml_is_already_in_the_agent_dir() {
2416        let dir = tempfile::TempDir::new().unwrap();
2417        std::fs::create_dir(dir.path().join("app")).unwrap();
2418        std::fs::write(
2419            dir.path().join("agents-cli-manifest.yaml"),
2420            "agent_directory: app\n",
2421        )
2422        .unwrap();
2423        std::fs::write(dir.path().join("app").join("keel.toml"), "[target.\"x\"]\n").unwrap();
2424
2425        assert!(agents_cli_placement_finding(dir.path()).is_none());
2426    }
2427
2428    /// A manifest whose `agent_directory` names the project root itself: the
2429    /// root `keel.toml` already sits inside the one directory the Dockerfile
2430    /// ships, so there is nothing to flag.
2431    #[test]
2432    fn agents_cli_placement_finding_is_none_when_agent_dir_is_the_project_root() {
2433        let dir = tempfile::TempDir::new().unwrap();
2434        std::fs::write(
2435            dir.path().join("agents-cli-manifest.yaml"),
2436            "agent_directory: .\n",
2437        )
2438        .unwrap();
2439        std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2440
2441        assert!(agents_cli_placement_finding(dir.path()).is_none());
2442    }
2443
2444    /// End-to-end through `run()`: the finding surfaces in the full report and
2445    /// (being a warn, not an error) does not flip `ok` to false.
2446    #[test]
2447    fn doctor_run_surfaces_the_agents_cli_placement_finding() {
2448        let dir = tempfile::TempDir::new().unwrap();
2449        std::fs::create_dir(dir.path().join("app")).unwrap();
2450        std::fs::write(
2451            dir.path().join("agents-cli-manifest.yaml"),
2452            "agent_directory: app\n",
2453        )
2454        .unwrap();
2455        std::fs::write(dir.path().join("keel.toml"), "[target.\"x\"]\n").unwrap();
2456
2457        let r = run(dir.path());
2458        assert_eq!(r.exit, EXIT_OK);
2459        assert_eq!(r.json["ok"], true);
2460        let findings = r.json["findings"].as_array().unwrap();
2461        assert!(
2462            findings
2463                .iter()
2464                .any(|f| f["topic"] == "agents-cli-config-placement" && f["level"] == "warn")
2465        );
2466    }
2467
2468    /// End-to-end over a real project dir: doctor resolves and reports the
2469    /// `file:` journal location from keel.toml.
2470    #[test]
2471    fn doctor_reports_the_policy_selected_journal_location() {
2472        let dir = tempfile::TempDir::new().unwrap();
2473        std::fs::write(
2474            dir.path().join("keel.toml"),
2475            "journal = \"file:custom/j.db\"\n",
2476        )
2477        .unwrap();
2478        let r = run(dir.path());
2479        assert_eq!(r.exit, EXIT_OK);
2480        assert_eq!(r.json["journal"]["backend"], "sqlite");
2481        assert_eq!(r.json["journal"]["location"], "custom/j.db");
2482        assert_eq!(r.json["journal"]["source"], "keel.toml");
2483        assert_eq!(r.json["journal"]["supported"], true);
2484        assert!(r.human.contains("custom/j.db"));
2485    }
2486
2487    /// End-to-end: a `postgres://` journal exits `EXIT_USAGE`, with credentials
2488    /// redacted from both output forms.
2489    #[test]
2490    fn doctor_flags_a_postgres_journal_and_redacts_credentials() {
2491        let dir = tempfile::TempDir::new().unwrap();
2492        std::fs::write(
2493            dir.path().join("keel.toml"),
2494            "journal = \"postgres://keel:sekrit@db.internal/keel\"\n",
2495        )
2496        .unwrap();
2497        let r = run(dir.path());
2498        assert_eq!(r.exit, EXIT_USAGE);
2499        assert_eq!(r.json["journal"]["backend"], "postgres");
2500        assert_eq!(r.json["journal"]["supported"], false);
2501        assert_eq!(r.json["ok"], false);
2502        let json_text = crate::render::json_string(&r.json);
2503        assert!(!json_text.contains("sekrit"), "credentials never printed");
2504        assert!(!r.human.contains("sekrit"), "credentials never printed");
2505    }
2506
2507    #[test]
2508    fn validate_policy_reports_exact_field_path() {
2509        let dir = tempfile::TempDir::new().unwrap();
2510        let path = dir.path().join("keel.toml");
2511        std::fs::write(&path, "[target.\"x\"]\nretry = { attempts = 0 }\n").unwrap();
2512        let v = validate_policy(&path);
2513        assert!(!v.check.valid);
2514        assert_eq!(v.check.field.as_deref(), Some("target.x.retry.attempts"));
2515    }
2516
2517    #[test]
2518    fn validate_policy_accepts_a_good_file() {
2519        let dir = tempfile::TempDir::new().unwrap();
2520        let path = dir.path().join("keel.toml");
2521        std::fs::write(
2522            &path,
2523            "[target.\"api.x\"]\nretry = { attempts = 5, schedule = \"exp(200ms, x2, max 30s, jitter)\" }\n",
2524        )
2525        .unwrap();
2526        let v = validate_policy(&path);
2527        assert!(
2528            v.check.valid,
2529            "field={:?} msg={:?}",
2530            v.check.field, v.check.message
2531        );
2532        assert!(v.fix.is_none(), "a valid policy needs no fix");
2533    }
2534
2535    #[test]
2536    fn absent_policy_is_valid_and_ok() {
2537        let v = validate_policy(Path::new("/nonexistent/keel.toml"));
2538        assert!(v.check.valid);
2539        assert!(!v.check.present);
2540    }
2541
2542    /// dx-spec §5: the invalid-policy finding carries an *applyable* fix — a
2543    /// patch that removes the offending entry (defaults cover it) while every
2544    /// untouched byte, comments included, survives.
2545    #[test]
2546    fn invalid_policy_finding_carries_an_applyable_removal_fix() {
2547        let dir = tempfile::TempDir::new().unwrap();
2548        let path = dir.path().join("keel.toml");
2549        std::fs::write(
2550            &path,
2551            "# my tuning\n[target.\"api.example.com\"]\ntimeout = \"30s\" # keep\nretry = { attempts = 0 }\n",
2552        )
2553        .unwrap();
2554
2555        let v = validate_policy(&path);
2556        assert!(!v.check.valid);
2557        assert_eq!(
2558            v.check.field.as_deref(),
2559            Some("target.api.example.com.retry.attempts"),
2560            "dotted host key resolves"
2561        );
2562        let fix = v.fix.expect("fix proposal attached");
2563        assert!(fix.patch.starts_with("--- a/keel.toml\n+++ b/keel.toml\n"));
2564        // The patch is faithful: applying it reproduces the proposed text.
2565        let applied =
2566            crate::diff::apply_unified(&std::fs::read_to_string(&path).unwrap(), &fix.patch)
2567                .unwrap();
2568        assert_eq!(applied, fix.new_text);
2569        // The proposed text is a valid policy with the untouched bytes intact.
2570        std::fs::write(&path, &fix.new_text).unwrap();
2571        let after = validate_policy(&path);
2572        assert!(after.check.valid, "removal fix yields a valid policy");
2573        assert!(fix.new_text.contains("# my tuning"));
2574        assert!(fix.new_text.contains("timeout = \"30s\" # keep"));
2575        assert!(
2576            !fix.new_text.contains("retry"),
2577            "whole invalid entry removed"
2578        );
2579        // The structured form names the removed entry.
2580        assert_eq!(fix.changes.len(), 1);
2581        assert_eq!(fix.changes[0].path, "target.\"api.example.com\".retry");
2582        assert!(fix.changes[0].after.is_none());
2583    }
2584
2585    /// A file that is not even TOML has no field to fix — no patch is attached.
2586    #[test]
2587    fn unparseable_policy_has_no_fix() {
2588        let dir = tempfile::TempDir::new().unwrap();
2589        let path = dir.path().join("keel.toml");
2590        std::fs::write(&path, "not [valid toml\n").unwrap();
2591        let v = validate_policy(&path);
2592        assert!(!v.check.valid);
2593        assert!(v.fix.is_none());
2594    }
2595
2596    /// Whether `python3` is on PATH — gates the Python-scan end-to-end test
2597    /// below, mirroring `scan::python`'s test helper (private to that module,
2598    /// so duplicated here rather than shared across crates).
2599    fn python3_present() -> bool {
2600        std::process::Command::new("python3")
2601            .arg("--version")
2602            .stdout(std::process::Stdio::null())
2603            .stderr(std::process::Stdio::null())
2604            .status()
2605            .is_ok_and(|s| s.success())
2606    }
2607
2608    /// WS6: a resumable flow recorded under a different code hash surfaces as
2609    /// the rank-6 `code-hash-stale` follow-up.
2610    #[test]
2611    fn code_hash_stale_flow_emits_the_rank6_follow_up() {
2612        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
2613        let schema = std::fs::read_to_string(root.join("contracts/journal.sql")).unwrap();
2614        let dir = tempfile::TempDir::new().unwrap();
2615        let keel = dir.path().join(".keel");
2616        std::fs::create_dir_all(&keel).unwrap();
2617        let conn = rusqlite::Connection::open(keel.join("journal.db")).unwrap();
2618        conn.execute_batch(&schema).unwrap();
2619        let t0: i64 = 1_783_728_000_000;
2620        conn.execute(
2621            "INSERT INTO flows (flow_id, entrypoint, args_hash, code_hash, status, created_at, \
2622             updated_at) VALUES ('01STALEFLOW', 'py:pipeline.ingest:main', 'ah-1', \
2623             'deadbeefdeadbeef', 'running', ?1, ?1)",
2624            rusqlite::params![t0],
2625        )
2626        .unwrap();
2627        // A script on disk whose hash will never match the synthetic recorded
2628        // value above.
2629        let script_dir = dir.path().join("pipeline");
2630        std::fs::create_dir_all(&script_dir).unwrap();
2631        std::fs::write(script_dir.join("ingest.py"), "def main():\n    pass\n").unwrap();
2632
2633        let r = run(dir.path());
2634        let f = r.json["follow_ups"]
2635            .as_array()
2636            .unwrap()
2637            .iter()
2638            .find(|f| f["code"] == "code-hash-stale")
2639            .expect("code-hash-stale emitted");
2640        assert_eq!(f["rank"], 6);
2641        assert!(f["detail"].as_str().unwrap().contains("keel replay"));
2642    }
2643
2644    /// WS2 hardening: doctor and init --diff (the two MCP-exposed report
2645    /// producers) must never emit raw source content. Allowed interpolations
2646    /// are ONLY: hostnames, file paths, lib names, literal subprocess argv, and
2647    /// keel-authored sentences. Canary strings placed in every other syntactic
2648    /// position must not survive into either the JSON or the human rendering.
2649    #[test]
2650    fn doctor_and_init_diff_never_leak_raw_source() {
2651        const CANARIES: [&str; 5] = [
2652            "CANARY_COMMENT_9f31",
2653            "CANARY_SECRET_9f31",
2654            "CANARY_QUERY_9f31",
2655            "CANARY_DOCSTRING_9f31",
2656            "CANARY_QUERY2_9f31",
2657        ];
2658        if !python3_present() {
2659            eprintln!("skip: python3 not available");
2660            return;
2661        }
2662        let dir = tempfile::TempDir::new().unwrap();
2663        std::fs::write(
2664            dir.path().join("app.py"),
2665            r#"import time
2666import urllib.request
2667import httpx
2668import tenacity
2669# CANARY_COMMENT_9f31 must never appear in any report
2670TOKEN = "CANARY_SECRET_9f31"
2671U = "https://api.leak.example/v1?key=CANARY_QUERY_9f31"
2672
2673def caller():
2674    attempt = 0
2675    while True:
2676        try:
2677            return httpx.get(U)
2678        except Exception:
2679            # CANARY_COMMENT_9f31 must never appear in any report
2680            # Deliberate handler-local canary: an unused local-variable
2681            # assignment RHS, a syntactic position distinct from the other
2682            # four canaries above (comment, module const, URL query param,
2683            # docstring) — not dead code left behind by mistake.
2684            local_secret = "CANARY_QUERY2_9f31"
2685            attempt += 1
2686            time.sleep(1)
2687"#,
2688        )
2689        .unwrap();
2690        std::fs::write(
2691            dir.path().join("risk_gate.py"),
2692            "\"\"\"risk gate. CANARY_DOCSTRING_9f31 stdlib only.\"\"\"\n\
2693             import json\n\
2694             G = \"https://api.gateonly.example/v2?tok=CANARY_QUERY2_9f31\"\n",
2695        )
2696        .unwrap();
2697        let doctor = run(dir.path());
2698        let doctor_json = crate::render::json_string(&doctor.json);
2699        let init = crate::init::run(
2700            dir.path(),
2701            crate::init::InitOptions {
2702                diff: true,
2703                stamp: false,
2704                agents: false,
2705            },
2706        );
2707        let init_json = crate::render::json_string(&init.json);
2708        for canary in CANARIES {
2709            assert!(!doctor_json.contains(canary), "doctor json leaks {canary}");
2710            assert!(
2711                !doctor.human.contains(canary),
2712                "doctor human leaks {canary}"
2713            );
2714            assert!(
2715                !init_json.contains(canary),
2716                "init --diff json leaks {canary}"
2717            );
2718            assert!(
2719                !init.human.contains(canary),
2720                "init --diff human leaks {canary}"
2721            );
2722        }
2723        // Sanity: the report DID see the project (hosts present) — the canaries
2724        // are absent because of scoping, not because the scan saw nothing.
2725        assert!(doctor_json.contains("api.leak.example"));
2726        // Sanity: the fixture's hand-rolled retry loop (Task 3.3's `--diff`
2727        // notes path) actually fired — proving the canary-absence assertions
2728        // above exercised the new note-rendering code, not an empty notes
2729        // list that would trivially satisfy them.
2730        assert!(
2731            init_json.contains("hand-rolled-retry"),
2732            "init --diff notes should surface the hand-rolled retry loop: {init_json}"
2733        );
2734    }
2735
2736    // ---- boundaries ----
2737
2738    /// A `Boundaries` frame for a project root with no governance files — what
2739    /// every `build_report` unit test wants unless it is specifically testing
2740    /// governance detection. Uses the real constructor so the tests cannot
2741    /// drift from `run`'s behavior.
2742    fn empty_boundaries() -> Boundaries {
2743        let dir = tempfile::TempDir::new().unwrap();
2744        boundaries(dir.path())
2745    }
2746
2747    #[test]
2748    fn report_always_carries_boundaries() {
2749        let scan = ScanResult::default();
2750        let wrapped = BTreeSet::new();
2751        let r = build_report(
2752            &scan,
2753            &wrapped,
2754            default_policy(),
2755            default_journal(),
2756            None,
2757            empty_boundaries(),
2758            &[],
2759        );
2760        assert!(r.boundaries.parsed_languages.contains(&"js-ts"));
2761        assert!(r.boundaries.unparsed.contains(&"ci-workflow"));
2762        // Boundaries are a frame, not work: they must never inflate findings.
2763        assert!(!r.findings.iter().any(|f| f.topic == "evaluation-protocol"));
2764        assert!(!r.findings.iter().any(|f| f.topic == "governance-boundary"));
2765    }
2766
2767    #[test]
2768    fn boundaries_list_governance_files_that_exist() {
2769        let dir = tempfile::TempDir::new().unwrap();
2770        std::fs::write(dir.path().join("CLAUDE.md"), "# rules\n").unwrap();
2771        let b = boundaries(dir.path());
2772        assert_eq!(b.governance_files, vec!["CLAUDE.md"]);
2773
2774        std::fs::write(dir.path().join("AGENTS.md"), "# keel\n").unwrap();
2775        let b = boundaries(dir.path());
2776        assert_eq!(b.governance_files, vec!["CLAUDE.md", "AGENTS.md"]);
2777    }
2778
2779    #[test]
2780    fn boundaries_are_empty_but_present_without_governance_files() {
2781        let dir = tempfile::TempDir::new().unwrap();
2782        let b = boundaries(dir.path());
2783        assert!(b.governance_files.is_empty());
2784        // The standing facts are unconditional — an agent that reached the tool
2785        // without the skill must always learn what was not parsed.
2786        assert!(b.parsed_languages.contains(&"python"));
2787        assert!(b.unparsed.contains(&"shell"));
2788        assert!(b.protocol.contains("Baseline"));
2789    }
2790
2791    #[test]
2792    fn human_report_carries_a_compact_boundaries_section() {
2793        let dir = tempfile::TempDir::new().unwrap();
2794        std::fs::write(dir.path().join("CLAUDE.md"), "# rules\n").unwrap();
2795        let scan = ScanResult::default();
2796        let r = build_report(
2797            &scan,
2798            &BTreeSet::new(),
2799            default_policy(),
2800            default_journal(),
2801            None,
2802            boundaries(dir.path()),
2803            &[],
2804        );
2805        let text = human(&r);
2806        assert!(text.contains("\nboundaries\n"), "{text}");
2807        assert!(text.contains("python, js-ts"), "{text}");
2808        assert!(text.contains("CLAUDE.md"), "{text}");
2809        // Compact: the whole section, not one line per fact.
2810        let section = text.split("\nboundaries\n").nth(1).unwrap();
2811        let lines = section.lines().take_while(|l| l.starts_with("  ")).count();
2812        assert!(
2813            lines <= 3,
2814            "boundaries section is {lines} lines:\n{section}"
2815        );
2816    }
2817
2818    // ---- orchestration blind spot ----
2819
2820    #[test]
2821    fn orchestration_sightings_become_a_finding() {
2822        let mut scan = ScanResult::default();
2823        for (file, line) in [
2824            ("scripts/run_autonomous.sh", 3),
2825            ("scripts/run_autonomous.sh", 9),
2826        ] {
2827            scan.orchestration.push(scan::OrchestrationSighting {
2828                file: file.to_owned(),
2829                line,
2830                kind: "lockfile-mutex".to_owned(),
2831                snippet: "flock -n /tmp/x.lock || exit 0".to_owned(),
2832            });
2833        }
2834        let r = build_report(
2835            &scan,
2836            &BTreeSet::new(),
2837            default_policy(),
2838            default_journal(),
2839            None,
2840            empty_boundaries(),
2841            &[],
2842        );
2843        let f = r
2844            .findings
2845            .iter()
2846            .find(|f| f.topic == "orchestration-blind-spot")
2847            .expect("orchestration finding present");
2848        assert_eq!(f.level, "warn");
2849        assert!(f.detail.contains("run_autonomous.sh"), "{}", f.detail);
2850        // Two sightings in one file name it once.
2851        assert_eq!(
2852            f.detail.matches("run_autonomous.sh").count(),
2853            1,
2854            "{}",
2855            f.detail
2856        );
2857    }
2858
2859    /// A monorepo must not get a multi-kilobyte finding.
2860    #[test]
2861    fn orchestration_finding_caps_the_file_list() {
2862        let mut scan = ScanResult::default();
2863        for i in 0..40 {
2864            scan.orchestration.push(scan::OrchestrationSighting {
2865                file: format!("scripts/s{i:02}.sh"),
2866                line: 1,
2867                kind: "pid-check".to_owned(),
2868                snippet: "kill -0 $PID".to_owned(),
2869            });
2870        }
2871        scan.orchestration.sort();
2872        let r = build_report(
2873            &scan,
2874            &BTreeSet::new(),
2875            default_policy(),
2876            default_journal(),
2877            None,
2878            empty_boundaries(),
2879            &[],
2880        );
2881        let f = r
2882            .findings
2883            .iter()
2884            .find(|f| f.topic == "orchestration-blind-spot")
2885            .unwrap();
2886        assert!(f.detail.contains("and 35 more"), "{}", f.detail);
2887        assert!(f.detail.len() < 600, "detail is {} bytes", f.detail.len());
2888    }
2889
2890    #[test]
2891    fn no_orchestration_no_finding() {
2892        let scan = ScanResult::default();
2893        let r = build_report(
2894            &scan,
2895            &BTreeSet::new(),
2896            default_policy(),
2897            default_journal(),
2898            None,
2899            empty_boundaries(),
2900            &[],
2901        );
2902        assert!(
2903            !r.findings
2904                .iter()
2905                .any(|f| f.topic == "orchestration-blind-spot")
2906        );
2907    }
2908
2909    #[test]
2910    fn orchestration_sightings_become_a_ranked_follow_up() {
2911        let mut scan = ScanResult::default();
2912        scan.orchestration.push(scan::OrchestrationSighting {
2913            file: "scripts/run_autonomous.sh".to_owned(),
2914            line: 3,
2915            kind: "lockfile-mutex".to_owned(),
2916            snippet: "flock -n /tmp/x.lock || exit 0".to_owned(),
2917        });
2918        let r = build_report(
2919            &scan,
2920            &BTreeSet::new(),
2921            default_policy(),
2922            default_journal(),
2923            None,
2924            empty_boundaries(),
2925            &[],
2926        );
2927        let up = r
2928            .follow_ups
2929            .iter()
2930            .find(|f| f.code == "orchestration-blind-spot")
2931            .expect("orchestration follow-up present");
2932        assert_eq!(up.rank, 2);
2933        assert!(!up.detail.is_empty());
2934        // follow_ups never affect ok.
2935        assert!(r.ok);
2936    }
2937}