candor-scan 0.5.8

candor's STABLE-Rust effect scanner — syntactic call-graph + effect report, no nightly.
candor-scan-0.5.8 is not a library.

candor-scan

A stable-Rust effect scanner for Rust code. It maps which functions perform side effects — filesystem, network, subprocess, database, clock, env, randomness, IPC — and how those effects propagate transitively across the call graph, including the blast radius of editing any function.

No nightly, no rustc_private, no dylint. It parses your .rs files with syn and runs anywhere cargo does — CI, a locked-down box, or cargo install. It does not build your crate, so it can even analyze a dependency's source without compiling it.

cargo install candor-scan
candor-scan path/to/crate              # writes <crate>/.candor/report.<crate>.scan.json
candor-scan . --json                   # print the report to stdout instead
candor-scan workspace-root             # one report per workspace MEMBER, all under the same prefix

The report is the same JSON the full candor nightly lint produces, so the candor CLI's read-only queries (show/where/callers/map) read it identically.

What it does well, and where it stops

candor-scan is syntactic — it sees what's written, not what the compiler resolves. It is calibrated to never fabricate an effect (validated across 1294 real crates: zero false positives on a curated-pure set). When it can't see an effect, it stays silent rather than guessing — an honest under-report, never a wrong label.

  • Catches: path-qualified effect calls (std::fs::read, Command::new, reqwest::Client::execute), use-aliases, transitive propagation, calls hidden in macros (try_call!, println!), four C-library FFI tiers (libc, libsqlite3, libgit2, libssl), and method dispatch via light local type inference (struct fields, params, constructors, factory return types).

  • Honest Unknown: when the body invokes a callable the scan can't see through — a closure or fn-pointer held in a field, dispatch table, or index ((self.handler)(), arr[i]()) — it marks the function Unknown rather than silently certifying it pure, and propagates that like any effect (so the receipt's unresolved count is truthful, not a hardcoded 0). A LOCAL closure whose body IS visible (let f = |..| ..; f()) is NOT flagged — its effects were already walked lexically.

  • Local-trait dispatch (syntactic CHA): a dispatch-typed receiver — &dyn Store / impl Store / S: Store param, a Box<dyn Store> field or let — resolves to the trait's LOCAL implementors when the trait is locally declared, its declaration carries the called method, and the dispatch is narrow (≤12 impls, the cross-engine bound) — so the DI pattern (self.store.save()PgStore::save) carries its effects. A local trait declaring the method but with no visible impl, an over-broad impl set, or an ambiguous trait name reads honest Unknown instead. Dispatch through an EXTERNAL trait (impl Iterator, serde's traits) is deliberately left out — even when a local type implements it (resolving there fabricated effects onto pure generic fns) — and a call the trait doesn't declare (.clone() on a bound param) neither edges nor floods.

  • Misses (silently): effects reached only through external-trait (dyn) dispatch or an uninferrable receiver, Deref-coercion receivers (self.agent.run() where the field is a wrapper that derefs to the type owning run), generic-parameter fields (struct B<S>(S)self.0.method() can't be typed without instantiating S), overloaded operators / ? / .await desugars, RAII drops (an effectful Drop::drop has no call expression — it runs at scope end), a custom Iterator::next reached only via a for-loop desugar, and cross-crate propagation by stable identity. These need the semantic resolution only the nightly lint has (the soundness fuzzer locks the desugar forms against the lint: op_add/index/deref/try_from/await_poll/drop/iterator/eq/add_assign). The Deref and generic-field shapes are measured in the wild (the PROVE-IT dogfood on ureq): 14 of a 16-function blast radius found, those two shapes the remainder — under-reported, never fabricated.

  • The κ-coverage ledger: the receipt names every Cargo.toml dependency the code demonstrably calls that the classifier knows nothing about (κ doesn't know N dependencies… effects through them are INVISIBLE (not Unknown)). The curated-classifier caveat as per-scan evidence instead of a doc footnote: never conclude "no effect" through a crate that line names.

  • Close a named blind spot by CHAINING (CANDOR_DEPS): scan the dependency itself (the scanner reads unbuilt source — ~/.cargo/registry/src/... works directly), then point CANDOR_DEPS at its report (a :-separated list of files and/or directories): an unclassified call into a crate a report covers inherits that function's recorded effects AND literal surfaces (hosts/cmds/paths/ tables) across the crate boundary, joined unambiguous-tail-first like every other resolution (spec §2; reports from a different scanner version are downgraded to Unknown, §2.1). The ledger names what's invisible; one dep scan closes it — κ only ever has to know the std/builtin frontier.

The policy gate floor. candor-scan <dir> --policy <file> (or CANDOR_POLICY=…) enforces a spec-§6.2 policy (deny/pure/allow/forbid — parsed by the same shared grammar as the nightly and JVM gates) over the scan and exits 1 on violation. It is the advisory floor: the syntactic backend under-reports, so a missed effect can pass — a clean run is necessary, never sufficient. It still catches every boundary crossing the scan can see, deterministically, with zero extra install.

For the soundness contract (Unknown over-approximation), conformance checking, and the sound policy/guard CI gates, use the full nightly candor lint. The two share one classifier and one policy parser, so they never disagree on what counts as an effect or what a rule means.

Usage

candor-scan [<dir>] [--out <prefix>] [--json] [--include-tests]
  • <dir> — crate root to scan (default .).
  • --out <prefix> — report path prefix (default <dir>/.candor/report); writes <prefix>.<crate>.scan.json plus a call-graph sidecar.
  • --json — print the report to stdout instead of writing files.
  • --include-tests — also scan tests//benches//examples/ and #[cfg(test)] modules (off by default, so the report describes the crate, not its test harness).

Requires a reasonably recent stable Rust (edition 2024 → Rust 1.85+).

Licensed under MIT OR Apache-2.0. Part of candor; see the repo for the calibration (eval/calibration/) and the full effect model.