Skip to main content

candor_classify/
lib.rs

1//! candor-classify — the curated effect classifier (crate+path -> effect), extracted to a STABLE
2//! crate so both the nightly `rustc_private` lint AND a stable backend share ONE source of truth
3//! (no drift). Pure string logic; no rustc internals. The effect vocabulary lives in candor-report.
4
5use candor_report::EFFECTS;
6
7/// The canonical CANDOR_POLICY DSL parser (SPEC §6.2), shared by the nightly gate and candor-query.
8pub mod policy;
9#[cfg(test)]
10mod policy_props;
11
12/// The SURPRISE heuristic (the cold-repo hook) — SHARED so candor-scan's scan-time note and
13/// candor-query's `tour` verb can't drift. Generic over the effect element type.
14pub mod surface;
15
16/// The transitive least fixed point over a call graph — SHARED so the scanner's gate-side reason-class
17/// accumulator and candor-query's `unverified --class` filter resolve over the SAME reach.
18pub mod propagate;
19
20/// ⟨0.24⟩ The §6.2 GATE over an already-accumulated signature — SHARED so `candor-scan --policy` and
21/// `candor-query gate --report` (SPEC §3.1) are the same gate reached by two routes, not two gates.
22pub mod gate;
23
24/// Project-supplied rules, consulted only when the built-in `classify` returns None.
25pub fn classify_extra(
26    crate_name: &str,
27    path: &str,
28    extra: &[(&'static str, bool, String)],
29) -> Option<&'static str> {
30    for (eff, is_crate, prefix) in extra {
31        let hit = if *is_crate { crate_name.starts_with(prefix.as_str()) } else { path.starts_with(prefix.as_str()) };
32        if hit {
33            return Some(eff);
34        }
35    }
36    None
37}
38
39/// The exact third-party crates `classify` has effect rules for, and the crate-name
40/// PREFIXES it recognizes. This is the single source of truth for "what candor knows":
41/// it is emitted beside the JSON report (`<prefix>.calibrated.json`) so the Claude Code
42/// receipt's coverage check reads candor's real coverage instead of a hand-copied list.
43/// Keep in lockstep with `classify` below — the `db_crates_are_calibrated` and
44/// `calibrated_crates_are_live` tests (in this crate's `tests` module) enforce both directions.
45pub const CALIBRATED_CRATES: [&str; 82] = [
46    // network (aws_config resolves credentials over the network on `.load()`;
47    // git2 remote ops — fetch/push/connect — contact the network; async_net is smol's net layer;
48    // pnet is raw L2/L3 packet capture)
49    "reqwest", "isahc", "ureq", "curl", "aws_config", "git2", "tokio_tcp", "tokio_udp", "async_net",
50    "async_nats", "lapin", "lettre", "tungstenite", "elasticsearch", "tonic", "rdkafka", "pnet",
51    // directory traversal (ignore = gitignore-aware walker, powers ripgrep/fd; its walk executors are Fs)
52    // + filesystem watching (notify = inotify/FSEvents/kqueue wrapper; powers watchexec/cargo-watch)
53    "ignore", "notify",
54    // database (see DB_CRATES in classify)
55    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
56    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
57    // filesystem (async_fs = smol; fs_err = std::fs wrapper; tempfile; glob) / entropy /
58    // subprocess (async_process = smol; duct) / env (dotenvy/dotenv) / clock (time) / log / clipboard
59    "memmap2", "fs_err", "async_fs", "tempfile", "glob",
60    "rand", "getrandom", "fastrand",
61    // entropy: the password-hashing tier (salt mints + bcrypt's internal salt) + the OsRng source
62    "argon2", "bcrypt", "scrypt", "pbkdf2", "password_hash", "rand_core",
63    "portable_pty", "async_process", "duct",
64    "dotenvy", "dotenv",
65    "chrono", "time", "tracing", "log", "arboard",
66    // compiler diagnostic emission (a dylint lint's output) — see the Log rules in classify
67    "rustc_lint", "rustc_errors",
68    // raw syscalls via FFI — the syscall-name table that lights up the FFI-thin tier (nix is routed
69    // through the same table by leaf name, so a consumer of nix is covered without nix's own source)
70    "libc", "nix", "rustix",
71    // coverage-differential additions (verb-keyed; see the per-crate rules near the end of classify):
72    // sync TLS core + native-tls variants (Net); env/dir resolution + argv + LS_COLORS (Env);
73    // sqlx-core execution terminals (Net/Db); directory walk + timestamp mutation + same-file (Fs);
74    // process-spawn helpers (Exec); signal handler + interactive-tty prompts (Ipc); env_logger (Log);
75    // jiff/backoff clock reads (Clock).
76    "rustls", "native_tls_crate", "tokio_native_tls",
77    "etcetera", "wild", "lscolors",
78    "sqlx_core", "walkdir", "filetime", "clircle",
79    "execute", "ctrlc", "clap", "jiff", "env_logger",
80    "dialoguer", "console", "terminal_colorsaurus", "backoff", "grep_cli",
81    // TUI: the terminal is a user dialogue channel (Ipc), exactly as dialoguer/console already rule.
82    // crossterm does the tty I/O; ratatui renders to a Buffer and drives a backend that does.
83    "crossterm", "ratatui",
84    // tracing_subscriber: the fmt INIT terminals write program output (Log); the EnvFilter constructors
85    // read RUST_LOG (Env). Everything else — layers, formatters, filter types — is a builder.
86    "tracing_subscriber",
87];
88
89pub const CALIBRATED_PREFIXES: [&str; 3] = ["aws_sdk_", "aws_smithy", "cap_"];
90
91/// Crates `classify` matches by PATH prefix rather than crate-name equality (their effectful modules
92/// are recognised, e.g. `tokio::net::`/`async_std::fs::`/`mio::net::`), so they're absent from
93/// `CALIBRATED_CRATES` (which the liveness test probes by crate name). The coverage check must still
94/// treat them as *covered* — otherwise it would mislabel the most common async crates as blind spots.
95pub const PATH_CALIBRATED_CRATES: [&str; 3] = ["tokio", "async_std", "mio"];
96
97/// The FFI-thin syscall crates whose `CALIBRATED_CRATES` membership is INCOMPLETE BY DESIGN.
98///
99/// For every OTHER calibrated crate, "classify returned None" is a REVIEWED verdict — the table covers
100/// the crate's effectful surface, so a miss means the call really is pure, and candor-scan's coverage
101/// ledger exempts all of `CALIBRATED_CRATES` from blind-spot disclosure on exactly that theory (a
102/// calibrated crate's silence is informative). That does not hold for `libc`/`nix`/`rustix`: the table
103/// above DELIBERATELY skips their generic fd verbs (`read`/`write`/`close`/`lseek`/`dup`/`fcntl`/...)
104/// because a fixed label would mis-categorise an ambiguous fd (file? socket? pipe?) as often as it
105/// helps — an honest no-classify, not a purity finding. Real programs that touch these crates go
106/// through those exact verbs constantly (`libc::read(fd, ..)` IS the effect, not a builder step), so the
107/// blanket exemption converted "no rule here" into a confident, silent purity claim: `fn drain(fd: i32)
108/// { libc::read(fd, buf, 64); }` reported zero effects AND zero disclosure (R59, SOUNDNESS.md) — worse
109/// than an uncalibrated dependency, which at least discloses `invisible`.
110///
111/// Consumed ONLY by candor-scan's coverage-ledger filter (the same one-consumer site
112/// `coverage_has_exactly_one_anchor_and_exactly_one_consumer` enforces), to carve these three OUT of the
113/// `CALIBRATED_CRATES` exemption there — an unclassified call into them still joins the ledger and
114/// disclosed `invisible`, exactly like a call into an uncalibrated dependency, while their CLASSIFIED
115/// calls (`open`/`close`/`socket`/...) are untouched (they never reach the ledger at all — see
116/// `blind_direct`'s `classified.is_none()` gate). Deliberately NOT removed from `CALIBRATED_CRATES`
117/// itself: that list means "classify has SOME live rules here" (true — the FS/NET/EXEC/IPC/ENV/CLOCK/
118/// RAND tables all fire for these three), and stays the honest `.calibrated.json` sidecar contract.
119pub const CALIBRATED_BUT_PARTIAL_CRATES: [&str; 3] = ["libc", "nix", "rustix"];
120
121/// SOUNDNESS R311 — the version each calibrated crate's rules were actually WRITTEN AGAINST.
122///
123/// `CALIBRATED_CRATES` converts "no rule matched" into "reviewed, and pure", with no disclosure. That
124/// claim is evidence about SOURCE SOMEONE READ, and it was being granted on the crate NAME alone — so
125/// an upstream release extended it, silently, to code that did not exist when the review happened.
126/// Measured 2026-09-07: the sea_orm rule block says in terms that it was "verified against sea-orm
127/// 1.1.20 source"; the calibrated fixture resolved 2.0.2, whose `DatabaseExecutor`,
128/// `RestrictedConnection`, `RestrictedTransaction`, `RbacContext` and `RusqliteSharedConnection` carry
129/// 43 public entry points that no rule can name and every one of which read PURE.
130///
131/// A crate resolving ABOVE its ceiling is downgraded to `CALIBRATED_BUT_PARTIAL_CRATES` behaviour:
132/// matched calls keep their rules (they are still right), unmatched calls rejoin the ledger and
133/// disclose `invisible` instead of reading as reviewed-pure. Fail-closed AT THE POINT THE CLAIM IS
134/// MADE — the coverage gate can only ever notify us afterwards.
135///
136/// EXACT, not major-only, and the direction is deliberate: an exact ceiling fails NOISY (a minor bump
137/// discloses until someone re-reviews and moves the line) while major-only fails SILENT — and the
138/// minor direction is not hypothetical, since redis 1.6.0 -> 1.7.0 and mongodb 3.8.1 -> 3.9.0 both
139/// produced real uncovered entry points in the same measurement. Per the family's denylist rule, start
140/// strict and relax against measured disclosure volume, never the reverse.
141///
142/// A crate ABSENT from this table keeps its unconditional exemption: the ceiling is a denylist of
143/// versions known to be beyond the review, not an allowlist of versions blessed by it. Adding a crate
144/// here is what makes its claim honest, so the table is expected to grow toward `CALIBRATED_CRATES`.
145// SOUNDNESS R328 — this table is MERGED, never rewritten. `bbd7615` replaced it wholesale with a
146// freshly-computed dict and silently dropped NINE entries (dialoguer, dotenv, dotenvy, grep_cli, isahc,
147// lettre, portable_pty, tempfile, postgres) while its own commit message claimed "four crates are
148// deliberately absent, each with a stated reason in the code". Eight had no stated reason and every one
149// of them cites a version in its own rules — they are exactly the crates the ceiling exists to protect,
150// and the protection would have been gone the moment any of them published. Effect classes at stake:
151// Net (isahc), SMTP (lettre), Exec (portable_pty), Fs (tempfile), Env (dotenv/dotenvy), Ipc (dialoguer).
152//
153// DELIBERATELY ABSENT, and this is the complete list:
154//   postgres  — R313. Its recorded 0.7.10 was TOKIO-postgres's version, lifted by a regex from a comment
155//               about a different crate; postgres ships 0.19.x. No verified baseline exists and inventing
156//               one is the claim-without-review this table exists to stop.
157//   async_fs, getrandom — their rules match on CRATE NAME alone, so no path can go stale.
158//   rand_core — R320/R333 unresolved.
159pub const CALIBRATED_CEILINGS: [(&str, &str); 70] = [
160    ("arboard", "3.6.1"),
161    ("argon2", "0.6.0"),
162    ("async_nats", "0.35.1"),
163    ("async_process", "2.5.0"),
164    ("aws_config", "1.12.0"),
165    ("backoff", "0.4.0"),
166    ("bcrypt", "0.19.3"),
167    ("chrono", "0.4.45"),
168    ("clap", "4.6.6"),
169    ("clircle", "0.6.1"),
170    ("console", "0.15.11"),
171    ("crossterm", "0.28.1"),
172    ("ctrlc", "3.5.2"),
173    ("curl", "0.4.50"),
174    ("deadpool_postgres", "0.14.2"),
175    ("dialoguer", "0.12.0"),
176    ("diesel", "2.3.13"),
177    ("dotenv", "0.15.0"),
178    ("dotenvy", "0.15.7"),
179    ("duct", "1.1.2"),
180    ("elasticsearch", "8.19.0-alpha.1"),
181    ("env_logger", "0.11.11"),
182    ("etcetera", "0.11.0"),
183    ("execute", "0.3.0"),
184    ("fastrand", "2.5.0"),
185    ("filetime", "0.2.29"),
186    ("fs_err", "3.3.1"),
187    ("git2", "0.20.4"),
188    ("glob", "0.3.4"),
189    ("grep_cli", "0.1.12"),
190    ("ignore", "0.4.33"),
191    ("isahc", "2.0.1"),
192    ("jiff", "0.2.35"),
193    ("lapin", "4.10.0"),
194    ("lettre", "0.11.23"),
195    ("log", "0.4.34"),
196    ("lscolors", "0.21.0"),
197    ("memmap2", "0.9.11"),
198    ("mongodb", "3.8.1"),
199    ("mysql", "28.0.0"),
200    ("mysql_async", "0.37.0"),
201    ("native_tls_crate", "0.2.18"),
202    ("notify", "8.2.0"),
203    ("password_hash", "0.6.1"),
204    ("pbkdf2", "0.13.0"),
205    ("pnet", "0.35.0"),
206    ("portable_pty", "0.9.0"),
207    ("rand", "0.10.2"),
208    ("ratatui", "0.29.0"),
209    ("rdkafka", "0.39.0"),
210    ("redis", "1.6.0"),
211    ("reqwest", "0.13.4"),
212    ("rusqlite", "0.39"),
213    ("rustls", "0.23.43"),
214    ("scrypt", "0.12.0"),
215    ("sea_orm", "1.1.20"),
216    ("sqlx", "0.9.0"),
217    ("sqlx_core", "0.9.0"),
218    ("tempfile", "3.27.0"),
219    ("terminal_colorsaurus", "1.0.3"),
220    ("time", "0.3.55"),
221    ("tokio_native_tls", "0.3.1"),
222    ("tokio_postgres", "0.7.18"),
223    ("tonic", "0.12.3"),
224    ("tracing", "0.1.44"),
225    ("tracing_subscriber", "0.3.23"),
226    ("tungstenite", "0.30.0"),
227    ("ureq", "2.12.1"),
228    ("walkdir", "2.5.0"),
229    ("wild", "2.2.1"),
230];
231
232/// `true` when `resolved` is a version this crate's rules were NOT written against — i.e. strictly
233/// newer than its ceiling. Unknown crate, unparseable version, or an exact match: `false`, keep the
234/// exemption. Comparison is numeric per dotted component so `1.10.0 > 1.9.0`; a pre-release or build
235/// suffix on the resolved version is treated as ABOVE the release it decorates, which is the
236/// charging direction.
237/// Version-only comparison: is `a` strictly newer than `b`? Shared with `candor-scan`'s lockfile
238/// parser so "which of two versions of one crate do we keep" is answered by the SAME comparator that
239/// decides whether a version is beyond review — one authority, not two spellings that drift.
240pub fn calibration_exceeded_raw(a: &str, b: &str) -> bool {
241    let parts = |v: &str| -> Vec<u64> {
242        v.split(['+', '-']).next().unwrap_or("")
243            .split('.')
244            .map(|p| p.parse::<u64>().unwrap_or(0))
245            .collect()
246    };
247    let (x, y) = (parts(a), parts(b));
248    for i in 0..x.len().max(y.len()) {
249        let (p, q) = (x.get(i).copied().unwrap_or(0), y.get(i).copied().unwrap_or(0));
250        if p != q {
251            return p > q;
252        }
253    }
254    a != b
255}
256
257pub fn calibration_exceeded(crate_name: &str, resolved: &str) -> bool {
258    let Some((_, ceiling)) = CALIBRATED_CEILINGS.iter().find(|(c, _)| *c == crate_name) else {
259        return false;
260    };
261    let parts = |v: &str| -> Vec<u64> {
262        v.split(['+', '-']).next().unwrap_or("")
263            .split('.')
264            .map(|p| p.parse::<u64>().unwrap_or(0))
265            .collect()
266    };
267    let (r, c) = (parts(resolved), parts(ceiling));
268    if r.is_empty() {
269        return false;
270    }
271    for i in 0..r.len().max(c.len()) {
272        let (a, b) = (r.get(i).copied().unwrap_or(0), c.get(i).copied().unwrap_or(0));
273        if a != b {
274            return a > b;
275        }
276    }
277    // Equal numerically: a pre-release/build suffix means it is not the reviewed artifact.
278    resolved != *ceiling
279}
280
281
282/// Crates REVIEWED AND FOUND TO PERFORM NO EFFECT OF THEIR OWN — the κ ledger treats them as covered, so
283/// their calls stop being disclosed blind spots.
284///
285/// SEPARATE FROM `CALIBRATED_CRATES` BY NECESSITY, not taste. That list means "classify has effect rules
286/// here", and `calibrated_crates_are_live` fails any entry no rule matches — "a dead entry would silently
287/// suppress a real coverage warning". A genuinely pure crate has no rule to be live, so it cannot go
288/// there; without this list the only way to silence its noise would be to invent a rule, which is worse.
289///
290/// **THIS LIST MANUFACTURES PURITY CLAIMS, so an entry needs evidence and not a reputation.** A crate here
291/// stops being disclosed and starts being believed. Each of these was checked against its source in the
292/// local cargo registry for `std::{fs,net,process,env}` and stdio use, and every apparent hit was a DOC
293/// COMMENT (serde_json's `/// [`File`]: std::fs::File`, serde_yml's `///  io::stdout()`):
294///
295///   serde_json 1.0.151, serde_yml 0.0.12, toml 1.1.3, regex 1.13.1, sha2 0.11.0
296///
297/// `color_eyre` was on the same filing and is NOT here — FETCHED AND CHECKED 2026-08-03, and it is not
298/// pure. It reads `RUST_BACKTRACE` / `RUST_LIB_BACKTRACE` / `RUST_SPANTRACE` / `COLORBT_SHOW_HIDDEN`
299/// (Env, `config.rs:939..1175`) and **opens source files to render code snippets** (Fs,
300/// `config.rs:248`). It is also not CALIBRATED, deliberately: the `File::open` sits inside
301/// `impl fmt::Display for SourceSection`, reached when a report is RENDERED rather than through any named
302/// verb a caller invokes — so there is no path for a rule to match, and calibrating the crate would turn
303/// that render path into an unmatched path, i.e. a PURITY CLAIM over the file read. Its calls therefore
304/// stay disclosed as a blind spot. The noise is real; it is also honest, and that is the right trade.
305///
306/// THE SERIALIZER CAVEAT, worth stating because it is the one that looks wrong: `serde_json::from_reader`
307/// and `to_writer` do move bytes — but through a handle the CALLER had to obtain, and obtaining it (a
308/// `File::open`, a `TcpStream::connect`) is already classified on the caller. The crate performs no
309/// syscall of its own, so charging it would double-count an effect the caller already carries.
310pub const REVIEWED_PURE_CRATES: [&str; 5] = ["serde_json", "serde_yml", "toml", "regex", "sha2"];
311
312/// The completeness gate's escape hatch (`eval/coverage-gate/`, `tests/coverage_gate.rs`): exact
313/// (crate, consumer-facing path) pairs a human has read and found genuinely pure, despite `candor-scan`
314/// self-scanning the crate's own vendored source finding a reachable Fs/Net/Db/Exec effect there. SEPARATE
315/// from `REVIEWED_PURE_CRATES` — that list exempts a WHOLE crate from the coverage LEDGER's crate-level
316/// disclosure; this one exempts a single ENTRY from the completeness GATE's per-function differential.
317/// The gate's generator run (2026-08-27) found 669 self-scan-confirmed entries `classify()` already
318/// recognizes (checked in as `covered.tsv`, asserted every push) and 251 it did not yet recognize under
319/// any guessed spelling (checked in as `open.tsv`, a ratchet — NOT individually hand-verified, see that
320/// file's header). An `open.tsv` row belongs here ONLY once read against the crate's real source and
321/// confirmed to perform no effect (the SAME evidence bar `REVIEWED_PURE_CRATES` documents) — until then
322/// it stays in the ratchet as an open question, not a silent assumption. The five below were the first
323/// batch, from the 2026-08-27 coverage-gate triage:
324///
325///   - `curl::Multi::timeout` — this file's own `curl` rule (above) already documents WHY: `Easy::
326///     timeout` is a pure `CURLOPT_TIMEOUT` setter sharing the `::timeout` leaf, and an under-report on
327///     the rare event-loop kick beats mis-tagging every consumer that sets a timeout.
328///   - `execute::command` / `execute::shell` — this file's own `execute` rule (above) already documents
329///     WHY: both free functions only BUILD a `std::process::Command` (no `.spawn()`/`.output()`/
330///     `.status()` call in either body) and return it to the caller, who spawns it themselves — the
331///     caller's own `Command::spawn()` carries the effect. Self-scan's raw "Exec" signal comes from
332///     `std::process::Command::new`'s coarse whole-type rule (constructing a `Command` is Exec by
333///     default, narrowed only for crates like `async_process` that document their own pure-setter
334///     surface), not from an actual subprocess launch in either function.
335///   - `elasticsearch::Response::content_type` — a pure accessor reading a header off an ALREADY-received
336///     response (`self.response.headers().get(..)`, http/response.rs:61) — no I/O of its own. Self-scan's
337///     raw "Net" signal is the underlying HTTP client response type's coarse whole-type propagation, not
338///     a second network round-trip.
339///   - `rusqlite::Context::get_connection` — this file's own rusqlite rule (above) already documents WHY:
340///     it hands back a `ConnectionRef` BORROWED from the ALREADY-established connection a running SQL
341///     function executes within (`ffi::sqlite3_context_db_handle`, not a syscall) — the same "handle
342///     accessor, not a syscall" shape as `TcpStream::local_addr`.
343///
344/// The next two (2026-08-28, the widened-CORE audit's `Ipc` batch — see `eval/coverage-gate/generate.py`
345/// for why `Ipc` only entered the trigger set that day) are a DIFFERENT reason than the five above: not
346/// "no effect", but genuinely UNREACHABLE — the item really does perform the effect self-scan found, but
347/// no external consumer can ever name it, because the module it lives in is declared with no `pub` at
348/// all and neither the module nor the item is re-exported anywhere in the crate root (checked against
349/// real source, both ways):
350///   - `dialoguer::Paging::render_prompt` — `dialoguer::paging` is a bare `mod paging;` (dialoguer
351///     0.12.0, lib.rs:62: `use paging::Paging;`, not `pub use`), so `pub struct Paging` and its `pub fn
352///     render_prompt` (which DOES call `Term::flush`, a real Ipc effect per this file's own `console`
353///     rule) are internal machinery every `Select`/`MultiSelect`-style prompt uses to render its own
354///     paginated view — the effect is already charged at THOSE public entry points' own `::interact*`
355///     rule (above), not double-counted here.
356///   - `mysql::Stream::connect_socket` — `mysql::io` is a bare `mod io;` (mysql 28.0.0, lib.rs:897, no
357///     `pub use` of `io::*` or `Stream` anywhere), so `pub enum Stream` and its `pub fn connect_socket`
358///     (a real Unix-domain-socket `connect()`, Ipc) are reachable only from within the crate's own
359///     connection-establishment code — a real consumer can't name `mysql::Stream` at all, let alone call
360///     a bare constructor on it.
361pub const REVIEWED_PURE_ENTRIES: &[(&str, &str)] = &[
362    ("curl", "curl::Multi::timeout"),
363    ("execute", "execute::command"),
364    ("execute", "execute::shell"),
365    ("elasticsearch", "elasticsearch::Response::content_type"),
366    ("rusqlite", "rusqlite::Context::get_connection"),
367    ("dialoguer", "dialoguer::Paging::render_prompt"),
368    ("mysql", "mysql::Stream::connect_socket"),
369];
370
371/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
372/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
373/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
374/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
375/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
376/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
377pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
378    "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
379    "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
380    "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
381    "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
382    "::X::draw",
383    "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
384    // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
385    // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
386    "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
387    // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
388    // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
389    "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
390    "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
391    "::write_line", "::background_color", "::retry", "::build",
392];
393
394/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
395/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
396pub const DB_CRATES: [&str; 11] = [
397    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
398    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
399];
400
401/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
402/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
403/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
404/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
405/// perform I/O. calling a PURE function effectful is a FABRICATION — the precision failure (candor's cardinal sin is the opposite direction, the silent under-report) — and these collide with the
406/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
407/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
408/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
409/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
410/// fabricated Net/Fs/Ipc.)
411const PURE_FD_TRANSFER: &[&str] = &[
412    "from_raw_fd", "from_raw_socket", "from_raw_handle",
413    "into_raw_fd", "into_raw_socket", "into_raw_handle",
414    "as_raw_fd", "as_raw_socket", "as_raw_handle",
415    "into_std",
416    // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
417    // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
418    // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
419    "from_pathname",
420];
421
422/// The std I/O **handle** types whose methods the scanner's receiver inference may route into
423/// `classify` as `Type::method`.
424///
425/// WHY A LIST AND NOT "std". The std rules in `classify` are a MIXTURE: a few are type-precise with
426/// reviewed pure-accessor carve-outs (`std::net::TcpStream` → Net minus `local_addr`/`peer_addr`/…;
427/// `std::process::Command` → Exec minus `get_program`/`get_args`/…), but most are coarse MODULE
428/// prefixes (`std::fs::` → Fs) written for free functions and constructors. Applied to arbitrary
429/// method calls, a coarse prefix charges an effect for reading a struct field: `std::fs::Metadata`,
430/// `DirEntry`, `Permissions` and `FileType` all sit under `std::fs::` and are pure DATA. So the
431/// scanner used to skip EVERY std-rooted receiver — which shut the door on the pure data types and on
432/// the real handles alike, and `fn run(cmd: &mut Command) { cmd.spawn(); }` certified PURE while it
433/// spawned a process (a silent false all-clear; the `cc` crate's `command_helpers::spawn` was a live
434/// instance). This list re-opens the door for the handles ONLY.
435///
436/// MEMBERSHIP RULE, and the reason it is safe to apply a whole-type rule to an inferred method call:
437/// a type belongs here only when EVERY method on it is either the effect itself or an already-carved-
438/// out pure read-back. That holds for an open descriptor's handle — a `File`/`TcpStream`/`Child` does
439/// nothing but I/O on the thing it owns — and the `PURE_FD_TRANSFER` guard at the top of `classify`
440/// exempts the `as_raw_fd`/`into_std` family for all of them before any prefix rule runs.
441///
442/// THE OPTION-BUILDERS, `OpenOptions` and `DirBuilder`, were absent for exactly this rule and now
443/// satisfy it: each has a type-keyed carve-out in `classify` subtracting its pure setters, so only its
444/// TERMINAL VERB (`open` / `create`) is Fs. The exclusion was written as "a narrow residual, not the
445/// reported hole" and MEASURED otherwise — `fn load(o: &OpenOptions, p: &Path) { o.open(p) }` opened a
446/// file and reported NOTHING, the same silent false all-clear as the `Command` parameter, while a
447/// setter-only `OpenOptions::new().read(true)` reported `Fs` for opening nothing. The order the old
448/// comment prescribed is the order taken: the verb-precise rules went in FIRST, and this list widened
449/// onto them. `ReadDir` stays out — its `next` IS a syscall, but nothing distinguishes it from any
450/// other iterator's `next`, so no verb-precise rule can be written and the honest miss stands.
451///
452/// `std::path::Path`/`PathBuf` are NOT here: they are pure data with an effectful STAT sub-surface —
453/// the exact inverse shape — and they route through their own verb-precise carve-out in the scanner.
454const STD_EFFECT_HANDLES: &[&str] = &[
455    // Exec — the whole type is the subprocess boundary (`new` names a program, `arg`/`env` build the
456    // invocation, `spawn`/`output`/`status` run it); the read-back getters are already carved out.
457    "std::process::Command",
458    "std::process::Child",
459    // Net — an open socket. `connect`/`accept`/`read`/`write`/`shutdown`/`set_*` are socket syscalls;
460    // `local_addr`/`peer_addr`/`nodelay`/`ttl`/`take_error` are carved out.
461    "std::net::TcpStream",
462    "std::net::TcpListener",
463    "std::net::UdpSocket",
464    // Ipc — a Unix-domain socket, the same shape as the TCP/UDP handles above.
465    "std::os::unix::net::UnixStream",
466    "std::os::unix::net::UnixListener",
467    "std::os::unix::net::UnixDatagram",
468    // Fs — an open file. Every method reads, writes, syncs, truncates or stats it; the descriptor
469    // conversions are carved out by `PURE_FD_TRANSFER`.
470    "std::fs::File",
471    // Fs — the option-builders, whose PURE setters are subtracted by the type-keyed carve-outs in
472    // `classify` so that only the terminal verb (`OpenOptions::open`, `DirBuilder::create`) routes to Fs.
473    "std::fs::OpenOptions",
474    "std::fs::DirBuilder",
475];
476
477/// Is `ty` a std I/O handle type whose methods may be routed into `classify` by the scanner's
478/// receiver inference? See `STD_EFFECT_HANDLES` for the membership rule and the named exclusions.
479pub fn is_std_effect_handle(ty: &str) -> bool {
480    STD_EFFECT_HANDLES.contains(&ty)
481}
482
483/// Classify a resolved callee by the crate it belongs to and its full path.
484pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
485    // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
486    // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
487    if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
488        return None;
489    }
490    if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
491        // Only request dispatch is network I/O; builder setters/accessors are pure.
492        if path.ends_with("::send") || path.ends_with("::send_with") {
493            return Some("Net");
494        }
495        return None;
496    }
497    // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
498    // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
499    // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
500    // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
501    //
502    // THE FIX: `load_from_env()` (lib.rs:170/188, both the `behavior-version-latest` and deprecated
503    // forms) is `from_env().load().await` / `load_defaults(latest).await` one call down — the crate's OWN
504    // "convenience wrapper" (its own doc comment's phrase) around the already-modelled `.load()` — but
505    // `"load_from_env".ends_with("::load")` is false, so the wrapper read pure. Same vein as
506    // `ignore::Walk::new`.
507    if crate_name == "aws_config" {
508        if path.ends_with("::load") || path.ends_with("::load_defaults") || path.ends_with("::load_from_env") {
509            return Some("Net");
510        }
511        return None;
512    }
513    // git2 (libgit2 FFI): remote operations contact the network; everything else is local
514    // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
515    // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
516    // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
517    // client reporting it makes no network calls.)
518    //
519    // `Repository::clone`/`clone_recurse`/`clone_local`/`init` (via `RepoBuilder::clone`) IS the
520    // thing the comment above named and then excluded anyway: the bare `::clone` denylist meant
521    // to keep out `Remote::clone` (the derived trait dup) also swallowed `Repository::clone` —
522    // libgit2's actual network clone, and arguably git2's single most common entry point. A
523    // fabrication corpus round caught it: `git2::Repository::clone(url, path)` reported ZERO
524    // effects and passed `deny Net` at exit 0. FQN-exact (like the `reqwest::get` disambiguation
525    // a few lines up) so the fix doesn't just re-widen `::clone` and reintroduce the very
526    // over-charge the comment was written to prevent: `Remote::clone` (and `Repository::clone`'s
527    // own `Clone` derive, if it has one) must still read pure.
528    if crate_name == "git2" {
529        if path.ends_with("::fetch")
530            || path.ends_with("::push")
531            || path.ends_with("::download")
532            || path.ends_with("::connect")
533            || path.ends_with("::connect_auth")
534            || path.ends_with("::ls")
535            || path.ends_with("::upload")
536            || path == "git2::Repository::clone"
537            || path == "git2::Repository::clone_recurse"
538            || path == "git2::build::RepoBuilder::clone"
539            // A FOURTH+FIFTH true positive in the same gap the `Repository::clone` fix left open:
540            // `Submodule::clone`/`Submodule::update` (submodule.rs:39,234) call `raw::git_submodule_clone`/
541            // `git_submodule_update` DIRECTLY — the exact FFI leaves already in this file's own
542            // FFI-tier NET table below, but only when a caller names the raw `ffi`/`raw::` leaf itself.
543            // git2's documented, standard submodule-init idiom (`sub.clone(None)` /
544            // `sub.update(true, None)`) never does that — it calls the safe wrapper, which carried no
545            // rule of its own and read pure under `deny Net`. FQN-exact, matching the `Repository::clone`
546            // fix's own discipline: a bare `::update`/`::clone` substring would sweep in git2's many pure
547            // `update_*` setters (`CheckoutBuilder::update_only`, `DiffOptions::update_index`, …) and the
548            // derive-`Clone` dup on every other git2 type.
549            || path == "git2::Submodule::clone"
550            || path == "git2::Submodule::update"
551            // `Remote::list`/`RemoteConnection::list` (remote.rs:378,755) call `raw::git_remote_ls`
552            // directly — the reference-advertisement fetch already in the FFI-tier NET table below,
553            // reached the same way `Submodule::clone`/`update` were: the safe wrapper's OWN name
554            // (`list`, not `ls`) never matched the `::ls` suffix this block already carried (which, on
555            // git2 0.20, matches no real method at all — dead weight kept for whatever older API it once
556            // fit). FQN-exact so a `list` on some other git2 type (none exist today, but the same
557            // discipline as `Submodule::clone` above) can't be swept in by a bare suffix later.
558            || path == "git2::Remote::list"
559            || path == "git2::RemoteConnection::list"
560        {
561            return Some("Net");
562        }
563        // THE COVERAGE-GATE SWEEP (2026-08-27): every one of these is a public entry point whose body
564        // calls a `raw::git_*` FFI leaf ALREADY in this file's own FS FFI-tier table below (config.rs/
565        // index.rs/odb.rs/packbuilder.rs/reference.rs/repo.rs/treebuilder.rs, verified against git2
566        // 0.20.4 source) — but that table is UNREACHABLE for a real consumer's call, because it only
567        // fires when self-scan resolves the FFI leaf's OWN crate (`libgit2_sys`, via git2's internal
568        // `use libgit2_sys as raw`), never for `crate_name == "git2"`, which returns from THIS block
569        // first. A consumer never spells `raw::git_repository_open`; they call `git2::Repository::open`,
570        // which fell through this entire branch to `None` — the exact `ignore::Walk::new` shape (a
571        // lower-level rule the top-level entry point never reaches) but affecting git2's single most
572        // common local operations (`Repository::open`/`init`), not an edge case. FQN-exact, not a
573        // `::open`/`::write`/`::read` suffix — those verbs are common enough elsewhere (`Odb::reader`
574        // isn't `::read`, `TreeBuilder`/`Index`/`PackBuilder::write` share the name with plenty of pure
575        // builder setters on other types) that a bare suffix would either miss these or over-charge.
576        if path == "git2::Config::add_file"
577            || path == "git2::Config::open"
578            || path == "git2::Config::open_default"
579            || path == "git2::Index::add_all"
580            || path == "git2::Index::add_path"
581            || path == "git2::Index::read"
582            || path == "git2::Index::write"
583            || path == "git2::Index::write_tree"
584            || path == "git2::Index::write_tree_to"
585            || path == "git2::Odb::read"
586            || path == "git2::Odb::reader"
587            || path == "git2::Odb::write"
588            || path == "git2::Odb::writer"
589            || path == "git2::PackBuilder::write"
590            || path == "git2::Reference::delete"
591            || path == "git2::Reference::set_target"
592            || path == "git2::Repository::blob_path"
593            || path == "git2::Repository::checkout_head"
594            || path == "git2::Repository::checkout_index"
595            || path == "git2::Repository::checkout_tree"
596            || path == "git2::Repository::commit"
597            || path == "git2::Repository::discover"
598            || path == "git2::Repository::discover_path"
599            || path == "git2::Repository::init"
600            || path == "git2::Repository::init_bare"
601            || path == "git2::Repository::init_opts"
602            || path == "git2::Repository::open"
603            || path == "git2::Repository::open_bare"
604            || path == "git2::Repository::open_ext"
605            || path == "git2::Repository::open_from_env"
606            || path == "git2::Repository::reference"
607            || path == "git2::Repository::tag"
608            || path == "git2::TreeBuilder::write"
609        {
610            return Some("Fs");
611        }
612        // `Cred::credential_helper` (cred.rs:121) calls `CredentialHelper::execute` (cred.rs:326),
613        // which — on every code path — spawns a REAL `sh -c "<helper> get"` (or, if `sh` itself fails to
614        // spawn, the helper binary directly) via `std::process::Command::spawn` to resolve
615        // `credential.helper`-configured auth (cred.rs:370-390): a real subprocess launch a caller
616        // authenticating against a private remote is one call away from (`Cred::credential_helper(cfg,
617        // url, user)` is the crate's own documented way to honor `credential.helper`), not a lower-level
618        // implementation detail. FQN-exact: `execute` alone is far too generic to key on blindly.
619        if path == "git2::Cred::credential_helper" || path == "git2::CredentialHelper::execute" {
620            return Some("Exec");
621        }
622        return None;
623    }
624    // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
625    // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
626    // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
627    // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
628    // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
629    // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
630    // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
631    // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
632    // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
633    // gmtime) are also skipped.
634    //
635    // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
636    // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
637    // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
638    // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
639    // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
640    // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
641    // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
642    // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
643    // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
644    // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
645    // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
646    // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
647    // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
648    if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
649        let f = path.rsplit("::").next().unwrap_or(path);
650        // path / directory / metadata syscalls (incl. *64 and *at variants)
651        const FS: &[&str] = &[
652            "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
653            "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
654            "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
655            "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
656            "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
657            "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
658            "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
659            "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
660            "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
661            "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
662            "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
663            "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
664            "realpath",
665        ];
666        // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
667        // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
668        const NET: &[&str] = &[
669            "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
670            "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
671            "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
672        ];
673        // process creation / replacement / reaping
674        const EXEC: &[&str] = &[
675            "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
676            "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
677            "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
678        ];
679        // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
680        const IPC: &[&str] = &[
681            "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
682            "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
683            "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
684        ];
685        const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
686        const CLOCK: &[&str] = &[
687            "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
688            // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
689            // which sets it via `clock_settime`).
690            "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
691        ];
692        const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
693        if FS.contains(&f) {
694            return Some("Fs");
695        }
696        if NET.contains(&f) {
697            return Some("Net");
698        }
699        if EXEC.contains(&f) {
700            return Some("Exec");
701        }
702        if IPC.contains(&f) {
703            return Some("Ipc");
704        }
705        if ENV.contains(&f) {
706            return Some("Env");
707        }
708        if CLOCK.contains(&f) {
709            return Some("Clock");
710        }
711        if RAND.contains(&f) {
712            return Some("Rand");
713        }
714        return None;
715    }
716    // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
717    // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
718    // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
719    // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
720    // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
721    // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
722    // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
723    // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
724    // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
725    {
726        let leaf = path.rsplit("::").next().unwrap_or(path);
727        if let Some(rest) = leaf.strip_prefix("sqlite3_") {
728            let _ = rest;
729            // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
730            //
731            // SOUNDNESS R166 — MEMBERSHIP CRITERION, stated because the list was extended and the next
732            // reader needs to know what "already audited" covers. A leaf belongs here when it either
733            // (a) reads or writes DATABASE CONTENT (disk or the in-memory image), or (b) mutates the
734            // registry of code the engine will later RUN — the extension/auto-extension registry, whose
735            // effect is not local to the calling frame. The audit boundary was the WHOLE `sqlite3_*`
736            // surface rusqlite 0.40.2 mentions (185 distinct symbols diffed against this list), not the
737            // one name the row was filed for: `sqlite3_auto_extension` arrived with its `_cancel_`/
738            // `_reset_` siblings and with four content-I/O leaves nobody had looked at.
739            //
740            // DELIBERATELY STILL ABSENT, so this is not read as "the surface is covered": the
741            // CALLBACK-INSTALLING entry points (`sqlite3_create_function[_v2]`,
742            // `sqlite3_create_window_function`, `sqlite3_create_collation_v2`, `sqlite3_create_module_v2`,
743            // `sqlite3_*_hook`, `sqlite3_set_authorizer`, `sqlite3_progress_handler`, `sqlite3_trace_v2`).
744            // Those register a UDF/hook on ONE connection for the life of that connection; they perform no
745            // database I/O themselves, and the opaque-callback boundary is already disclosed by the
746            // scanner's own `Unknown` at the call site that hands the function pointer across. The
747            // auto-extension registry is the case that differs: it is PROCESS-GLOBAL and outlives every
748            // connection, so its consequence cannot be attributed to a caller's own frame at all.
749            // Unexamined and named as such: the libgit2 (`git_*`) table below got no equivalent sweep.
750            const DB: &[&str] = &[
751                "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
752                "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
753                "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
754                "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
755                "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
756                "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
757                // (b) the PROCESS-GLOBAL extension registry — every one of these changes what a LATER
758                // `sqlite3_open` on any connection will execute, and `sqlite3_load_extension` (already
759                // above) is the same capability one call along.
760                "sqlite3_auto_extension", "sqlite3_cancel_auto_extension", "sqlite3_reset_auto_extension",
761                "sqlite3_enable_load_extension",
762                // (a) further DATABASE-CONTENT I/O the original list missed: whole-image load/save,
763                // a forced page-cache flush to disk, a direct VFS file operation, and arming the
764                // automatic WAL checkpoint writes whose manual twin is already listed.
765                "sqlite3_deserialize", "sqlite3_serialize", "sqlite3_db_cacheflush",
766                "sqlite3_file_control", "sqlite3_wal_autocheckpoint",
767            ];
768            return DB.contains(&leaf).then_some("Db");
769        }
770        if leaf.starts_with("git_") {
771            // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
772            // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
773            // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
774            const NET: &[&str] = &[
775                "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
776                "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
777                "git_submodule_clone", "git_submodule_update",
778            ];
779            // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
780            const FS: &[&str] = &[
781                "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
782                "git_repository_init", "git_repository_init_ext", "git_repository_discover",
783                "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
784                "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
785                "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
786                "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
787                "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
788                "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
789                "git_commit_create_v", "git_reference_create", "git_reference_set_target",
790                "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
791                "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
792                "git_packbuilder_write",
793            ];
794            if NET.contains(&leaf) {
795                return Some("Net");
796            }
797            if FS.contains(&leaf) {
798                return Some("Fs");
799            }
800            return None;
801        }
802        if leaf.starts_with("curl_") {
803            // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
804            // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
805            // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
806            // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
807            // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
808            // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
809            // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
810            const NET: &[&str] = &[
811                "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
812                "curl_multi_perform", "curl_multi_socket_action",
813            ];
814            return NET.contains(&leaf).then_some("Net");
815        }
816        if let Some(op) = leaf.strip_prefix("SSL_") {
817            // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
818            // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
819            // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
820            // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
821            // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
822            const SSL_NET: &[&str] = &[
823                "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
824                "peek_ex", "shutdown",
825            ];
826            return SSL_NET.contains(&op).then_some("Net");
827        }
828    }
829    // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
830    // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
831    // were silently classified network-free because reqwest wasn't recognized.)
832    if crate_name == "reqwest" || crate_name == "isahc" {
833        // The dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot CONVENIENCE functions
834        // `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send immediately — they're
835        // an EXACT match (not `Client::get`, the builder) to avoid false-positiving the builder path.
836        // (Found running on `xh`: a one-shot `reqwest::get(url)` was classified network-free.)
837        if path.ends_with("::send")
838            || path.ends_with("::execute")
839            || path == "reqwest::get"
840            || path == "reqwest::blocking::get"
841            || path == "isahc::get"
842        {
843            return Some("Net");
844        }
845        // THE URL-BEARING BUILDER METHODS: `Client::{get,post,put,delete,patch,head,request}(URL)`.
846        // Real code almost never uses `reqwest::get(url)`; the DOMINANT idiom is the builder chain
847        // `Client::new().post(url).send()` / `Client::builder().build()?.post(url).send()`. The `.send()`
848        // already classifies `Net` — but the URL literal rides the `.post(url)` call, NOT `.send()`, so
849        // without classifying the URL-naming step `Net` the endpoint is NEVER captured and the `Llm`
850        // host refinement can't fire (ebman's `api.anthropic.com` call read as bare Net, undisclosed as
851        // Llm — the dogfood silent under-report). Classifying these `Net` (idempotent with the eventual
852        // `.send()`) makes the scanner capture the URL from their string arg. `request(method, url)`'s
853        // url is its SECOND arg — the scanner's first-string-literal capture still gets it when the
854        // method is a literal string, and misses it (honest under-report) when the method is an
855        // expression. The pure builder surface (`::header`, `::json`, `::body`, `::query`, …) stays None.
856        if path.ends_with("::get")
857            || path.ends_with("::post")
858            || path.ends_with("::put")
859            || path.ends_with("::delete")
860            || path.ends_with("::patch")
861            || path.ends_with("::head")
862            || path.ends_with("::request")
863        {
864            return Some("Net");
865        }
866        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against isahc 2.0.1 — isahc-only, since
867        // reqwest has no equivalent free-function async family:
868        //
869        // `get_async`/`head_async`/`post_async`/`put_async`/`delete_async`/`send_async` (lib.rs:357-500)
870        // are the crate's async one-shot convenience functions — `HttpClient::shared().get_async(uri)`
871        // etc., the exact async mirror of the already-covered `isahc::get`, dispatching immediately.
872        // `HttpClient::new`/`HttpClientBuilder::build` (client.rs:629,444) are genuinely NOT lazy like
873        // reqwest's `Client::new`: `build()` unconditionally calls `agent_builder.spawn()`
874        // (agent/mod.rs:63), which spawns a background OS thread running `AgentContext::run` — curl's
875        // multi-handle event loop that DOES perform the real socket reads/writes for every request later
876        // sent through this client. Confirmed via self-scan on isahc's own source: `AgentBuilder::spawn`
877        // reaches `Net` through that same call chain, not a guess.
878        if crate_name == "isahc"
879            && (path == "isahc::get_async"
880                || path == "isahc::head_async"
881                || path == "isahc::post_async"
882                || path == "isahc::put_async"
883                || path == "isahc::delete_async"
884                || path == "isahc::send_async"
885                || path.ends_with("HttpClient::new")
886                || path.ends_with("HttpClientBuilder::build"))
887        {
888            return Some("Net");
889        }
890        // `reqwest::multipart::Part::file` (blocking/multipart.rs:223) opens the given path directly
891        // (`std::fs::File::open`) to attach it as a form part — genuinely Fs, not Net (the eventual
892        // upload is charged at `.send()`, already covered above).
893        //
894        // NOT modelled: `h3_client::pool::PoolClient::send_request` (async_impl/h3_client/pool.rs:207)
895        // — `mod async_impl;` is PRIVATE in reqwest's lib.rs (only `self::async_impl::multipart` is
896        // selectively re-exported), so `PoolClient` is unreachable from outside the crate; the real
897        // HTTP/3 dispatch a consumer can actually reach is the already-covered `::send`/`::execute`.
898        // Removed from open.tsv rather than guessed.
899        // SOUNDNESS R337 — THE WRAPPER, not the wrapped. candor already charges
900        // `native_tls_crate::Identity::from_pkcs8`/`from_pkcs12` `Fs` (the clause below), and the reason
901        // it does is concrete rather than defensive: on the security-framework backend those create a
902        // real `tempfile::TempDir` and write a keychain into it —
903        // `native-tls-0.2.18/src/imp/security_framework.rs:93` (`identity.keychain`, `from_pkcs8`) and
904        // `:167` (`tmp.keychain`, `from_pkcs12`). But candor classifies the call a CONSUMER writes, not
905        // the dependency body behind it, so a consumer who reaches that code through its idiomatic
906        // wrapper matched no rule at all — and in a CALIBRATED crate an unmatched path is a claim of
907        // reviewed purity with no disclosure. A call taking only BYTES writes a file on macOS, and it
908        // read pure.
909        //
910        // Swept for the CLASS rather than the two rows the coverage gate handed me: every caller of
911        // `native_tls{,_crate}::Identity::from_pkcs8|from_pkcs12` in the local registry is reqwest
912        // (0.11/0.12/0.13, all three spelling these the same way), lettre, and `tcp_stream` — which is
913        // NOT calibrated, so its calls disclose rather than claim purity and it needs no rule.
914        //
915        // Suffix-matched, not exact-path, because all three reqwest majors carry the same names; and
916        // narrow on purpose. The SIBLINGS are the fabrication control and they must stay pure:
917        // `reqwest::tls::Identity::from_pem` is rustls-only, a `Cursor` over a buffer (tls.rs:373), and
918        // lettre's `Certificate::from_der`/`from_pem` reach `native_tls::Certificate::*`, which imports
919        // items with no keychain and no temp dir (security_framework.rs:206,212).
920        // SOUNDNESS R342 — THE RESPONSE BODY IS READ FROM THE SOCKET, and `resp.text().await` is one of
921        // the most-written lines in Rust networking. `send()` is charged; the methods that DRAIN what it
922        // returned were not, so a helper that takes a `Response` and reads it — an extremely common
923        // shape — was a positive purity claim on a CALIBRATED crate. Executed at reqwest 0.13.4:
924        // `r.text()`, `r.json()` and `r.bytes()` all ABSENT while `c.get(..).send()` was `['Net']`.
925        //
926        // Verified in the source rather than assumed: `text` -> `text_with_charset` -> `bytes` ->
927        // `do_bytes`, which is `BodyExt::collect(self.res.into_body()).await`
928        // (`async_impl/response.rs:433`) — it drains the body off the wire. `chunk` and `bytes_stream`
929        // are the streaming spellings of the same read; charging the stream CONSTRUCTOR follows this
930        // file's own `Command::new` convention, where the effect is attributed at the point the caller
931        // asks for it rather than at the poll nobody can see.
932        //
933        // Scoped by `Response::` and not by the bare verb: `::text` and `::json` are far too common a
934        // leaf to match crate-wide. Found by the coverage gate the moment R185 taught the scanner to
935        // see through `Option`/`Result` unwraps — the same "closing a silence makes consumers' gaps
936        // visible" shape recorded on R302 and R337.
937        if crate_name == "reqwest"
938            && path.contains("Response::")
939            && (path.ends_with("::text")
940                || path.ends_with("::text_with_charset")
941                || path.ends_with("::json")
942                || path.ends_with("::bytes")
943                || path.ends_with("::chunk")
944                || path.ends_with("::bytes_stream"))
945        {
946            return Some("Net");
947        }
948        if crate_name == "reqwest"
949            && (path.ends_with("::Identity::from_pkcs12_der")
950                || path.ends_with("::Identity::from_pkcs8_pem"))
951        {
952            return Some("Fs");
953        }
954        if crate_name == "reqwest" && path.ends_with("::Part::file") {
955            return Some("Fs");
956        }
957        return None;
958    }
959    if crate_name == "ureq" && path.ends_with("::call") {
960        return Some("Net");
961    }
962    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ureq 2.12.1 (the version this ratchet's
963    // rows were generated from — 3.x, also cached locally, rewrote this surface beyond recognition and
964    // is a different question for a future pass). `::call` above is the no-body dispatch terminal;
965    // these are its siblings:
966    //
967    // `Request::send`/`send_bytes`/`send_form`/`send_json`/`send_string` (request.rs:78-296) are the
968    // WITH-BODY dispatch terminals — `do_call(Payload::..)`, the exact same terminal `call` reaches with
969    // an empty payload. Missing them left every `agent.post(url).send_json(&body)` (the dominant idiom
970    // for a JSON API call) silently pure.
971    // `ureq::agent`/`request`/`request_url` (lib.rs:513,539,561) are real gaps in a stranger way: each
972    // one's own body contains a REACHABLE (if `is_test(true)` was ever called — itself a `pub fn` a
973    // consumer or another dependency could invoke) call to `testserver::test_agent()`, which binds a
974    // real listening `TcpStream` (see `TestServer::new` below) — self-scan's whole-body reachability
975    // treats that as real, and per this project's over-approximate-rather-than-miss discipline (a
976    // toggleable global flag routing traffic through an internal listener is exactly the kind of path a
977    // security-conscious caller wants disclosed, not silently pure), so does this rule. `agent()` is also
978    // the crate's OWN base for `request`/`request_url` (`agent().request(..)`), and `request`/
979    // `request_url` additionally carry the URL argument — matching the reqwest/isahc precedent above of
980    // classifying the URL-bearing constructor `Net` too (idempotent with the eventual `.call()`/`.send()`)
981    // so the destination is captured even if a caller never re-touches the returned `Request`.
982    if crate_name == "ureq"
983        && (path.ends_with("::send")
984            || path.ends_with("::send_bytes")
985            || path.ends_with("::send_form")
986            || path.ends_with("::send_json")
987            || path.ends_with("::send_string")
988            || path == "ureq::agent"
989            || path == "ureq::request"
990            || path == "ureq::request_url"
991            || path == "ureq::unversioned::transport::RustlsConnector::connect"
992            || path == "ureq::tls::rustls::RustlsConnector::connect"
993            || path == "ureq::RustlsConnector::connect")
994    {
995        return Some("Net");
996    }
997    // NOT modelled (both removed from open.tsv, not guessed):
998    //   `ureq::Arc::connect` — the ratchet's generator strips generic arguments from an impl header, so
999    //   `impl TlsConnector for Arc<rustls::ClientConfig>` (rtls.rs:88) produced the type name "Arc" — a
1000    //   bogus path (`ureq::Arc` names nothing; `Arc` is std's, not ureq's) that no consumer could ever
1001    //   write. The trait method itself is real (ureq's own internal TLS dispatch, invoked by `.call()`,
1002    //   already covered), but no FQN a real caller would spell resolves through this guess.
1003    //   `ureq::TestServer::new` — `TestServer` (testserver.rs:62) lives in `mod testserver;` (lib.rs:444),
1004    //   PRIVATE with no re-export, so `ureq::TestServer` is unreachable from outside the crate (the
1005    //   listener it binds is real, but only `agent()`'s internal `is_test` branch above can reach it).
1006    // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
1007    // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
1008    // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
1009    // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
1010    // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
1011    // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
1012    if crate_name == "curl"
1013        && (path.ends_with("::perform")
1014            || path.ends_with("::send")
1015            || path.ends_with("::recv")
1016            || path.ends_with("::upkeep")
1017            || path.ends_with("::action"))
1018    {
1019        return Some("Net");
1020    }
1021    // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
1022    // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
1023    // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
1024    // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
1025    // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
1026    // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
1027    match crate_name {
1028        // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
1029        "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
1030        // hyper-util's pooled legacy Client + its TCP connectors.
1031        "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
1032        // hickory (trust-dns) resolver — issues DNS queries over the network.
1033        "hickory_resolver"
1034            if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
1035                || path.ends_with("::resolve") => return Some("Net"),
1036        // HTTP/3 over QUIC.
1037        "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
1038            || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
1039        // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
1040        // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
1041        // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
1042        // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
1043        "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
1044            || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
1045            || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
1046            || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
1047            || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
1048            || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
1049            || path.ends_with("::finish") => return Some("Net"),
1050        // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
1051        "tokio_rustls" | "native_tls"
1052            if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
1053            return Some("Net"),
1054        // AF_VSOCK host<->guest sockets — inter-process / VM comms.
1055        "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
1056            return Some("Ipc"),
1057        // Loads the OS trust store from disk (cert files / keychain).
1058        "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
1059        // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
1060        // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
1061        // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
1062        // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
1063        // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
1064        // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
1065        "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
1066            || path.ends_with("::increase_nofile_limit") => return Some("Env"),
1067        // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
1068        // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
1069        // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
1070        // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
1071        // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
1072        // read_tls), so flagging it would over-report Net on the pure decrypt step.
1073        "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
1074            || path.ends_with("::complete_io") => return Some("Net"),
1075        // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
1076        // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
1077        "native_tls_crate" | "tokio_native_tls"
1078            if path.ends_with("::connect") || path.ends_with("::accept")
1079                || path.ends_with("::handshake") => return Some("Net"),
1080        _ => {}
1081    }
1082    // THE COVERAGE-GATE SWEEP (2026-08-27) — two crate-root CONSTRUCTORS the verb-keyed match above
1083    // never reached because neither ends in connect/accept/handshake, verified against their real
1084    // per-platform impl (native-tls 0.2.18, rustls 0.23.43):
1085    //
1086    // `native_tls_crate::TlsConnector::new` (lib.rs:481, the PUBLIC newtype wrapper around the private
1087    // per-platform `imp::TlsConnector`) reads the system trust store — on the openssl backend it loads
1088    // the probed cert file/dir from disk before any socket exists. `Identity::from_pkcs8` (lib.rs:178,
1089    // wrapping `imp::Identity::from_pkcs8`) on the security-framework (macOS) backend creates a real
1090    // temporary keychain file on disk to import the PKCS8 key into.
1091    // `rustls::KeyLogFile::new` (key_log_file.rs:88) reads `$SSLKEYLOGFILE` and, if set, opens (creating
1092    // if needed) that file in append mode — a real, if opt-in, disk write path independent of the sync
1093    // TLS record-layer I/O the `rustls` arm above already covers.
1094    // SOUNDNESS R331 — …and `TlsConnector::builder`, which is the SAME trust-store read reached by the
1095    // other spelling. `TlsConnector::new` is literally `TlsConnector::builder().build()`
1096    // (`native-tls-0.2.18/src/lib.rs:481`), and `TlsConnectorBuilder::build` (`:451`) is where
1097    // `imp::TlsConnector::new` — the openssl-backend cert-file/dir load — actually runs. The builder
1098    // spelling is the DOMINANT idiom the moment any option is set, and it is what `reqwest` itself
1099    // writes (`reqwest-0.12.28/src/async_impl/client.rs:529`).
1100    //
1101    // **KEYED ON `builder`, NOT ON `build`, AND THAT IS THE WHOLE FIX.** The first attempt added
1102    // `ends_with("TlsConnectorBuilder::build")` and moved nothing, so the row was closed with the
1103    // conclusion that the scanner "never types `TlsConnector::builder()`" and the repair had to be
1104    // receiver typing. **That conclusion was wrong.** `.build()` is a method on a foreign-returned
1105    // receiver and cannot be typed without a return index for a foreign crate — but `TlsConnector::
1106    // builder()` is a PATH call, recorded and classified exactly like `TlsConnector::new` beside it.
1107    // Charging the constructor rather than the terminal is also this file's own convention
1108    // (`Command::new`). Over-charge direction: a `builder()` whose `.build()` never happens, which is
1109    // not a shape anyone writes.
1110    //
1111    // WHY IT LOOKED LIKE NOTHING WAS RECORDED: under the calibration exemption a call that classifies
1112    // `None` is DROPPED from the report, so "no calls recorded" is what a correct scan of an exempt
1113    // crate looks like — not evidence that the receiver went untyped. Measured: under the PLAIN
1114    // `native_tls::` spelling (not calibrated) the same body is PRESENT with `invisible: ["native_tls"]`,
1115    // a disclosure; under `native_tls_crate` (the calibrated rename reqwest and tungstenite use) it is
1116    // ABSENT with nothing hidden. The rename is what turns a disclosure into a purity claim.
1117    if matches!(crate_name, "native_tls" | "native_tls_crate")
1118        && (path.ends_with("TlsConnector::new")
1119            || path.ends_with("TlsConnector::builder")
1120            || path.ends_with("Identity::from_pkcs8")
1121            || path.ends_with("Identity::from_pkcs12"))
1122    {
1123        return Some("Fs");
1124    }
1125    if crate_name == "rustls" && path.ends_with("KeyLogFile::new") {
1126        return Some("Fs");
1127    }
1128    // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
1129    // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
1130    // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
1131    // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
1132    // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
1133    // classified pure — a message-queue client reporting no I/O.)
1134    if crate_name == "async_nats" {
1135        if path.ends_with("::connect")
1136            || path.contains("::publish")
1137            || path.ends_with("::subscribe")
1138            || path.ends_with("::queue_subscribe")
1139            || path.contains("::request")
1140            || path.ends_with("::flush")
1141        {
1142            return Some("Net");
1143        }
1144        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against async-nats 0.35.1 (the version this
1145        // ratchet's rows were generated from — 0.50.0, also cached locally, removed/relocated this
1146        // exact surface, a separate question for a future pass):
1147        //
1148        // `connect_with_options` (lib.rs:891) is the crate's OWN real entry point `connect()` calls one
1149        // hop down — the actual handshake, missing because the verb list above matches the bare
1150        // `::connect` suffix, not the `_with_options` sibling.
1151        // `ConnectOptions::credentials_file`/`with_credentials_file` (options.rs:417,438) load a real
1152        // credentials file off disk via `auth_utils::load_creds` before authenticating — Fs, not Net
1153        // (the network round-trip these feed happens later, at `connect`).
1154        // `ServerAddr::socket_addrs` (lib.rs:1473) does `tokio::net::lookup_host` — a real DNS query.
1155        if path == "async_nats::connect_with_options" {
1156            return Some("Net");
1157        }
1158        if path == "async_nats::ConnectOptions::credentials_file"
1159            || path == "async_nats::ConnectOptions::with_credentials_file"
1160        {
1161            return Some("Fs");
1162        }
1163        if path == "async_nats::ServerAddr::socket_addrs" {
1164            return Some("Net");
1165        }
1166        return None;
1167    }
1168    if crate_name == "lapin" {
1169        if path.ends_with("::connect")
1170            || path.ends_with("::create_channel")
1171            || path.contains("::basic_")
1172            || path.contains("::queue_")
1173            || path.contains("::exchange_")
1174            || path.contains("::tx_")
1175            || path.ends_with("::confirm_select")
1176            || path.ends_with("::close")
1177        {
1178            return Some("Net");
1179        }
1180        return None;
1181    }
1182    // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
1183    // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
1184    //
1185    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against lettre 0.11.23 source — the TLS-setup and
1186    // connection-establishment family this crate's own `send` rule never reached:
1187    //
1188    // `TlsParameters::new`/`new_rustls` (client/tls.rs:598,617) and `TlsParametersBuilder::build`/
1189    // `build_rustls` (tls.rs:325,446) — `build_rustls` calls `rustls_native_certs::load_native_certs()`
1190    // (a real OS-trust-store read) plus `tracing::debug!`; `build()` dispatches to it under the
1191    // (textually-visible, cfg-gated) `rustls` feature. `SmtpTransport`/`AsyncSmtpTransport::{from_url,
1192    // relay,starttls_relay}` (transport.rs:90,114,243; async_transport.rs:135,169,306) each construct a
1193    // `TlsParameters` directly (`relay`/`starttls_relay`) or via the crate's OWN `pub(crate)
1194    // from_connection_url` (`from_url`) — real TLS setup a consumer never spells `TlsParameters` to
1195    // reach. `FileTransport::read` (transport/file/mod.rs:216) calls `std::fs::read` directly.
1196    if crate_name == "lettre" {
1197        if path.ends_with("::send") || path.ends_with("::send_raw") {
1198            return Some("Net");
1199        }
1200        // SOUNDNESS R337 — lettre's half of the same wrapper gap. `Identity::from_pem(pem, key)`
1201        // (transport/smtp/client/tls.rs:749) is `native_tls::Identity::from_pkcs8(pem, key)`, which on
1202        // macOS writes a temporary keychain. The `::Identity::` in the suffix is load-bearing: the
1203        // `Certificate::from_pem` twenty lines above it (`:676`) is in-memory and must stay pure, and a
1204        // bare `::from_pem` would charge both.
1205        if path.ends_with("::Identity::from_pem") {
1206            return Some("Fs");
1207        }
1208        if path == "lettre::TlsParametersBuilder::build"
1209            || path == "lettre::transport::smtp::client::TlsParametersBuilder::build"
1210            || path == "lettre::TlsParametersBuilder::build_rustls"
1211            || path == "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"
1212            || path == "lettre::TlsParameters::new"
1213            || path == "lettre::transport::smtp::client::TlsParameters::new"
1214            || path == "lettre::TlsParameters::new_rustls"
1215            || path == "lettre::transport::smtp::client::TlsParameters::new_rustls"
1216            // SOUNDNESS R331 — the `_native` half of the same family, which this enumeration never had.
1217            // It surfaced the moment `TlsConnector::builder` started charging: the coverage gate's
1218            // oracle saw `Fs` on `TlsParameters::new_native` and `TlsParametersBuilder::build_native`
1219            // and `classify()` had no rule for either. `build_native` is `TlsConnector::builder()`
1220            // (`lettre-0.11.23/src/transport/smtp/client/tls.rs:338`) and `new_native` delegates
1221            // straight to it (`:610`); the already-listed `build`/`new` reach whichever backend is
1222            // enabled and were covered, so the list held every variant EXCEPT the one named after the
1223            // backend that does the disk read.
1224            //
1225            // `build_boring`/`new_boring` are deliberately NOT added. `build_boring` (`:384`) builds a
1226            // `boring::ssl::SslConnector`, and openssl's `SslConnector::builder` calls
1227            // `set_default_verify_paths()` — so it is PROBABLY `Fs` too, but `boring` is not a crate
1228            // this file has read, and the last three defects in this area came from charging a path
1229            // whose body nobody had opened. Left for a pass that reads it.
1230            || path == "lettre::TlsParameters::new_native"
1231            || path == "lettre::transport::smtp::client::TlsParameters::new_native"
1232            || path == "lettre::TlsParametersBuilder::build_native"
1233            || path == "lettre::transport::smtp::client::TlsParametersBuilder::build_native"
1234            || path == "lettre::SmtpTransport::from_url"
1235            || path == "lettre::SmtpTransport::relay"
1236            || path == "lettre::SmtpTransport::starttls_relay"
1237            || path == "lettre::AsyncSmtpTransport::from_url"
1238            || path == "lettre::AsyncSmtpTransport::relay"
1239            || path == "lettre::AsyncSmtpTransport::starttls_relay"
1240            || path == "lettre::FileTransport::read"
1241        {
1242            return Some("Fs");
1243        }
1244        // `AsyncStd1Executor::{connect,fs_read,fs_write}` (executor.rs:223,260,265, the sealed
1245        // `Executor` trait impl — `#[doc(hidden)]` in lettre's own source, so this is a narrower, less-
1246        // visited surface than the others above, but genuinely reachable: both the trait and the type
1247        // are `pub`, re-exported at the crate root) dial a real connection / read+write real files.
1248        // `AsyncSmtpConnection`/`AsyncNetworkStream::connect_asyncstd1` (client/async_connection.rs:121,
1249        // client/async_net.rs:207) dial a real `async-std` TCP connection — reachable only via the
1250        // module-qualified path (`transport::smtp::client::`), since neither type is re-exported at the
1251        // crate root (unlike `TlsParameters`'s sibling forms, which at least reach `client::` — the dual
1252        // spellings above, same shape as rusqlite's `Blob`/`Backup`).
1253        if path == "lettre::AsyncStd1Executor::connect"
1254            || path == "lettre::AsyncSmtpConnection::connect_asyncstd1"
1255            || path == "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
1256            || path == "lettre::AsyncNetworkStream::connect_asyncstd1"
1257            || path == "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
1258        {
1259            return Some("Net");
1260        }
1261        if path == "lettre::AsyncStd1Executor::fs_read" || path == "lettre::AsyncStd1Executor::fs_write" {
1262            return Some("Fs");
1263        }
1264        // NOT included: `NetworkStream::{shutdown,set_read_timeout,set_write_timeout}` (client/net.rs)
1265        // LOOK like three more hits (self-scan flags them too, propagating from the coarse
1266        // `std::net::TcpStream` whole-handle rule) but `mod net;` (client/mod.rs:50) is PRIVATE and
1267        // `NetworkStream` is never re-exported (only imported crate-internally via a bare `use
1268        // self::net::NetworkStream;`, mod.rs:35) — no external consumer can ever name the type, so no
1269        // rule was added despite self-scan flagging it (the same `InnerConnection`/`RawStatement` shape
1270        // as the rusqlite fix elsewhere in this file).
1271        return None;
1272    }
1273    // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
1274    // and the socket read/write/send are network; Message constructors are pure. (Found on a
1275    // tungstenite consumer: connect + send + read classified pure.)
1276    //
1277    // THE FIX: `connect` only names the URL-dialing client entry point. tungstenite ALSO ships the
1278    // stream-first client/server handshake free functions — `client`/`client_with_config` (client.rs:176,
1279    // 152: "use this if you need a nonblocking handshake ... or a custom stream") and
1280    // `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config` (server.rs:23-63) — each of
1281    // which does `{Client,Server}Handshake::start(stream, ..).handshake()`, the REAL WS upgrade
1282    // handshake read/write over an already-open stream. These are the documented way to run tungstenite
1283    // over a caller-managed TCP/TLS/mio stream (exactly the shape most async runtimes use, since
1284    // tungstenite itself is sync/transport-agnostic) — missing from a verb list keyed only on the
1285    // dial-it-yourself `connect` spelling. Same vein as `git2::Submodule::clone`/`ignore::Walk::new`: an
1286    // established effect's SIBLING entry point absent from the allowlist.
1287    if crate_name == "tungstenite" {
1288        if path.ends_with("::connect")
1289            || path.ends_with("::connect_with_config")
1290            || path.ends_with("::client")
1291            || path.ends_with("::client_with_config")
1292            || path.ends_with("::client_tls")
1293            || path.ends_with("::client_tls_with_config")
1294            || path.ends_with("::accept")
1295            || path.ends_with("::accept_with_config")
1296            || path.ends_with("::accept_hdr")
1297            || path.ends_with("::accept_hdr_with_config")
1298            || path.ends_with("::read")
1299            || path.ends_with("::write")
1300            || path.ends_with("::send")
1301            || path.ends_with("::close")
1302            || path.ends_with("::flush")
1303            || path.ends_with("::read_message")
1304            || path.ends_with("::write_message")
1305        {
1306            return Some("Net");
1307        }
1308        return None;
1309    }
1310    // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
1311    // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
1312    // SOUNDNESS R342 — …and the RESPONSE BODY, one wrapper up from reqwest's. `Response::json` is
1313    // `self.response.json::<B>().await` (`http/response.rs:102`) — reqwest's body drain, which this file
1314    // now charges — and `text`/`bytes`/`exception` are the same read. A consumer writing
1315    // `client.search(..).send().await?.json().await?` had the `send` charged and the read silent.
1316    //
1317    // This is EXACTLY the distinction this file's own exclusion list already draws for this type:
1318    // `Response::content_type` is documented there as a pure accessor reading a header off an
1319    // ALREADY-received response. `status_code`/`headers` are the same shape and stay pure; the body
1320    // methods are not accessors, they pull the remaining bytes off the wire. Both halves are asserted
1321    // in the test so the line between them cannot move by accident.
1322    if crate_name == "elasticsearch"
1323        && path.contains("Response::")
1324        && (path.ends_with("::json")
1325            || path.ends_with("::text")
1326            || path.ends_with("::bytes")
1327            || path.ends_with("::exception"))
1328    {
1329        return Some("Net");
1330    }
1331    if crate_name == "elasticsearch" && path.ends_with("::send") {
1332        return Some("Net");
1333    }
1334    // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
1335    // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
1336    // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
1337    //
1338    // THE COVERAGE-GATE FIX: the rule above only ever covered the CLIENT half. `Router::serve`/
1339    // `::serve_with_shutdown` (transport/server/mod.rs:783,811) are the SERVER's own most common entry
1340    // points — a tonic quickstart's last line — and both bind a real listening socket via
1341    // `TcpIncoming::new` (incoming.rs:197: `StdTcpListener::bind(addr)`), the crate's other bare-`pub`
1342    // constructor (`Server<L>::serve_with_shutdown` at mod.rs:530 is a DIFFERENT, `pub(crate)` method on
1343    // a different receiver — not reachable, and not this one). A `deny Net` on a gRPC SERVER app missed
1344    // its own listen call entirely.
1345    if crate_name == "tonic" {
1346        if path.ends_with("::connect")
1347            || path.ends_with("::unary")
1348            || path.ends_with("::server_streaming")
1349            || path.ends_with("::client_streaming")
1350            || path.ends_with("::streaming")
1351            || path.ends_with("::Router::serve")
1352            || path.ends_with("::Router::serve_with_shutdown")
1353            || path == "tonic::TcpIncoming::new"
1354            || path == "tonic::transport::server::TcpIncoming::new"
1355        {
1356            return Some("Net");
1357        }
1358        return None;
1359    }
1360    // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
1361    // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
1362    // repro needs librdkafka/cmake, deferred.)
1363    if crate_name == "rdkafka" {
1364        if path.ends_with("::send")
1365            || path.ends_with("::send_result")
1366            || path.ends_with("::recv")
1367            || path.ends_with("::poll")
1368            || path.ends_with("::subscribe")
1369            || path.ends_with("::commit")
1370            || path.ends_with("::commit_message")
1371            || path.ends_with("::commit_consumer_state")
1372            || path.ends_with("::store_offset")
1373            || path.ends_with("::seek")
1374            || path.ends_with("::fetch_metadata")
1375            || path.ends_with("::fetch_watermarks")
1376            || path.ends_with("::flush")
1377        {
1378            return Some("Net");
1379        }
1380        return None;
1381    }
1382    // cap-std: capability-oriented std. I/O goes *through* a held capability handle
1383    // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
1384    // cap-std project's real I/O is detected and matches the capability it declared
1385    // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
1386    if crate_name.starts_with("cap_") {
1387        if path.contains("::net::Unix") || path.contains("::os::") {
1388            return Some("Ipc");
1389        }
1390        if path.contains("::net") {
1391            return Some("Net");
1392        }
1393        if path.contains("::time") {
1394            return Some("Clock");
1395        }
1396        if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
1397            return Some("Fs");
1398        }
1399        return None;
1400    }
1401    // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
1402    // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
1403    // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
1404    // `unix`.
1405    if path.starts_with("tokio::net::Unix")
1406        || path.starts_with("std::os::unix::net")
1407        || path.starts_with("async_std::os::unix::net")
1408        || path.starts_with("async_net::unix")
1409    {
1410        return Some("Ipc");
1411    }
1412    // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
1413    // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
1414    // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
1415    // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
1416    // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
1417    // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
1418    // scanning bandwhich — a packet sniffer — which reported Net 0.)
1419    if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
1420        if path.ends_with("::channel") || path.ends_with("::transport_channel") {
1421            return Some("Net");
1422        }
1423        return None;
1424    }
1425    // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
1426    // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
1427    // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
1428    // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
1429    // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
1430    // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
1431    // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
1432    //
1433    // `Walk::new`/`Walk::from_iter` are the crate's own documented convenience constructors — literally
1434    // `WalkBuilder::new(path).build()` / `WalkBuilder::from_iter(paths).build()` in `ignore`'s own source
1435    // (walk.rs:1128-1146) — but a verb list keyed only on `WalkBuilder::build`/`build_parallel`/
1436    // `WalkParallel::run`/`add_ignore` never sees `Walk::new(root)` (the crate's own top-level doc example
1437    // and the SAME shape as the already-fixed `walkdir::WalkDir::new`/`git2::Repository::clone`: a
1438    // documented public entry point that reaches the modelled effect through a wrapper the allowlist
1439    // didn't name). `deny Fs` over `for e in Walk::new(root) { .. }` exited 0. Charged at the SAME
1440    // construction site as `WalkBuilder::build` — no receiver typing needed, it's a plain `Expr::Call` on
1441    // `ignore::Walk::new`/`ignore::Walk::from_iter`, crate-gated so an unrelated `Walk::new` (a different
1442    // crate, or a local type) cannot match this arm.
1443    if crate_name == "ignore" {
1444        if path == "ignore::WalkBuilder::build"
1445            || path == "ignore::WalkBuilder::build_parallel"
1446            || path.ends_with("::WalkParallel::run")
1447            // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
1448            // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
1449            // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
1450            // surface, so it was silently pure under the covered-crate floor.
1451            || path == "ignore::WalkBuilder::add_ignore"
1452            || path == "ignore::Walk::new"
1453            || path == "ignore::Walk::from_iter"
1454        {
1455            return Some("Fs");
1456        }
1457        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ignore 0.4.33 — the gitignore-matcher
1458        // construction family, a DIFFERENT public surface from the walk-builder family above:
1459        //
1460        // `Gitignore::new` (gitignore.rs:103) opens and reads the given file directly via
1461        // `GitignoreBuilder::add`. `Gitignore::global` (gitignore.rs:141) reads `$PWD` then delegates to
1462        // `GitignoreBuilder::new(cwd).build_global()`. `GitignoreBuilder::add` (gitignore.rs:405) is the
1463        // crate's own per-file loader — `File::open` + line-by-line read — the same shape `add_ignore`
1464        // above was fixed for, on the SIBLING type. `GitignoreBuilder::build_global`/
1465        // `gitconfig_excludes_path` (gitignore.rs:377,583) resolve git's global excludesfile: read
1466        // `GIT_CONFIG_GLOBAL`/`XDG_CONFIG_HOME`/`HOME` env vars, then open and parse whichever gitconfig
1467        // file they point at. `WalkBuilder::build_matchers` (walk.rs:675) is the incremental-walk
1468        // sibling of the already-covered `build_parallel` — same `self.build_ignore()` call, different
1469        // caller-facing verb. `IncrementalIgnore::matched`/`matched_with_errors` (incremental.rs:194,213)
1470        // are the newer per-directory incremental-walk API: each lazily loads that directory's ignore
1471        // files (and, when `max_filesize` is set, `stat`s the candidate file) as the walk descends.
1472        //
1473        // `Gitignore`/`GitignoreBuilder`/`gitconfig_excludes_path` live in `pub mod gitignore;`
1474        // (lib.rs:59) but — unlike `Walk`/`WalkBuilder` two lines up (`pub use crate::walk::{..}`,
1475        // lib.rs:52) — are NOT re-exported at the crate root, so the only real, compilable spelling is
1476        // the module-qualified one (proven by a consumer fixture: `ignore::Gitignore::new` does not
1477        // exist, `ignore::gitignore::Gitignore::new` does). `IncrementalIgnore` IS re-exported at the
1478        // root (`pub use crate::incremental::{..}`, lib.rs:51), so the bare form is correct there.
1479        if path == "ignore::gitignore::Gitignore::new"
1480            || path == "ignore::gitignore::Gitignore::global"
1481            || path == "ignore::gitignore::GitignoreBuilder::add"
1482            || path == "ignore::gitignore::GitignoreBuilder::build_global"
1483            || path == "ignore::gitignore::gitconfig_excludes_path"
1484            || path == "ignore::WalkBuilder::build_matchers"
1485            || path == "ignore::IncrementalIgnore::matched"
1486            || path == "ignore::IncrementalIgnore::matched_with_errors"
1487            || path == "ignore::DirEntry::metadata"
1488            || path == "ignore::walk::DirEntry::metadata"
1489            || path == "ignore::WalkParallel::visit"
1490            || path == "ignore::walk::WalkParallel::visit"
1491        {
1492            return Some("Fs");
1493        }
1494        return None;
1495    }
1496    // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
1497    // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
1498    // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
1499    // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
1500    // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
1501    // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
1502    if crate_name == "notify" {
1503        if path.ends_with("Watcher::new")
1504            || path.ends_with("::recommended_watcher")
1505            || path.ends_with("::watch")
1506            || path.ends_with("::unwatch")
1507            // `ReadDirectoryChangesWatcher::create` (windows.rs:475, `pub use windows::
1508            // ReadDirectoryChangesWatcher` at the crate root) is a SECOND, directly-callable
1509            // constructor distinct from the `Watcher::new` trait method above — it takes a raw
1510            // meta-event channel `Watcher::new`'s impl builds internally by calling THIS same
1511            // `create`, then spawns the real ReadDirectoryChangesW watch server thread.
1512            || path.ends_with("ReadDirectoryChangesWatcher::create")
1513        {
1514            return Some("Fs");
1515        }
1516        return None;
1517    }
1518    // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
1519    // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
1520    // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
1521    if path.ends_with("::to_socket_addrs")
1522        || path == "std::net::lookup_host"
1523        || path.ends_with("ToSocketAddrs::to_socket_addrs")
1524    {
1525        return Some("Net");
1526    }
1527    // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
1528    // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
1529    if path.starts_with("std::net::TcpStream")
1530        || path.starts_with("std::net::TcpListener")
1531        || path.starts_with("std::net::UdpSocket")
1532        || path.starts_with("tokio::net::")
1533    {
1534        // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
1535        // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
1536        // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
1537        // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
1538        if path.ends_with("::local_addr")
1539            || path.ends_with("::peer_addr")
1540            || path.ends_with("::nodelay")
1541            || path.ends_with("::ttl")
1542            || path.ends_with("::take_error")
1543        {
1544            return None;
1545        }
1546        return Some("Net");
1547    }
1548    // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
1549    // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
1550    // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
1551    // network-free — a network tool confidently reporting 0 Net.)
1552    if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
1553        return Some("Net");
1554    }
1555    // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
1556    // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
1557    // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
1558    // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
1559    // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
1560    // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
1561    if path.starts_with("async_std::net::")
1562        || path.starts_with("mio::net::")
1563        || crate_name == "async_net"
1564    {
1565        return Some("Net");
1566    }
1567    // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
1568    // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
1569    // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
1570    // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
1571    if DB_CRATES.contains(&crate_name) {
1572        // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
1573        // execution (round-trips to the server). sqlx is the outlier where bare `query()`
1574        // only BUILDS — it keeps the narrow set below. (Found by running on a real
1575        // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
1576        if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
1577            const PG: [&str; 21] = [
1578                "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
1579                "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
1580                "::copy_in", "::copy_out", "::transaction", "::connect",
1581                // `Config::connect_raw` (tokio-postgres config.rs:739) does the SAME protocol handshake
1582                // as `Config::connect` over a caller-supplied stream (a Unix socket, a TLS-terminated
1583                // proxy) instead of dialing one itself — real Db/Net I/O, missing because the verb
1584                // doesn't end in the plain `connect` spelling.
1585                "::connect_raw",
1586                // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
1587                // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
1588                // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
1589                // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
1590                // tokio_postgres 0.7.10+.
1591                "::query_row", "::query_map", "::query_and_then", "::execute_batch",
1592                "::prepare_cached", "::query_typed",
1593                "::prepare_typed_cached",
1594            ];
1595            if PG.iter().any(|v| path.ends_with(v)) {
1596                return Some("Db");
1597            }
1598            // THE COVERAGE-GATE SWEEP (2026-08-27): `CancelToken::cancel_query` (tokio-postgres
1599            // cancel_token.rs:34) opens a BRAND NEW connection to the server to send a raw CancelRequest
1600            // packet — real socket I/O, but not a query round-trip on an existing connection like the PG
1601            // verb list above, so bucketed `Net` rather than `Db`.
1602            if matches!(crate_name, "tokio_postgres" | "postgres")
1603                && path.ends_with("CancelToken::cancel_query")
1604            {
1605                return Some("Net");
1606            }
1607            // rusqlite only: opening the database IS the connection establishment (`Connection::
1608            // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
1609            //
1610            // THE FIX: rusqlite 0.32's `Connection` type has SIX `open*` constructors, not three —
1611            // `open_with_flags_and_vfs` and `open_in_memory_with_flags_and_vfs` call the real
1612            // `InnerConnection::open_with_flags` (the sqlite3_open_v2 FFI) directly (lib.rs:492,530), and
1613            // `open_in_memory_with_flags` (lib.rs:515) calls `Connection::open_with_flags(":memory:", ..)`
1614            // — but an exact-suffix allowlist keyed on the THREE simplest names never matches any of
1615            // them (`"open_in_memory_with_flags".ends_with("::open_with_flags")` is false: the suffix
1616            // check needs the string to END in that literal tail, and `_and_vfs`/`_with_flags` add
1617            // characters AFTER it). The same shape as `ignore::Walk::new`: a sibling spelling of an
1618            // already-modelled constructor missing from the allowlist, silently pure because rusqlite is
1619            // calibrated. Match on the `Connection::open` PREFIX rather than enumerating every suffix —
1620            // every current and future `Connection::open*` constructor opens a real handle. Scoped to the
1621            // `Connection::` segment specifically (not a bare leaf prefix) so an unrelated same-crate
1622            // `open`-prefixed method on a DIFFERENT type (e.g. the private `pragma::Sql::open_brace`,
1623            // which pushes one char to a string buffer — no I/O) cannot be swept in by accident.
1624            //
1625            // Same sighting, different constructor: `Connection::blob_open` and `Blob::reopen` call
1626            // `ffi::sqlite3_blob_open`/`sqlite3_blob_reopen` directly (blob/mod.rs:218,251) — genuinely
1627            // Db, and `sqlite3_blob_open`/`sqlite3_blob_reopen` are already in this file's own FFI-leaf DB
1628            // table above (matched only when a caller names the raw `ffi::` leaf directly) — but rusqlite's
1629            // own documented safe wrapper, the incremental-BLOB-I/O API every rusqlite blob consumer
1630            // actually calls, carried no rule of its own.
1631            if crate_name == "rusqlite"
1632                && (path.contains("::Connection::open")
1633                    || path.ends_with("::Connection::blob_open")
1634                    || path.ends_with("::Blob::reopen"))
1635            {
1636                return Some("Db");
1637            }
1638            // THE COVERAGE-GATE SWEEP (2026-08-27): same shape again, and the SAME root cause as
1639            // `git2`'s — this `if crate_name == "rusqlite"` block returns unconditionally, so it never
1640            // reaches this file's own `sqlite3_*` FFI-leaf table below even though every one of these
1641            // calls a leaf ALREADY listed there (`sqlite3_backup_init`/`_step`, `sqlite3_blob_read`/
1642            // `_write`). Verified against rusqlite 0.32.1 source. `Backup::new`/`new_with_names` are the
1643            // crate's own online-backup constructors (backup.rs:187,200); `step`/`run_to_completion`
1644            // drive it; `Connection::backup`/`restore` (backup.rs:61,99) are the one-call convenience
1645            // wrappers around the same API and need their OWN rule since a consumer calling THEM never
1646            // names `Backup` at all. `Blob::{read,write}_at[_exact]`/`raw_read_at[_exact]`/
1647            // `write_all_at` (blob/pos_io.rs) are the incremental-BLOB positional-I/O methods, each
1648            // calling `ffi::sqlite3_blob_read`/`_write` directly. `Connection::from_handle`/
1649            // `_owned`/`extension_init2` and the free fn `init_auto_extension` (lib.rs:950,999,966;
1650            // auto_extension.rs:26) wrap a caller-supplied raw `*mut ffi::sqlite3` into a live
1651            // `Connection` — the loadable-extension entry points, same "produces a live connection"
1652            // effect as `Connection::open`, just over a handle the caller already has instead of one
1653            // this call opens itself (the `tokio_postgres::connect_raw`/`sea_orm::connect_proxy` shape).
1654            //
1655            // NOT included: `InnerConnection::*` and `RawStatement::step` LOOK like six more hits (self-
1656            // scan flags them too) but `mod inner_connection;`/`mod raw_statement;` (lib.rs:119,127) are
1657            // PRIVATE — despite `pub struct InnerConnection`/`RawStatement`, neither type has any public
1658            // path a real consumer could ever name or obtain a value of, so no rule was added for them
1659            // (a rule would be dead weight, never reachable). `Context::get_connection` (functions.rs)
1660            // is ALSO left out, deliberately: it returns a `ConnectionRef` accessor to an
1661            // ALREADY-established connection (`ffi::sqlite3_context_db_handle`, not an I/O verb) — the
1662            // same "handle accessor, not a syscall" shape as `TcpStream::local_addr`, not confirmed
1663            // enough to charge without risking the over-charge this file's history has repeatedly warned
1664            // against. THE COVERAGE-GATE SWEEP (2026-08-27) resolved this from "left in the ratchet"
1665            // to a formal call: `REVIEWED_PURE_ENTRIES` now carries `("rusqlite",
1666            // "rusqlite::Context::get_connection")` so the gate stops re-flagging it every refresh.
1667            //
1668            // TWO SPELLINGS EACH for `Backup`/`Blob`/`init_auto_extension`: none of the three is
1669            // re-exported at rusqlite's crate root (unlike `Connection`, declared directly in lib.rs), so
1670            // the ONLY real spelling a consumer can write is the module-qualified one
1671            // (`rusqlite::backup::Backup::new`, `rusqlite::blob::Blob::read_at`,
1672            // `rusqlite::auto_extension::init_auto_extension`) — but the coverage-gate ratchet recorded
1673            // the SHORT, technically-unreachable guess (`generate.py` always adds a bare
1674            // `{crate}::{Type}::{fn}` guess as a possible root-alias, without confirming a `pub use`
1675            // actually exists for it). Both are listed: the short form so this fix closes the exact row
1676            // the ratchet carries, the long form so it fires for what a real consumer's source (proven
1677            // against a compiling fixture) actually contains — dropping the long form would leave the
1678            // real gap open while the bookkeeping said closed.
1679            if crate_name == "rusqlite"
1680                && (path == "rusqlite::Backup::new"
1681                    || path == "rusqlite::backup::Backup::new"
1682                    || path == "rusqlite::Backup::new_with_names"
1683                    || path == "rusqlite::backup::Backup::new_with_names"
1684                    || path == "rusqlite::Backup::step"
1685                    || path == "rusqlite::backup::Backup::step"
1686                    || path == "rusqlite::Backup::run_to_completion"
1687                    || path == "rusqlite::backup::Backup::run_to_completion"
1688                    || path == "rusqlite::Connection::backup"
1689                    || path == "rusqlite::Connection::restore"
1690                    || path == "rusqlite::Blob::read_at"
1691                    || path == "rusqlite::blob::Blob::read_at"
1692                    || path == "rusqlite::Blob::read_at_exact"
1693                    || path == "rusqlite::blob::Blob::read_at_exact"
1694                    || path == "rusqlite::Blob::raw_read_at"
1695                    || path == "rusqlite::blob::Blob::raw_read_at"
1696                    || path == "rusqlite::Blob::raw_read_at_exact"
1697                    || path == "rusqlite::blob::Blob::raw_read_at_exact"
1698                    || path == "rusqlite::Blob::write_at"
1699                    || path == "rusqlite::blob::Blob::write_at"
1700                    || path == "rusqlite::Blob::write_all_at"
1701                    || path == "rusqlite::blob::Blob::write_all_at"
1702                    || path == "rusqlite::Connection::from_handle"
1703                    || path == "rusqlite::Connection::from_handle_owned"
1704                    || path == "rusqlite::Connection::extension_init2"
1705                    || path == "rusqlite::init_auto_extension"
1706                    || path == "rusqlite::auto_extension::init_auto_extension"
1707                    || path == "rusqlite::Statement::insert"
1708                    || path == "rusqlite::statement::Statement::insert"
1709                    || path == "rusqlite::Statement::raw_execute"
1710                    || path == "rusqlite::statement::Statement::raw_execute"
1711                    // SOUNDNESS R342 — `Rows::next` STEPS the statement: it is `self.advance()?`
1712                    // (`rusqlite-0.39.0/src/row.rs:40`), i.e. `sqlite3_step`, which is where a query's
1713                    // rows actually come from. Iterating a result set is the ordinary way to read from
1714                    // sqlite and it carried no `Db`. `advance` is listed beside it because it is `pub`
1715                    // and is the same call one level down.
1716                    || path == "rusqlite::Rows::next"
1717                    || path == "rusqlite::row::Rows::next"
1718                    || path == "rusqlite::Rows::advance"
1719                    || path == "rusqlite::row::Rows::advance")
1720            {
1721                return Some("Db");
1722            }
1723            return None;
1724        }
1725        // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
1726        // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
1727        // connection establishment. The shared VERBS below only catch the low-level
1728        // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
1729        // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
1730        // SOUNDNESS R335 — the SENTINEL module, which the rules above reach only by accident. redis's
1731        // `sentinel` surface is how a client finds the current master in a failover deployment, and
1732        // every lookup TALKS TO THE SENTINEL SERVERS: `Sentinel::master_for` runs
1733        // `find_master_address` (a `SENTINEL masters` round-trip) and its own doc says "this will
1734        // connect to the master node to verify that it considers itself master"; `get_sentinel_client`
1735        // opens a client per address and issues `ROLE` until one answers. Measured on redis 1.6.0 —
1736        // which is this crate's REVIEWED CEILING, so the calibration exemption applied and every one of
1737        // them read as a positive purity claim, in both the receiver-typed and UFCS spellings.
1738        //
1739        // A DENYLIST, not the enumeration the surrounding rules use, and that is the point. The
1740        // enumerate-the-connecting-verbs shape is what goes stale: a release that ADDS a lookup verb
1741        // adds a SILENCE, the cardinal direction. Inverted, the whole module is `Db` except its
1742        // config surface, so a new verb is charged by default and only a new PURE verb costs a
1743        // fabrication. The exempt set is not guessed: every `pub fn` leaf in redis-1.6.0/src/sentinel.rs
1744        // is one of 33 names, and `build` / `new` / `set_*` are exactly the ones that touch no socket
1745        // (`Sentinel::build` parses URLs via `into_connection_info`; `SentinelClientBuilder::build`
1746        // assembles a config struct). The `contains("::Sentinel")` arm is not the loose match R321
1747        // punished: the module's ENTIRE public surface was enumerated before writing it, and no item
1748        // named `Sentinel*` exists anywhere else in the crate, nor any of these verb names.
1749        if crate_name == "redis"
1750            && (path.starts_with("redis::sentinel::") || path.contains("::Sentinel"))
1751        {
1752            let m = path.rsplit("::").next().unwrap_or(path);
1753            if m == "build" || m == "new" || m.starts_with("set_") {
1754                return None;
1755            }
1756            // §E1 HIT COUNTER (`CANDOR_ALIAS_DEBUG`) for `bin/corpus-ab.py --mark`.
1757            if std::env::var("CANDOR_ALIAS_DEBUG").is_ok() {
1758                eprintln!("R335SENTINEL {path}");
1759            }
1760            return Some("Db");
1761        }
1762        if crate_name == "redis"
1763            && (path.contains("Commands::")
1764                // THE COVERAGE-GATE SWEEP (2026-08-27), a pre-existing over-report found in passing:
1765                // `path.contains("::get_connection")` is a SUBSTRING match, so it also matched
1766                // `Client::get_connection_info` (client.rs:78) — a pure accessor returning an
1767                // already-stored `&ConnectionInfo`, no round-trip at all. Verified against redis 1.6.0.
1768                || (path.contains("::get_connection") && !path.ends_with("::get_connection_info"))
1769                || path.contains("::get_async_connection")
1770                || path.contains("::get_multiplexed_async_connection")
1771                // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
1772                // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
1773                // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
1774                || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
1775                    && !path.ends_with("::clone"))
1776                || path.ends_with("::query")
1777                || path.ends_with("::query_async")
1778                || path.ends_with("::req_command")
1779                || path.ends_with("::req_packed_command")
1780                || path.ends_with("::req_packed_commands"))
1781        {
1782            return Some("Db");
1783        }
1784        // mongodb: a document-store API with none of the SQL verbs — the user calls
1785        // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
1786        // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
1787        // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
1788        // and option/doc builders don't match these verbs, so they stay pure.
1789        //
1790        // THE FIX: `Client::with_options(options)` IS `with_uri_str`'s own body one call down —
1791        // `with_uri_str` (client.rs:179) is literally `ClientOptions::parse(uri).await?;
1792        // Client::with_options(options)`, and `with_options` (client.rs:188) is where the topology/
1793        // monitoring actually spins up. A caller who already holds a `ClientOptions` (built
1794        // programmatically, or via `ClientOptions::parse` called separately) uses `with_options`
1795        // directly — mongodb's OWN alternate entry point for the identical effect, missing from an
1796        // allowlist keyed only on the URI-string spelling. Same shape as `ignore::Walk::new` and
1797        // diesel's `establish`: a sibling constructor of an already-modelled effect. Verified against
1798        // mongodb 3.8.1 source for both the async (`client.rs`) and sync (`sync/client.rs`) `Client`.
1799        if crate_name == "mongodb" {
1800            const MONGO: [&str; 28] = [
1801                "::with_uri_str", "::with_options", "::connect", "::find", "::find_one", "::insert_one",
1802                "::insert_many", "::update_one", "::update_many", "::delete_one",
1803                "::delete_many", "::replace_one", "::aggregate", "::count_documents",
1804                "::estimated_document_count", "::count", "::distinct", "::run_command",
1805                "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
1806                "::list_collections", "::list_collection_names", "::list_databases",
1807                "::list_database_names", "::create_collection", "::create_index", "::watch",
1808            ];
1809            if MONGO.iter().any(|v| path.ends_with(v)) {
1810                return Some("Db");
1811            }
1812            // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mongodb 3.8.1 — the client-side
1813            // field-level encryption (CSFLE) surface, entirely unrelated to the CRUD verb list above.
1814            //
1815            // `ClientEncryption::decrypt` (client/csfle/client_encryption.rs:195) is an ordinary `pub
1816            // async fn` that bottoms out in `CryptExecutor::run_ctx` (client/csfle/state_machine.rs:106),
1817            // a state-machine loop that — depending on state — runs a real `list_collections` query
1818            // against the key-vault database, executes a real command against `mongocryptd` (respawning
1819            // the child process if the connection drops), and fetches data keys from the key vault:
1820            // genuinely Net (and, via mongocryptd respawn, Exec), reached from a manual decrypt call, not
1821            // a guess. Suffix-matched because the real path runs through a re-export hop the ratchet's
1822            // crate-root-alias guess skips: `ClientEncryption` is defined inside `pub(crate) mod csfle;`
1823            // (client.rs:4) and reachable only via `pub use crate::client::csfle::client_encryption;` at
1824            // the crate root (lib.rs:74) — `mongodb::client_encryption::ClientEncryption`, not bare
1825            // `mongodb::ClientEncryption`. The name is specific enough within this crate that a suffix
1826            // match carries no fabrication risk (crate-gated, and `ClientEncryption` exists nowhere else
1827            // in mongodb's own source).
1828            if path.ends_with("ClientEncryption::decrypt") {
1829                return Some("Net");
1830            }
1831            // NOT modelled (removed from open.tsv, not guessed): `CreateDataKey::execute` /
1832            // `Encrypt::execute` — a DIFFERENT false-guess shape from every other row in this pass.
1833            // `CreateDataKey`/`Encrypt` (action/csfle/create_data_key.rs, action/csfle/encrypt.rs) are
1834            // the crate's real, publicly re-exported Action-builder types (`mongodb::action::csfle::
1835            // {CreateDataKey,Encrypt}`) — but the SAME effect self-scan found (the real `run_ctx` call)
1836            // lives in `impl Action for CreateDataKey<'a> { async fn execute(self) -> .. { .. } }`
1837            // (client/csfle/client_encryption/create_data_key.rs:14), which is the INPUT to the
1838            // `#[action_impl]` proc macro, not a real method: the `Action` trait (action.rs:104) declares
1839            // only `optional`, and its own doc comment says the crate's action types are "executed via
1840            // `await` (or `run` if using the sync client)" — the macro consumes this `execute` fn body
1841            // and re-emits it as `IntoFuture::into_future`, so no compiled `CreateDataKey`/`Encrypt`
1842            // value ever has a method literally named `execute` a consumer could call. Self-scan found a
1843            // real function performing a real effect; it is just never reachable under this spelling
1844            // (nor, by construction, under ANY spelling — a consumer's call site is `.await`, not a
1845            // `.execute()` method call, so candor-scan would need to resolve the `IntoFuture` desugaring
1846            // itself to see this at all, a different, deeper question than a classify() rule can answer).
1847            return None;
1848        }
1849        // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
1850        // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
1851        // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
1852        // API (unit-tested; a real-app repro is the remaining confirmation).
1853        //
1854        // THE FIX: `Conn::new(opts)` is each crate's OWN primary connection constructor — not a
1855        // pool helper like `get_conn`, but the raw handle. `mysql::Conn::new` (conn/mod.rs:342) calls
1856        // `conn.connect_stream()?; conn.connect()?` directly; `mysql_async::Conn::new` (conn/mod.rs:921)
1857        // returns a future that does the same handshake. Real connection establishment, same shape as
1858        // diesel's `establish`/rusqlite's `open*` — but `::new` never appeared in this verb list, so a
1859        // bare `Conn::new(opts)?` (the crate's OWN first doctest example) read pure. Scoped to the
1860        // `Conn::` segment specifically (`::new` alone is far too generic a suffix to key on blindly —
1861        // `Opts`/`Pool`/query-result types in the same two crates have their own pure `new`s).
1862        if matches!(crate_name, "mysql" | "mysql_async") {
1863            if path.ends_with("::Conn::new") {
1864                return Some("Db");
1865            }
1866            const MY: [&str; 16] = [
1867                "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
1868                "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
1869                "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
1870            ];
1871            if MY.iter().any(|v| path.ends_with(v)) {
1872                return Some("Db");
1873            }
1874            // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mysql_async 0.37.0:
1875            // `Conn::from_url` (conn/mod.rs:1059) is `Conn::new(Opts::from_str(url)?)` one hop down —
1876            // the crate's OWN alternate entry point for the identical connect effect `Conn::new` above
1877            // already carries, same shape as `ignore::Walk::new`/diesel's `establish`.
1878            // `WhiteListFsHandler::handle` (local_infile_handler/builtin.rs:58, the `GlobalHandler` impl
1879            // for `LOAD DATA LOCAL INFILE`) opens a real file off the caller-supplied whitelist —
1880            // reachable at the crate root (`pub use self::local_infile_handler::{builtin::
1881            // WhiteListFsHandler, ..}`, lib.rs:500).
1882            if path == "mysql_async::Conn::from_url" {
1883                return Some("Db");
1884            }
1885            if path.ends_with("WhiteListFsHandler::handle") {
1886                return Some("Fs");
1887            }
1888            // NOT modelled (all three removed from open.tsv, not guessed): `mysql::MyTcpBuilder::connect`/
1889            // `Stream::connect_tcp`/`Stream::make_secure` (mysql, not mysql_async — io/tcp.rs, io/mod.rs,
1890            // io/tls/native_tls_io.rs). `mod io;` is PRIVATE in mysql's lib.rs with no re-export of
1891            // `MyTcpBuilder`/`Stream`, so none of the three is a path any external consumer can name —
1892            // the crate's real, externally-reachable connect effect is `Conn::new` above (already
1893            // covered), which calls into this private `io` module internally. Also NOT modelled:
1894            // `mysql_async::PathOrBuf::read` (opts/mod.rs) — `PathOrBuf` is declared in the same private
1895            // `mod opts;` whose OTHER types (`Opts`, `OptsBuilder`, …) ARE individually `pub use`-
1896            // re-exported at the crate root (lib.rs:493) but `PathOrBuf` specifically is not, so it too
1897            // is unreachable — the same private-module shape as tempfile's `imp::` functions above.
1898            return None;
1899        }
1900        // sea_orm: an ORM whose execution is split from building (like sqlx). The query
1901        // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
1902        // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
1903        // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
1904        // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
1905        // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
1906        if crate_name == "sea_orm" {
1907            // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
1908            // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
1909            // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
1910            // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
1911            // exclude the whole re-exported namespace first.
1912            if path.contains("sea_query") {
1913                return None;
1914            }
1915            if path.ends_with("::all")
1916                || path.ends_with("::one")
1917                || path.ends_with("::count")
1918                || path.ends_with("::stream")
1919                || path.ends_with("::exec")
1920                || path.ends_with("::exec_with_returning")
1921                || path.ends_with("::exec_without_returning")
1922                || path.ends_with("::connect")
1923                // `Database::connect_proxy` (database/mod.rs:139, behind the `proxy` feature) is
1924                // `Database::connect`'s sibling for a caller-supplied `ProxyDatabaseTrait` backend —
1925                // same effect (produces a live `DatabaseConnection`), different verb, missing from an
1926                // allowlist keyed on the plain `connect` spelling.
1927                || path.ends_with("::connect_proxy")
1928                || path.ends_with("::execute")
1929                || path.ends_with("::execute_unprepared")
1930                || path.ends_with("::query_one")
1931                || path.ends_with("::query_all")
1932                || path.ends_with("::fetch_page")
1933                || path.ends_with("::num_items")
1934                || path.contains("ActiveModelTrait::")
1935            {
1936                return Some("Db");
1937            }
1938            // THE COVERAGE-GATE SWEEP (2026-08-27): sea_orm's TRANSACTION and PAGINATION families,
1939            // verified against sea-orm 1.1.20 source — each of these was found effectful by self-scan
1940            // under no guessed spelling this allowlist already covered.
1941            //
1942            // `DatabaseConnection::transaction`/`transaction_with_config` (db_connection.rs:302,345, the
1943            // `TransactionTrait` impl) is the crate's documented callback-transaction API
1944            // (`db.transaction(|txn| ...)`) — every sqlx-backed match arm dispatches to the
1945            // `SqlxXxxPoolConnection::transaction` methods FQN-listed below, a real BEGIN. FQN-exact
1946            // (not a bare `::transaction` suffix) because `MockDatabaseConnection`/`ProxyDatabaseConnection`
1947            // (driver/mock.rs, driver/proxy.rs, both feature-gated) have their OWN `begin`/`commit`/
1948            // `rollback`/`ping`/`transaction` methods sharing every one of these verb names — a mock
1949            // backend performs no real I/O by construction, and a caller-supplied proxy is a callback
1950            // boundary, not a provable effect; a bare suffix would fabricate Db on both.
1951            if path == "sea_orm::DatabaseConnection::transaction"
1952                || path == "sea_orm::DatabaseConnection::transaction_with_config"
1953                // BONUS, found while proving the above reachable with a real consumer fixture (not in
1954                // the original coverage-gate ratchet — self-scan's own reachability pass didn't flag
1955                // these, apparently because it doesn't track an enum match arm's payload binding as a
1956                // typed receiver, but they dispatch through the identical match-on-`self` shape as
1957                // `transaction` two lines up): `DatabaseConnection::ping` (db_connection.rs:462) and the
1958                // `TransactionTrait::begin`/`begin_with_config` impl (db_connection.rs:246,268) — a fixture
1959                // proved `ping` silently read pure with no rule at all.
1960                || path == "sea_orm::DatabaseConnection::ping"
1961                || path == "sea_orm::DatabaseConnection::begin"
1962                || path == "sea_orm::DatabaseConnection::begin_with_config"
1963                // `DatabaseTransaction::commit`/`rollback` (transaction.rs:119,159) call
1964                // `<sqlx::X as sqlx::Database>::TransactionManager::commit`/`rollback` directly — real
1965                // COMMIT/ROLLBACK, not sea_orm's OWN `::begin`/`::run` (both `pub(crate)`, unreachable).
1966                || path == "sea_orm::DatabaseTransaction::commit"
1967                || path == "sea_orm::DatabaseTransaction::rollback"
1968                // The three sqlx-backed pool connections' OWN `begin`/`ping`/`transaction` (driver/
1969                // sqlx_{mysql,postgres,sqlite}.rs) — `begin`/`transaction` acquire a pool connection and
1970                // start a real transaction, `ping` round-trips `conn.ping()` to the server.
1971                || path == "sea_orm::SqlxMySqlPoolConnection::begin"
1972                || path == "sea_orm::SqlxMySqlPoolConnection::ping"
1973                || path == "sea_orm::SqlxMySqlPoolConnection::transaction"
1974                || path == "sea_orm::SqlxPostgresPoolConnection::begin"
1975                || path == "sea_orm::SqlxPostgresPoolConnection::ping"
1976                || path == "sea_orm::SqlxPostgresPoolConnection::transaction"
1977                || path == "sea_orm::SqlxSqlitePoolConnection::begin"
1978                || path == "sea_orm::SqlxSqlitePoolConnection::ping"
1979                || path == "sea_orm::SqlxSqlitePoolConnection::transaction"
1980                // `Paginator::fetch`/`fetch_and_next`/`into_stream`/`num_pages`/`num_items_and_pages`
1981                // (executor/paginator.rs) all transitively reach `fetch_page`/`num_items` — already-
1982                // covered verbs — but each is ALSO a documented entry point a real consumer calls
1983                // directly (`cake::Entity::find().paginate(db, 50).into_stream()`, the crate's own
1984                // pagination doc example) and needs its own rule for the same reason
1985                // `Connection::backup` needed one beside `Backup::new` in rusqlite.
1986                || path == "sea_orm::Paginator::fetch"
1987                || path == "sea_orm::Paginator::fetch_and_next"
1988                || path == "sea_orm::Paginator::into_stream"
1989                || path == "sea_orm::Paginator::num_pages"
1990                || path == "sea_orm::Paginator::num_items_and_pages"
1991                // `Insert`/`Inserter`/`TryInsert::exec_with_returning_keys`/`_many` (executor/insert.rs)
1992                // are sibling spellings of the already-covered `::exec_with_returning` (same shape as
1993                // rusqlite's `open_with_flags_and_vfs` beside `open_with_flags`): each calls through to
1994                // the same underlying `exec_with_returning_keys`/`_many` executor, a real INSERT.
1995                || path.ends_with("::exec_with_returning_keys")
1996                || path.ends_with("::exec_with_returning_many")
1997            {
1998                return Some("Db");
1999            }
2000            return None;
2001        }
2002        // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
2003        // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
2004        // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
2005        //
2006        // `establish` is diesel's OWN name for `::connect` — `Connection::establish(url)`
2007        // (connection/mod.rs:243), implemented by opening the real backend handle in
2008        // `SqliteConnection`/`PgConnection`/`MysqlConnection::establish` (sqlite/connection/mod.rs:230,
2009        // pg/connection/mod.rs:176, mysql/connection/mod.rs:158 — each does the real file-open/socket-
2010        // connect). It is diesel's canonical, and by far most common, connection entry point — every
2011        // diesel quickstart opens with it — yet it shares no verb spelling with `::connect`, so it fell
2012        // through this allowlist to `None`, and because `diesel` IS a CALIBRATED_CRATES entry the miss
2013        // produced NO `coverage.uncovered` disclosure either (that ledger is crate-level: a calibrated
2014        // crate's unmatched path reads as reviewed-pure, not as a gap) — the same silent-purity shape as
2015        // `ignore::Walk::new` above, one call away from `establish_test_transaction`/`establish_inner`,
2016        // which are diesel's own private plumbing and stay unmatched (no `::` in the allowed suffix keeps
2017        // this exact-verb, not a substring match).
2018        const VERBS: [&str; 20] = [
2019            "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
2020            "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
2021            "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
2022            "::first", "::get_result", "::get_results", "::establish",
2023        ];
2024        if VERBS.iter().any(|v| path.ends_with(v)) {
2025            return Some("Db");
2026        }
2027        return None;
2028    }
2029    // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
2030    // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
2031    // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
2032    // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
2033    // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
2034    // fd's residual `Path::symlink_metadata` under-report.)
2035    if let Some(m) = path
2036        .strip_prefix("std::path::Path::")
2037        .or_else(|| path.strip_prefix("std::path::PathBuf::"))
2038    {
2039        const STAT: &[&str] = &[
2040            "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
2041            "try_exists", "is_file", "is_dir", "is_symlink",
2042        ];
2043        return STAT.contains(&m).then_some("Fs");
2044    }
2045    // OPTION-BUILDERS, the shape SPEC §1 ⟨0.32⟩ separates from an invocation object: "option-builders for
2046    // other effects (`OpenOptions`, request builders) stay pure because their resource arrives at the
2047    // terminal verb, which is charged at its own call site". An `OpenOptions` holds a handful of bools and
2048    // names no file; `DirBuilder` holds one bool and a mode. Both sit under the coarse `std::fs::` prefix
2049    // below, which charged Fs for `OpenOptions::new()` and for every `o.read(true)` — MEASURED: a
2050    // `let o = OpenOptions::new().read(true);` with no `open` ANYWHERE reported `Fs`.
2051    //
2052    // A DENYLIST, and keyed on the TYPE. Only the provably-pure setters/ctors are subtracted; anything
2053    // else under the type keeps its effect, so the terminal verb (`OpenOptions::open`, `DirBuilder::create`)
2054    // stays Fs and a std addition we have not read about fails in the safe direction. The type key is not
2055    // decoration: `create` is a pure flag SETTER on `OpenOptions` and the mkdir SYSCALL on `DirBuilder` —
2056    // one leaf, opposite answers. (candor-java keys the same carve-out by DESCRIPTOR, because its collision
2057    // is an overload — `command()` reads back, `command(List)` sets. Rust has no overloads, so the type IS
2058    // the discriminator; a bare-name denylist would silence `DirBuilder::create`.)
2059    if let Some(m) = path.strip_prefix("std::fs::OpenOptions::") {
2060        const PURE: &[&str] = &[
2061            // the builder itself + the std flag setters
2062            "new", "read", "write", "append", "truncate", "create", "create_new",
2063            // the platform extension setters (`OpenOptionsExt`, unix + windows)
2064            "mode", "custom_flags", "security_qos_flags", "access_mode", "share_mode", "attributes",
2065            // derives
2066            "clone", "default", "fmt", "eq", "ne", "hash",
2067        ];
2068        return (!PURE.contains(&m)).then_some("Fs");
2069    }
2070    if let Some(m) = path.strip_prefix("std::fs::DirBuilder::") {
2071        const PURE: &[&str] = &["new", "recursive", "mode", "clone", "default", "fmt"];
2072        return (!PURE.contains(&m)).then_some("Fs");
2073    }
2074    // THE PLATFORM `fs` MODULES — `std::os::{unix,windows,wasi}::fs`. Measured 2026-09-02, ground truth
2075    // EXECUTED (a `cargo run` that really created a symlink on disk and then stat'd it back):
2076    // `std::os::unix::fs::symlink`, `chown`, `lchown` and `chroot` all read PURE, so `deny Fs` over a
2077    // crate whose only filesystem write is a symlink exited 0. The `std::fs::` prefix below is the whole
2078    // filesystem rule and these modules are simply not under it — the platform-specific half of std's
2079    // filesystem API had no rule at all. (Found tracing why `tokio::fs::symlink` carries no `Fs` of its
2080    // own; its body is `asyncify(move || std::os::unix::fs::symlink(..))`.)
2081    //
2082    // A DENYLIST keyed on the TRAIT, exactly like `OpenOptions`/`DirBuilder` above and for the same
2083    // reason: subtract only the provably-pure surfaces, so a std addition nobody here has read about
2084    // fails in the SAFE direction. What is subtracted, and why each is pure:
2085    //   * `MetadataExt` / `DirEntryExt` / `FileTypeExt` — accessors over data ALREADY fetched by the
2086    //     `metadata()`/`read_dir()` call that is charged at its own site. `m.uid()` issues no syscall.
2087    //   * `PermissionsExt` — reads/sets bits on an in-memory `Permissions`; the syscall is
2088    //     `set_permissions`, charged under `std::fs::`.
2089    //   * `OpenOptionsExt` / `DirBuilderExt` — SPEC §1 ⟨0.32⟩ option-builders: the resource arrives at
2090    //     the terminal verb (`open`/`create`), which is charged at its own call site.
2091    // `FileExt` is deliberately NOT subtracted — `read_at`/`write_at`/`seek_read`/`seek_write` are the
2092    // positional-I/O syscalls and are exactly what this rule must keep.
2093    if let Some(rest) = path
2094        .strip_prefix("std::os::unix::fs::")
2095        .or_else(|| path.strip_prefix("std::os::windows::fs::"))
2096        .or_else(|| path.strip_prefix("std::os::wasi::fs::"))
2097    {
2098        const PURE_TRAITS: &[&str] = &[
2099            "MetadataExt::", "DirEntryExt::", "DirEntryExt2::", "FileTypeExt::",
2100            "PermissionsExt::", "OpenOptionsExt::", "DirBuilderExt::",
2101        ];
2102        return (!PURE_TRAITS.iter().any(|t| rest.starts_with(t))).then_some("Fs");
2103    }
2104    // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
2105    // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
2106    if path.starts_with("std::fs::")
2107        || path.starts_with("tokio::fs::")
2108        || path.starts_with("async_std::fs::")
2109        || crate_name == "async_fs"
2110        || crate_name == "fs_err"
2111    {
2112        return Some("Fs");
2113    }
2114    // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
2115    // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
2116    // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
2117    // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
2118    // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
2119    // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
2120    // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
2121    // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
2122    if crate_name == "memmap2" {
2123        let m = path.rsplit("::").next().unwrap_or(path);
2124        if m.starts_with("map")
2125            || m == "flush"
2126            || m == "flush_async"
2127            || m == "flush_range"
2128            || m == "flush_async_range"
2129            || m == "remap"
2130            || m.starts_with("make_")
2131            || m == "advise"
2132            || m == "advise_range"
2133            || m == "lock"
2134            || m == "unlock"
2135        {
2136            return Some("Fs");
2137        }
2138        return None;
2139    }
2140    // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
2141    // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
2142    // the file on disk; `close` removes it.
2143    if crate_name == "tempfile"
2144        && (path.ends_with("::tempfile")
2145            || path.ends_with("::tempfile_in")
2146            || path.ends_with("::tempdir")
2147            || path.ends_with("::tempdir_in")
2148            || path.ends_with("NamedTempFile::new")
2149            || path.ends_with("NamedTempFile::new_in")
2150            || path.ends_with("TempDir::new")
2151            || path.ends_with("TempDir::new_in")
2152            || path.ends_with("::persist")
2153            || path.ends_with("::persist_noclobber")
2154            || path.ends_with("::keep"))
2155    {
2156        return Some("Fs");
2157    }
2158    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against tempfile 3.27.0 source — the SIBLING
2159    // constructors/closers the create/persist verb list above never reached:
2160    //
2161    // `Builder::make`/`make_in` (lib.rs:716,739) are the crate's OWN documented escape hatch for a
2162    // caller-supplied factory (`Builder::new().make_in(dir, |path| UnixListener::bind(path))`) — real
2163    // disk creation via `util::create_helper`, same mechanism as `Builder::tempfile`/`tempfile_in`
2164    // two lines up, just a different entry verb.
2165    // `NamedTempFile::with_prefix`/`with_prefix_in`/`with_suffix`/`with_suffix_in` (file/mod.rs:630-677)
2166    // are one-call convenience wrappers documented as equivalent to `Builder::new().prefix(..).tempfile()`
2167    // — real creation, missing because the verb list matched `NamedTempFile::new`/`new_in` but not these.
2168    // `NamedTempFile::reopen` (file/mod.rs:951) calls the platform `imp::reopen`, a real `open`/`fstat`
2169    // pair (found the original file was replaced, or hands back a fresh handle to it).
2170    // `TempPath::close` (file/mod.rs:161) and `TempDir::close` (dir/mod.rs:470) `fs::remove_file`/
2171    // `remove_dir_all` the real path — `NamedTempFile::close` (file/mod.rs:727) is a thin `self.path.close()`
2172    // delegate to the same `TempPath::close`, so matching the `::close` suffix (crate-gated, and the ONLY
2173    // three `pub fn close` in this crate) catches it too without a separate rule.
2174    // `SpooledTempFile::set_len` (spooled.rs:134) calls `File::set_len` on the real disk file once the
2175    // in-memory buffer has spilled past `max_size` — the crate's own roll-over threshold, not a corner case.
2176    //
2177    // NOT included: `tempfile::create`/`create_named`/`reopen` (dir/imp/any.rs, file/imp/windows.rs,
2178    // file/imp/unix.rs) — despite being `pub fn`, `mod dir;`/`mod file;` are PRIVATE at the crate root
2179    // (lib.rs:202,204) with no re-export of the `imp` submodules, so no external consumer can ever name
2180    // these paths; the ratchet's generator guesses a crate-root alias for every candidate regardless of
2181    // whether one is real (the same "second, related shape" gap the previous pass's rusqlite/lettre
2182    // private-module findings already documented — restricted_types()/is_bare_pub check a type's or fn's
2183    // OWN visibility keyword, not its enclosing module chain). Removed from open.tsv rather than guessed.
2184    if crate_name == "tempfile"
2185        && (path == "tempfile::Builder::make"
2186            || path == "tempfile::Builder::make_in"
2187            || path == "tempfile::NamedTempFile::with_prefix"
2188            || path == "tempfile::NamedTempFile::with_prefix_in"
2189            || path == "tempfile::NamedTempFile::with_suffix"
2190            || path == "tempfile::NamedTempFile::with_suffix_in"
2191            || path == "tempfile::NamedTempFile::reopen"
2192            || path == "tempfile::SpooledTempFile::set_len"
2193            || path.ends_with("::close"))
2194    {
2195        return Some("Fs");
2196    }
2197    // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
2198    // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
2199    if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
2200        return Some("Fs");
2201    }
2202    // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
2203    // argon2 landed on exactly the call a security review cares about). In this engine's
2204    // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
2205    // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
2206    // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
2207    // carries the OsRng source itself (otherwise the most common salt mint is invisible).
2208    if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
2209        // SOUNDNESS R318 — `SaltString::generate` was this block's ONLY match target and the type was
2210        // DELETED upstream in password-hash 0.6.0 (0.5.0: 21 occurrences; 0.6.1: zero). Everything below
2211        // it fell through to `return None`, so all four crates asserted purity — including on
2212        // `argon2.hash_password(password)`, the one-argument DEFAULT TRAIT METHOD at
2213        // `password-hash-0.6.1/src/lib.rs:107` whose body is `let salt = try_generate_salt()?;`. That is
2214        // the most documented call shape in the family, minting OS entropy, reading pure, on crates whose
2215        // entire purpose is a security review.
2216        //
2217        // `ends_with`, NOT `contains`: `hash_password_with_salt` takes an explicit salt and is
2218        // deterministic recomputation — correctly PURE — and a `contains` match would fabricate `Rand`
2219        // on it. `hash_password_with_rng` DOES draw, from the caller's RNG, so it is charged.
2220        // The old `SaltString::generate` clause is kept: it is still the right answer for 0.5.x.
2221        if path.contains("SaltString::generate")
2222            || path.ends_with("::generate_salt")
2223            || path.ends_with("::try_generate_salt")
2224            // SOUNDNESS R330 — this suffix is a DEFAULT, refined by arity at the call site. `hash_password`
2225            // is one name with two signatures (see `password_hash_explicit_salt` below), and the path
2226            // cannot tell them apart. Charging here and NARROWING there is deliberate: a consumer that
2227            // never consults the refinement keeps the over-report, never the silence.
2228            || path.ends_with("::hash_password")
2229            || path.ends_with("::hash_password_with_rng")
2230        {
2231            return Some("Rand");
2232        }
2233        return None;
2234    }
2235    if crate_name == "bcrypt" {
2236        // SOUNDNESS R319 — this list was EXACTLY bcrypt's internal-salt surface at 0.15.1 and fell
2237        // behind by ADDITION, not by rename: 0.19.3 adds four more verbs that each document "the salt
2238        // is generated randomly using the OS randomness" and reach `getrandom::fill`. A stale-PATH
2239        // check could never have found this, because every old path still exists and still works —
2240        // a version ceiling catches additions, a path check only catches removals.
2241        // The explicit-salt family (`hash_with_salt`, `hash_with_salt_bytes`,
2242        // `non_truncating_hash_with_salt*`) stays PURE: the caller supplies the salt.
2243        if path.ends_with("::hash")
2244            || path.ends_with("::hash_with_result")
2245            || path.ends_with("::non_truncating_hash")
2246            || path.ends_with("::hash_bytes")
2247            || path.ends_with("::non_truncating_hash_bytes")
2248            || path.ends_with("::non_truncating_hash_with_result")
2249        {
2250            return Some("Rand");
2251        }
2252        return None;
2253    }
2254    if crate_name == "rand_core" {
2255        if path.contains("OsRng")
2256            || path.ends_with("::next_u32")
2257            || path.ends_with("::next_u64")
2258            || path.ends_with("::fill_bytes")
2259            // SOUNDNESS R333 — the FALLIBLE trait. rand_core 0.10 splits `RngCore` from `TryRngCore`,
2260            // and `SysRng` (the renamed `OsRng`) is fallible, so `try_next_u32` is the natural API on
2261            // it. `ends_with("::next_u32")` does NOT match `::try_next_u32`, so the modern spelling of
2262            // a real entropy draw read PURE. Third instance in one day, after R319 and R321, of the
2263            // same shape: an upstream rename is rarely just a rename — it arrives with an API SHAPE
2264            // change, and a suffix list written against the old shape stops covering the new one.
2265            || path.ends_with("::try_next_u32")
2266            || path.ends_with("::try_next_u64")
2267            || path.ends_with("::try_fill_bytes")
2268        {
2269            return Some("Rand");
2270        }
2271        return None;
2272    }
2273    // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
2274    // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
2275    // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
2276    // over-reported those as `Rand`; match only the calls that actually consume randomness — the
2277    // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
2278    // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
2279    if crate_name == "getrandom" {
2280        return Some("Rand");
2281    }
2282    // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
2283    // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
2284    // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
2285    // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
2286    // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
2287    // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
2288    // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
2289    // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
2290    // (i.e. a value draw) is Rand.
2291    if crate_name == "fastrand" {
2292        let m = path.rsplit("::").next().unwrap_or(path);
2293        // Provably pure: deterministic seeded ctor + state split/copy.
2294        if m == "with_seed" || m == "fork" || m == "clone" {
2295            return None;
2296        }
2297        // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
2298        // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
2299        // method we don't recognise — keep the effect, the safe direction.)
2300        return Some("Rand");
2301    }
2302    if crate_name == "rand" {
2303        let rng_verb = path.ends_with("::gen")
2304            || path.ends_with("::gen_range")
2305            || path.ends_with("::gen_bool")
2306            || path.ends_with("::gen_ratio")
2307            || path.ends_with("::random")
2308            || path.ends_with("::random_range")
2309            || path.ends_with("::random_bool")
2310            || path.ends_with("::random_ratio")
2311            || path.ends_with("::random_iter") // rand 0.9 iterator generator
2312            || path.ends_with("::gen_iter")
2313            || path.ends_with("::fill")
2314            || path.ends_with("::fill_bytes")
2315            || path.ends_with("::try_fill")
2316            || path.ends_with("::try_fill_bytes")
2317            || path.ends_with("::sample")
2318            || path.ends_with("::sample_iter")
2319            || path.ends_with("::next_u32")
2320            || path.ends_with("::next_u64")
2321            // SOUNDNESS R333 — the FALLIBLE trait, in the `rand` block as well as `rand_core`'s. Two
2322            // verb lists answer one question ("is this a draw?") and only one had been widened, which
2323            // is why the first half of this fix moved nothing: correct code on a path the call does not
2324            // take. `TryRngCore`'s verbs are the natural API on the fallible `SysRng`.
2325            || path.ends_with("::try_next_u32")
2326            || path.ends_with("::try_next_u64")
2327            || path.ends_with("::thread_rng")
2328            || path.ends_with("::rng")
2329            || path.ends_with("::from_entropy")
2330            || path.ends_with("::from_os_rng");
2331        // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
2332        // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
2333        // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
2334        // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
2335        // review: OsRng is a unit struct, cloning consumes nothing).
2336        let m = path.rsplit("::").next().unwrap_or(path);
2337        let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
2338        // SOUNDNESS R320 — …and the SAME clause for the type's CURRENT NAME. rand 0.10 renamed the OS
2339        // entropy source to `SysRng` and moved it to `getrandom`, re-exporting it as
2340        // `rand::rngs::SysRng` (`rand-0.10.2/src/rngs/mod.rs:119`). Measured, not assumed: candor
2341        // resolves the crate from the path the CONSUMER writes, so `rand::rngs::SysRng` arrives with
2342        // crate_name `rand` and lands here — NOT in getrandom's whole-crate exemption, which is what
2343        // R320 left open. The asymmetry that leaves was executed on a fixture: with `OsRng` as the
2344        // receiver an unrecognised verb charges `Rand` (the `contains` clause is the safety margin over
2345        // the verb list); with `SysRng` it read PURE. So the margin protected the DELETED spelling and
2346        // not its replacement, which is the wrong way round for a type whose only purpose is to draw
2347        // from the OS. Same construction exemption for the same reason — `SysRng` is a unit struct
2348        // (`getrandom-0.4.3/src/sys_rng.rs:34`), so copying the handle consumes nothing.
2349        let sys_rng = path.contains("SysRng") && !matches!(m, "clone" | "fork" | "default");
2350        // §E1 HIT COUNTER (`CANDOR_ALIAS_DEBUG`), for `bin/corpus-ab.py --mark`: a widened charge that
2351        // moves no rows is either inert or unreached, and those two print identically.
2352        if sys_rng && std::env::var("CANDOR_ALIAS_DEBUG").is_ok() {
2353            eprintln!("R320SYSRNG {path}");
2354        }
2355        if rng_verb || os_rng || sys_rng {
2356            return Some("Rand");
2357        }
2358        return None;
2359    }
2360    // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
2361    // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
2362    // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
2363    // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
2364    // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
2365    if path.starts_with("std::process::Command")
2366        || path.starts_with("std::process::Child")
2367        || path.starts_with("tokio::process::Command")
2368        || path.starts_with("tokio::process::Child")
2369        || path.starts_with("async_std::process::Command")
2370        || path.starts_with("async_std::process::Child")
2371    {
2372        // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
2373        // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
2374        // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
2375        // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
2376        if path.ends_with("::get_program")
2377            || path.ends_with("::get_args")
2378            || path.ends_with("::get_envs")
2379            || path.ends_with("::get_current_dir")
2380            || path.ends_with("Child::id")
2381        {
2382            return None;
2383        }
2384        return Some("Exec");
2385    }
2386    // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
2387    // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
2388    // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
2389    // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
2390    // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
2391    // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
2392    // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
2393    // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
2394    if crate_name == "async_process" || crate_name == "portable_pty" {
2395        let m = path.rsplit("::").next().unwrap_or(path);
2396        // THE COVERAGE-GATE SWEEP (2026-08-27): `CommandBuilder::get_shell` (cmdbuilder.rs:544, unix)
2397        // LOOKS like a config read-back getter — same `get_` naming as the pure getters below — but its
2398        // unix body calls `nix::unistd::access(shell, X_OK)`, a real filesystem-access syscall, before
2399        // falling back to a password-database lookup if `$SHELL` isn't set or isn't executable. Carved
2400        // out of the `get_` prefix exemption BEFORE it, not folded into it — the one getter this crate
2401        // exposes that is not actually pure. Verified against portable-pty 0.9.0.
2402        if m == "get_shell" {
2403            return Some("Fs");
2404        }
2405        // configuration read-back getters — pure (no spawn).
2406        if m.starts_with("get_") || m == "as_unix_command_line" {
2407            return None;
2408        }
2409        // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
2410        if matches!(
2411            m,
2412            "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
2413                | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
2414                | "cwd" | "current_dir" | "rows" | "cols"
2415                | "clone" | "fmt" | "eq" | "ne" | "hash"
2416        ) {
2417            return None;
2418        }
2419        return Some("Exec");
2420    }
2421    // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
2422    // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
2423    if crate_name == "duct"
2424        && (path.ends_with("::run")
2425            || path.ends_with("::read")
2426            || path.ends_with("::start")
2427            || path.ends_with("::read_chars")
2428            // `Expression::reader` (lib.rs:357) calls the already-covered `.start()` internally to
2429            // launch the child and hands back a streaming `ReaderHandle` — a distinct public entry
2430            // point from `start` itself, so the suffix match above never reached it.
2431            || path.ends_with("Expression::reader"))
2432    {
2433        return Some("Exec");
2434    }
2435    if path.starts_with("std::env::") {
2436        return Some("Env");
2437    }
2438    // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
2439    // environment). Match the load/read entry points; `Error`/builder types stay pure.
2440    if matches!(crate_name, "dotenvy" | "dotenv")
2441        && (path.ends_with("::dotenv")
2442            || path.ends_with("::dotenv_override")
2443            || path.ends_with("::from_path")
2444            || path.ends_with("::from_path_override")
2445            || path.ends_with("::from_filename")
2446            || path.ends_with("::from_filename_override")
2447            || path.ends_with("::from_read")
2448            || path.ends_with("::from_read_override")
2449            || path.ends_with("::load")
2450            || path.ends_with("::var")
2451            || path.ends_with("::vars"))
2452    {
2453        return Some("Env");
2454    }
2455    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against dotenv 0.15.0 and dotenvy 0.15.7
2456    // (byte-identical shape in both crates) — the ITERATOR-returning siblings of the load verbs above,
2457    // which return the parsed pairs instead of mutating the environment but still touch disk to get
2458    // them: `dotenv_iter`/`from_filename_iter` (lib.rs) resolve and open a `.env`-shaped file exactly
2459    // like `dotenv`/`from_filename` two lines up (same `Finder::new().find()` call); `from_path_iter`
2460    // opens the given path directly. Bucketed `Env` for consistency with the rest of this family rather
2461    // than `Fs` (self-scan's raw signal): the crate's OWN purpose is env-var loading, and splitting the
2462    // iterator variants into a different effect bucket from their non-iterator siblings would be an
2463    // arbitrary distinction a reviewer gains nothing from.
2464    //
2465    // NOT included: `Finder::find` / the free fn `find` (find.rs) — both are used only via a PRIVATE
2466    // `use crate::find::Finder;` (no `pub use`) inside a private `mod find;` (lib.rs), so neither
2467    // `dotenv(y)::Finder` nor `dotenv(y)::find` is a path any external consumer can compile a call to;
2468    // the ratchet's generator still guesses a crate-root alias for every candidate (the same private-
2469    // module shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2470    if matches!(crate_name, "dotenvy" | "dotenv")
2471        && (path.ends_with("::dotenv_iter")
2472            || path.ends_with("::from_filename_iter")
2473            || path.ends_with("::from_path_iter"))
2474    {
2475        return Some("Env");
2476    }
2477    // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
2478    // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
2479    // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
2480    if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
2481        return Some("Clock");
2482    }
2483    if crate_name == "time"
2484        && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
2485    {
2486        return Some("Clock");
2487    }
2488    // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
2489    // the emit, not the whole crate. The actual program output is the macro-expanded
2490    // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
2491    // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
2492    // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
2493    // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
2494    // fabricated Log on them. Match the emit verbs; everything else returns None.
2495    if crate_name == "tracing" {
2496        let m = path.rsplit("::").next().unwrap_or(path);
2497        // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
2498        // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
2499        // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
2500        // have other tails and stay None.
2501        if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
2502            || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
2503            || m == "error_span" || m == "span"
2504            || m == "event"
2505            || m == "new_span"
2506            || m == "record"
2507            || m == "record_follows_from"
2508            || m == "enter"
2509            || m == "exit"
2510            || m == "in_scope"
2511            || m == "entered"
2512            || path.contains("::__macro_support")
2513            || path.contains("::__tracing")
2514            || path.contains("Subscriber::event")
2515            || path.contains("Subscriber::new_span")
2516            || path.contains("Subscriber::enter")
2517            || path.contains("Subscriber::exit")
2518        {
2519            return Some("Log");
2520        }
2521        return None;
2522    }
2523    // The `log` facade: its macros route through `log::__private_api`; the crate's types
2524    // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
2525    if crate_name == "log" {
2526        // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
2527        // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
2528        let m = path.rsplit("::").next().unwrap_or(path);
2529        if path.contains("::__private_api")
2530            || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
2531        {
2532            return Some("Log");
2533        }
2534    }
2535    // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
2536    // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
2537    // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
2538    // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
2539    // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
2540    if crate_name == "rustc_lint"
2541        && (path.ends_with("::emit_span_lint")
2542            || path.ends_with("::span_lint")
2543            || path.ends_with("::span_lint_hir"))
2544    {
2545        return Some("Log");
2546    }
2547    if crate_name == "rustc_errors"
2548        && (path.ends_with("::emit")
2549            || path.ends_with("::emit_diagnostic")
2550            || path.ends_with("::emit_now"))
2551    {
2552        return Some("Log");
2553    }
2554    // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
2555    // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
2556    // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
2557    // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
2558    // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
2559    // connection to the clipboard server, so it's an effect too; `get`/`set` return the
2560    // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
2561    //
2562    // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28), verified against arboard 3.6.1: `file_list`
2563    // (lib.rs:205,251, `Get::file_list`/`Set::file_list`) is the SAME builder-then-terminal shape as
2564    // `text`/`image`/`html` two lines up — reading/writing the clipboard's file-path list — but was
2565    // missing, not because it's ambiguous (it's the only `file_list` in the crate) but because
2566    // `eval/coverage-gate/generate.py`'s own generator only triggers on a self-scan `inferred` set
2567    // containing Fs/Net/Db/Exec; Clipboard isn't in that trigger set, so a missing Clipboard verb is
2568    // structurally invisible to the completeness gate regardless of phrasing. `Clear::default`
2569    // (lib.rs:265) is `Clipboard::clear`'s own documented alternate entry point
2570    // (`clear() { self.clear_with().default() }`, lib.rs:156-158) — the SAME "sibling constructor"
2571    // shape as `ignore::Walk::new` beside `WalkBuilder::build`; `clear_with()` itself doesn't share
2572    // `clear`'s leaf, so neither the constructor nor its terminal was covered. `default` is otherwise
2573    // unused in this crate (no derived `Default` impls), so the bare leaf carries no fabrication risk
2574    // once crate-gated.
2575    if crate_name == "arboard" {
2576        let m = path.rsplit("::").next().unwrap_or(path);
2577        if m == "new"
2578            || m == "get"
2579            || m == "set"
2580            || m == "clear"
2581            || m == "get_text"
2582            || m == "set_text"
2583            || m == "set_html"
2584            || m == "get_image"
2585            || m == "set_image"
2586            || m == "text"
2587            || m == "image"
2588            || m == "html"
2589            || m == "file_list"
2590            || m == "default"
2591        {
2592            return Some("Clipboard");
2593        }
2594        return None;
2595    }
2596    // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
2597    //    notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
2598
2599    // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
2600    // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
2601    // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
2602    // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
2603    if crate_name == "etcetera" {
2604        let m = path.rsplit("::").next().unwrap_or(path);
2605        if m == "home_dir"
2606            || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
2607            || m == "config_dir" || m == "data_dir" || m == "cache_dir"
2608            || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
2609        {
2610            return Some("Env");
2611        }
2612        return None;
2613    }
2614    // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
2615    // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
2616    // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
2617    // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
2618    if crate_name == "sqlx_core" {
2619        if path.ends_with("::connect") || path.ends_with("::connect_with") {
2620            return Some("Net");
2621        }
2622        if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
2623            || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
2624            || path.ends_with("::execute") || path.ends_with("::execute_many")
2625            || path.ends_with("::prepare") || path.ends_with("::prepare_with")
2626            || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
2627        {
2628            return Some("Db");
2629        }
2630        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against sqlx-core 0.8.6/0.9.0 (identical
2631        // shape in both) — the free-function surface under `pub mod fs`/`pub mod net::tls`/
2632        // `pub mod migrate`, none of which end in the connect/fetch/execute verbs above:
2633        //
2634        // `fs::{read,read_to_string,create_dir_all,remove_file,remove_dir,remove_dir_all,read_dir}`
2635        // (fs.rs) are direct `std::fs::*` calls off-loaded to `spawn_blocking` — sqlx's OWN async
2636        // filesystem shim, used by the migrator to read `.sql` files and by SQLite to manage on-disk
2637        // databases. `net::tls::handshake` (net/tls/mod.rs:69) is the real TLS handshake dispatch
2638        // reqwest/ureq-style crates already model — sqlx has its OWN copy because it drives the
2639        // handshake over its own `Socket` trait rather than a `TcpStream` directly.
2640        // `migrate::resolve_blocking`/`resolve_blocking_with_config` (migrate/source.rs:151,156) are
2641        // `#[doc(hidden)]` but still `pub use`-exported at `sqlx_core::migrate` (mod.rs:16) — real,
2642        // callable, and reads the migrations directory off disk (`fs::read_dir` + per-file `canonicalize`).
2643        //
2644        // NOT included: `net::tls::tls_rustls::RustlsSocket::poll_{flush,read_ready,write_ready,
2645        // shutdown}` — `RustlsSocket` is declared inside `mod tls_rustls;` (net/tls/mod.rs), which is
2646        // PRIVATE with no re-export, so no external consumer can ever name it (the same private-module
2647        // shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2648        if path == "sqlx_core::fs::read"
2649            || path == "sqlx_core::fs::read_to_string"
2650            || path == "sqlx_core::fs::create_dir_all"
2651            || path == "sqlx_core::fs::remove_file"
2652            || path == "sqlx_core::fs::remove_dir"
2653            || path == "sqlx_core::fs::remove_dir_all"
2654            || path == "sqlx_core::fs::read_dir"
2655            || path.ends_with("::migrate::resolve_blocking")
2656            || path.ends_with("::migrate::resolve_blocking_with_config")
2657            || path == "sqlx_core::config::Config::try_from_path"
2658            || path == "sqlx_core::Config::try_from_path"
2659            || path == "sqlx_core::config::Config::try_from_path_or_default"
2660            || path == "sqlx_core::Config::try_from_path_or_default"
2661            || path == "sqlx_core::config::Config::try_from_crate_or_default"
2662            || path == "sqlx_core::Config::try_from_crate_or_default"
2663        {
2664            return Some("Fs");
2665        }
2666        if path.ends_with("::net::tls::handshake") {
2667            return Some("Net");
2668        }
2669        return None;
2670    }
2671    // `walkdir` — recursive directory traversal. Charged at `WalkDir::new` (construction), the SAME
2672    // point `ignore::WalkBuilder::build`/`glob::glob` are charged, and NOT at `IntoIter::next` (driving
2673    // the iterator) as this rule originally read. The disk read (`read_dir`+`stat`) is technically lazy
2674    // — nothing happens until the value is iterated — but `.next()` is reached ONLY through a receiver
2675    // typed as `walkdir::IntoIter`, and candor-scan's receiver-typing (`ctor_type`/`resolve_recv_type`)
2676    // hard-blocks the `.into_iter()` verb (a guard against fabricating onto a DIFFERENT std type, e.g.
2677    // `Vec::into_iter()` -> `std::vec::IntoIter`) with no per-crate exception for the SAME-crate case.
2678    // `WalkDir::into_iter()` returns `walkdir::IntoIter` (same crate as `WalkDir` itself, exactly the
2679    // shape the blocklist exists to protect, not the shape it should be blocking) — so every idiomatic
2680    // form (`for e in WalkDir::new(p)`, `.into_iter().count()`, `.into_iter().filter_map(..)`, an
2681    // untyped `let it = ...into_iter(); it.next()`) never reaches a typed `IntoIter::next` receiver and
2682    // read silent-pure under the OLD rule, with `deny Fs` exiting 0 over code that walks the filesystem.
2683    // Charging at `WalkDir::new` needs no receiver typing at all — it's a plain `Expr::Call`, robust to
2684    // however the iterator is later consumed. `max_depth`/`follow_links`/`sort_by`/`into_iter` (the
2685    // BUILDER chain, no I/O until pulled) and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
2686    // accessors (`file_type` makes NO syscall) stay PURE — matched by nothing here, so they fall through.
2687    // The `IntoIter::next`/`DirEntry::metadata` rule is KEPT, not dead: it still fires whenever the
2688    // concrete `walkdir::IntoIter` type reaches `.next()` by a route the blocklist doesn't gate — an
2689    // EXPLICIT type annotation (`let it: walkdir::IntoIter = ...`), a struct field declared with that
2690    // type, or a local fn's declared return type — real, if rarer, shapes than the inline-chain idiom.
2691    // (Companion to the already-modeled `ignore`.)
2692    if crate_name == "walkdir" {
2693        if path.ends_with("::WalkDir::new")
2694            || path.ends_with("::IntoIter::next")
2695            || path.ends_with("::DirEntry::metadata")
2696        {
2697            return Some("Fs");
2698        }
2699        return None;
2700    }
2701    // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
2702    // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
2703    // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
2704    // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
2705    if crate_name == "filetime" {
2706        if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
2707            || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
2708            || path.ends_with("::set_file_handle_times")
2709        {
2710            return Some("Fs");
2711        }
2712        if path.ends_with("::FileTime::now") {
2713            return Some("Clock");
2714        }
2715        return None;
2716    }
2717    // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
2718    // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
2719    // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
2720    if crate_name == "execute" {
2721        if path.contains("::execute") {
2722            return Some("Exec");
2723        }
2724        return None;
2725    }
2726    // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
2727    // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
2728    // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
2729    if crate_name == "ctrlc" {
2730        if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
2731            return Some("Ipc");
2732        }
2733        return None;
2734    }
2735    // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
2736    // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
2737    // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
2738    // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
2739    // read argv) so they stay pure too.
2740    //
2741    // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28): this file used to leave `Arg::env` unmodeled, calling
2742    // bare `::env` "too generic to gate safely" — the same words used for libc's genuinely ambiguous fd
2743    // verbs. It isn't the same shape: `Arg::env(name)` calls `env::var_os(&name)` DIRECTLY at builder
2744    // time (verified against clap_builder 4.6.6, builder/arg.rs:2205-2213 — real, immediate, not
2745    // deferred to `get_matches()`), and — unlike a bare fd, which could be Fs/Net/Ipc — `clap_builder`
2746    // has exactly ONE `pub fn` ending `::env` in its entire source (`Arg::env`; the deprecated
2747    // `Arg::env_os` is a one-line delegate to it). This whole match arm is already crate-gated on
2748    // `crate_name == "clap"`, so no ambiguity survives to fabricate on: a fixture proved the untreated
2749    // gap silently vanishes the function ("functions": [] — the exact R59 shape, worse than an
2750    // uncalibrated dependency, since `clap` is fully `CALIBRATED_CRATES` and the miss reads as
2751    // reviewed-pure rather than disclosed). Classified, not carved into
2752    // `CALIBRATED_BUT_PARTIAL_CRATES`, because the effect IS unambiguous once actually checked.
2753    if crate_name == "clap" {
2754        if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
2755            || path.ends_with("::try_get_matches")
2756            || path.ends_with("::parse") || path.ends_with("::try_parse")
2757            || path.ends_with("::env") || path.ends_with("::env_os")
2758        {
2759            return Some("Env");
2760        }
2761        return None;
2762    }
2763    // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
2764    // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
2765    // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
2766    // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
2767    if crate_name == "jiff" {
2768        if path.ends_with("::now") || path.ends_with("::now_with") {
2769            return Some("Clock");
2770        }
2771        if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
2772            || path.ends_with("::TimeZoneDatabase::get")
2773            // `TimeZone::try_system` (tz/timezone.rs:391) is `TimeZone::system`'s fallible sibling —
2774            // same `crate::tz::system::get(crate::tz::db())` call, real localtime/zoneinfo disk read.
2775            || path.ends_with("::TimeZone::try_system")
2776        {
2777            return Some("Fs");
2778        }
2779        return None;
2780    }
2781    // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
2782    // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
2783    // `Builder::new`/`build` and the format/filter/target config setters are PURE.
2784    if crate_name == "env_logger" {
2785        if path.ends_with("::init") || path.ends_with("::try_init")
2786            || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
2787        {
2788            return Some("Log");
2789        }
2790        return None;
2791    }
2792    // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
2793    // console dialogue with the user — Ipc, like the other local-channel effects). The
2794    // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
2795    if crate_name == "dialoguer" {
2796        if path.ends_with("::interact") || path.ends_with("::interact_on")
2797            || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
2798            || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
2799        {
2800            return Some("Ipc");
2801        }
2802        // `Editor::edit` (edit.rs:93) writes the caller's string to a real tempfile, spawns
2803        // `$VISUAL`/`$EDITOR` on it via `process::Command::spawn().wait()`, then reads the result back
2804        // — a real subprocess launch, not a terminal-dialogue read/write like the verbs above.
2805        if path.ends_with("Editor::edit") {
2806            return Some("Exec");
2807        }
2808        // WIDENED-CORE AUDIT (2026-08-28): `Editor::new`/`Editor::default` (edit.rs:54,44) call
2809        // `get_default_editor()` (edit.rs:31), which reads `env::var_os("VISUAL")` then `("EDITOR")`
2810        // IMMEDIATELY at construction — a real, independent Env read a consumer triggers just by
2811        // building an `Editor`, whether or not `.edit()` is ever called (the SAME "builder-time env
2812        // read, separate from the terminal verb" shape as `clap::Arg::env`, R59-class). Verified against
2813        // dialoguer 0.12.0: `get_default_editor` is the ONLY caller of either `env::var_os`, so there is
2814        // no ambiguity gating this to just these two constructors.
2815        if path.ends_with("Editor::new") || path.ends_with("Editor::default") {
2816            return Some("Env");
2817        }
2818        return None;
2819    }
2820    // `tracing_subscriber` — the subscriber that gives `tracing` somewhere to go. TWO effects, and the
2821    // filing said "Log/Fs": VERIFIED against 0.3.23, the Fs half is WRONG.
2822    //
2823    //   Log — `fmt/fmt_layer.rs:749` defaults `make_writer: io::stdout`, so the fmt INIT terminals install
2824    //         a subscriber that writes program output. Same family as `log`/`tracing`/`env_logger`.
2825    //   Env — `fmt/mod.rs:1219` reads `RUST_LOG` on the `init()` path, `fmt_layer.rs` reads `NO_COLOR`,
2826    //         and `filter/env/builder.rs:189,203` read `env::var(self.env_var_name())`.
2827    //
2828    // NOT Fs. The only `std::fs` in the crate is `impl MakeWriter for std::fs::File` — the crate ACCEPTING
2829    // a caller-supplied File, not opening one. The caller's `File::create` is classified on the caller, so
2830    // charging Fs here would double-count, exactly the `serde_json::from_reader` caveat one crate over.
2831    //
2832    // The builders (`fmt()`, `layer()`, `with_writer`, `with_target`, `EnvFilter::new`) are PURE: they
2833    // describe a subscriber. Only the INIT terminals install one, and only the from-env constructors read.
2834    if crate_name == "tracing_subscriber" {
2835        if path.ends_with("::init") || path.ends_with("::try_init") {
2836            return Some("Log");
2837        }
2838        if path.ends_with("::from_default_env") || path.ends_with("::try_from_default_env")
2839            || path.ends_with("::from_env") || path.ends_with("::from_env_lossy")
2840            || path.ends_with("::try_from_env")
2841        {
2842            return Some("Env");
2843        }
2844        return None;
2845    }
2846    // `crossterm` — the terminal driver. The tty is a USER DIALOGUE CHANNEL, so this is Ipc, matching the
2847    // ruling `dialoguer`/`console`/`terminal_colorsaurus` already carry rather than a new one.
2848    //
2849    // VERIFIED against crossterm-0.28.1 rather than assumed: `command.rs` `execute`/`queue` end in
2850    // `self.flush()?` on the writer (real code, not a doc example), `event::read`/`poll` read tty input,
2851    // and `terminal::{enable,disable}_raw_mode` + `size`/`window_size` talk to the device.
2852    //
2853    // `size`/`window_size`/`is_raw_mode_enabled` ARE classified, and that is deliberate: once a crate is
2854    // CALIBRATED every unmatched path becomes a PURITY CLAIM rather than a disclosed blind spot, so a tty
2855    // ioctl left to fall through would be claimed pure. The genuinely pure surface — the Command VALUE
2856    // types (`Print`, `MoveTo`, `SetForegroundColor`), the style/event data types — carries none of these
2857    // tails and stays pure correctly.
2858    if crate_name == "crossterm" {
2859        if path.ends_with("::execute") || path.ends_with("::queue")
2860            || path.ends_with("::event::read") || path.ends_with("::event::poll")
2861            || path.ends_with("::enable_raw_mode") || path.ends_with("::disable_raw_mode")
2862            || path.ends_with("::size") || path.ends_with("::window_size")
2863            || path.ends_with("::is_raw_mode_enabled")
2864        {
2865            return Some("Ipc");
2866        }
2867        // `terminal::supports_keyboard_enhancement` (terminal/sys/unix.rs:188, re-exported `pub use
2868        // sys::supports_keyboard_enhancement` in terminal.rs) drives the SAME channel as
2869        // `event::read`/`enable_raw_mode` above (it toggles raw mode, then reads/polls terminal events
2870        // to detect the kitty keyboard protocol) — bucketed `Ipc` for consistency with the rest of this
2871        // tty-dialogue family rather than the `Fs` self-scan's raw signal reports (that signal traces
2872        // through the crate's internal event source opening `/dev/tty` as a fallback fd, the same
2873        // primitive `tty_fd` below wraps — not a distinct effect from the terminal channel itself).
2874        //
2875        // NOT modelled: `tty_fd` (terminal/sys/file_descriptor.rs:124) — declared inside `pub(crate) mod
2876        // sys;` (terminal.rs:99), so despite being `pub fn` it is unreachable from outside the crate
2877        // (only used internally by the event source). Removed from open.tsv rather than guessed.
2878        if path.ends_with("::supports_keyboard_enhancement") {
2879            return Some("Ipc");
2880        }
2881        return None;
2882    }
2883    // `ratatui` — the TUI renderer, and the single loudest source of disclosed-blind calls measured in the
2884    // 2026-07-14 four-ecosystem sweep (3,345 across three real repos). The backlog filed it as
2885    // "mark reviewed-pure"; VERIFYING against ratatui-0.29.0 REFUTES that for part of the surface:
2886    // `terminal/terminal.rs` `draw`/`flush`/`clear`/`autoresize`/`hide_cursor`/`show_cursor` end in a
2887    // backend flush, and `backend/` writes to the terminal. Marking the whole crate pure would have
2888    // claimed purity over the one API that actually writes.
2889    //
2890    // So the split is where the sweep's noise actually is: the BULK of those 3,345 calls are widget,
2891    // layout, buffer, style and text constructors — genuinely pure, and now covered rather than disclosed.
2892    // The Terminal/backend verbs are Ipc, same channel as crossterm underneath them.
2893    if crate_name == "ratatui" {
2894        // CARVE-OUT FIRST: `widgets::canvas` is an IN-MEMORY grid. `Context::draw(&shape)` sets
2895        // `self.dirty` and paints into a `Painter` — no terminal, no writer, provably pure — but it ends
2896        // in `::draw` and the tails below would have charged it `Ipc`. MEASURED as a live fabrication on a
2897        // fixture (`plot(ctx) -> ['Ipc']`) before this line existed, and it is a HOT path: a TUI drawing
2898        // charts or maps calls it per shape per frame.
2899        //
2900        // A DENYLIST (carve out the proven-pure module) rather than an allowlist of `Terminal::`, per the
2901        // family rule: an allowlist silently under-reports whatever it forgot, and the write surface here
2902        // is Terminal AND the backends (`CrosstermBackend::flush`), so pinning to `Terminal::` would drop
2903        // a direct backend call. Reading the crate, canvas is the only module whose methods collide with
2904        // these tails.
2905        if path.contains("::canvas::") {
2906            return None;
2907        }
2908        if path.ends_with("::draw") || path.ends_with("::try_draw") || path.ends_with("::flush")
2909            || path.ends_with("::autoresize") || path.ends_with("::clear")
2910            || path.ends_with("::hide_cursor") || path.ends_with("::show_cursor")
2911            || path.ends_with("::insert_before")
2912            || path.ends_with("::set_cursor_position") || path.ends_with("::get_cursor_position")
2913        {
2914            return Some("Ipc");
2915        }
2916        return None;
2917    }
2918    // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
2919    // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
2920    // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
2921    // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
2922    //
2923    // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28): the note above ("no `write_str` — `Term` impls
2924    // `io::Write`") RECORDED the trait impl without covering it. `Term::write`/`Term::flush` (console
2925    // 0.15.11, term.rs:622-633) call `self.write_through(buf)` — the SAME primitive `write_line` already
2926    // charges Ipc for — and `Term::read` (term.rs:650-654) calls `io::stdin().read(buf)` directly. These
2927    // are a second, real entry point to the identical tty channel, missed because `read`/`write`/`flush`
2928    // are the names every I/O type shares — but crate-gated on `crate_name == "console"`, term.rs
2929    // defines exactly one of each (verified against the real source; no ambiguity survives the gate,
2930    // same shape as `clap::Arg::env`). Left unclassified, a fn calling only `term.write_all(..)` (no
2931    // `write_line`) read as reviewed-pure, since `console` is fully `CALIBRATED_CRATES`.
2932    if crate_name == "console" {
2933        if path.ends_with("::write_line") || path.ends_with("::read_line")
2934            || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
2935            || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
2936            || path.ends_with("::read_secure_line")
2937            || path.ends_with("Term::write") || path.ends_with("Term::flush")
2938            || path.ends_with("Term::read")
2939        {
2940            return Some("Ipc");
2941        }
2942        if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
2943            || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
2944        {
2945            return Some("Env");
2946        }
2947        return None;
2948    }
2949    // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
2950    // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
2951    if crate_name == "terminal_colorsaurus" {
2952        if path.ends_with("::background_color") || path.ends_with("::foreground_color")
2953            || path.ends_with("::color_palette")
2954            // SOUNDNESS R321 — FQN-exact, not a suffix. `1.0.3` added
2955            // `ColorPalette::theme_mode(&self)` (src/lib.rs:112), a PURE derived accessor
2956            // comparing `perceived_lightness()` on two already-fetched colours, alongside the
2957            // effectful free `theme_mode(options)` (src/lib.rs:157). A `::theme_mode` suffix
2958            // charges `Ipc` to both. Counterpart to R319: the same upstream mechanism — a
2959            // release ADDING a name — under-reports in one crate and fabricates in another,
2960            // depending only on whether the new name is effectful.
2961            || path == "terminal_colorsaurus::theme_mode"
2962        {
2963            return Some("Ipc");
2964        }
2965        return None;
2966    }
2967    // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
2968    // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
2969    // effects are out of scope here — we model only backoff's own Clock effect.)
2970    if crate_name == "backoff" {
2971        if path.ends_with("::retry") || path.ends_with("::retry_notify") {
2972            return Some("Clock");
2973        }
2974        return None;
2975    }
2976    // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
2977    // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
2978    if crate_name == "lscolors" {
2979        if path.ends_with("::from_env") {
2980            return Some("Env");
2981        }
2982        return None;
2983    }
2984    // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
2985    if crate_name == "wild" {
2986        if path.ends_with("::args") || path.ends_with("::args_os") {
2987            return Some("Env");
2988        }
2989        return None;
2990    }
2991    // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
2992    // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
2993    // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
2994    // they read no data; flagging them would be an inconsistent over-report.
2995    if crate_name == "grep_cli" {
2996        if path.ends_with("::build") {
2997            return Some("Exec");
2998        }
2999        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against grep-cli 0.1.12 — the sibling entry
3000        // points to `::build` above, none of which end in that suffix:
3001        //
3002        // `CommandReader::new` (process.rs:195) is `CommandReaderBuilder::new().build(cmd)` — the same
3003        // spawn, reached through the type's own one-call constructor instead of the builder.
3004        // `CommandReader::close` (process.rs:218) drops the child's stdout handle then calls
3005        // `self.child.wait()` — `std::process::Child::wait` is ALREADY classified `Exec` elsewhere in
3006        // this file (reaping/blocking on a spawned child is part of the subprocess lifecycle this
3007        // project tracks), and self-scan resolves `self.child`'s concrete `process::Child` type to
3008        // confirm it, not a guess.
3009        // `DecompressionMatcher::command` (decompress.rs:179) builds a REAL `Command::new(&decomp_cmd.bin)`
3010        // naming an actual decompressor binary (gzip/xz/bzip2/…) and returns it un-spawned — the same
3011        // "constructs a real argv, caller spawns it" shape `Cred::credential_helper` was classified for
3012        // in git2 (see the FQN-exact rule near this file's top).
3013        // `DecompressionReader::new` (decompress.rs:352) is `DecompressionReaderBuilder::new().build(path)`,
3014        // which calls the already-covered `CommandReaderBuilder::build` two hops down.
3015        // `patterns_from_path` (pattern.rs:82) opens and reads the given file directly (`std::fs::File::
3016        // open`) — unrelated to the Exec family above, genuinely Fs.
3017        // SOUNDNESS R342 — `DecompressionReader::close` was the sibling this enumeration missed. It is
3018        // `match self.rdr { Ok(ref mut rdr) => rdr.close(), Err(_) => Ok(()) }` (`decompress.rs:383`),
3019        // i.e. the already-listed `CommandReader::close` one level up, so it reaps the same child. Its
3020        // `CommandReader` counterpart was listed and it was not — measured on a consumer fixture:
3021        // `CommandReader::close` charged `Exec` and `DecompressionReader::close` read ABSENT.
3022        //
3023        // The `read` pair is listed for the same reason one hop over: reading a `DecompressionReader`
3024        // or a `CommandReader` pulls bytes from the child's stdout, which is the point of having
3025        // spawned it. FQN-exact rather than a `::read`/`::close` suffix, because both are far too
3026        // common a leaf to match crate-wide — the R321 lesson.
3027        //
3028        // Found because R185 taught the scanner to see through `match self.rdr { Ok(..) }`, which made
3029        // grep-cli's own self-scan nominate it. The rule is what a CONSUMER needs; the self-scan only
3030        // pointed at it.
3031        if path == "grep_cli::CommandReader::new"
3032            || path == "grep_cli::CommandReader::close"
3033            || path == "grep_cli::CommandReader::read"
3034            || path == "grep_cli::DecompressionMatcher::command"
3035            || path == "grep_cli::DecompressionReader::new"
3036            || path == "grep_cli::DecompressionReader::close"
3037            || path == "grep_cli::DecompressionReader::read"
3038            || path == "grep_cli::decompress::DecompressionReader::close"
3039            || path == "grep_cli::decompress::DecompressionReader::read"
3040            || path == "grep_cli::process::CommandReader::close"
3041            || path == "grep_cli::process::CommandReader::read"
3042        {
3043            return Some("Exec");
3044        }
3045        if path == "grep_cli::patterns_from_path" {
3046            return Some("Fs");
3047        }
3048        return None;
3049    }
3050    // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
3051    // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
3052    // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
3053    // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
3054    if crate_name == "clircle" {
3055        if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
3056            return Some("Fs");
3057        }
3058        return None;
3059    }
3060    None
3061}
3062
3063pub fn cap_from_name(name: &str) -> Option<&'static str> {
3064    EFFECTS.iter().copied().find(|e| *e == name)
3065}
3066
3067/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
3068/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
3069/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
3070/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
3071/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
3072/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
3073/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
3074/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
3075/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
3076/// the one boundary every engine hits — refines identically (the §4-consistency argument).
3077/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its path implies.
3078/// `["read"]`, `["write"]`, `["read","write"]`, or `[]` when the verb does not say.
3079///
3080/// THE EMPTY CASE IS THE DISCIPLINE. §2: *"when `Fs` is reached but its kind is unknown … the field MUST
3081/// be omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
3082/// never writes'), which is the §4 trust contract's forbidden direction."* So an unrecognised verb
3083/// contributes nothing and the field stays absent; absence means "kind undetermined", never "read-only".
3084///
3085/// A syntactic refinement of an effect already proved, NOT a soundness claim. Deliberately the same
3086/// vocabulary as candor-java's `fsKind`, candor-swift's and candor-ts's — the surface is spec'd four-way,
3087/// and four engines inventing four verb tables for one field is how a shared field stops meaning one thing.
3088pub fn fs_kind(path: &str) -> &'static [&'static str] {
3089    // the terminal segment is the verb (`std::fs::write`, `File::create`, `f.read_to_string`)
3090    let leaf = path.rsplit("::").next().unwrap_or(path);
3091    // `OpenOptions::open` is the one verb that does NOT carry its own direction — the direction was set
3092    // by the BUILDER chain (`.read(true)`/`.write(true)`), which this function cannot see. The READ list
3093    // below holds a bare `open` for `File::open`, unambiguously a read; letting an `OpenOptions` receiver
3094    // reach it publishes `fs: ["read"]` for a builder configured `write(true)`, and §2 reads that as the
3095    // positive claim "reads but never writes" — the forbidden direction. The paragraph under the READ
3096    // list has always SAID this; nothing implemented it, and it only became REACHABLE when
3097    // `OpenOptions::open` started classifying on its own — before, the `Fs` came from the
3098    // `OpenOptions::new` constructor, whose leaf claims nothing, so the field was simply absent.
3099    // MEASURED on this commit's own fix before this guard: `OpenOptions::new().read(true).open(p)` went
3100    // from `fs` ABSENT to `fs: ["read"]`, i.e. the fix minted a claim the engine cannot support. Keyed on
3101    // the TYPE segment, so it holds for the `fs_err`/`tokio::fs`/`async_std::fs` spellings too.
3102    if path.rsplit("::").nth(1) == Some("OpenOptions") {
3103        return &[];
3104    }
3105    // Reads the source AND writes the destination in one call.
3106    if matches!(leaf, "copy" | "rename" | "hard_link" | "soft_link" | "symlink") {
3107        return &["read", "write"];
3108    }
3109    const WRITE: &[&str] = &[
3110        "write", "write_all", "write_fmt", "write_vectored", "create", "create_new", "create_dir",
3111        "create_dir_all", "remove_file", "remove_dir", "remove_dir_all", "set_permissions",
3112        "set_len", "set_modified", "set_times", "append", "flush", "sync_all", "sync_data",
3113        "write_at", "truncate",
3114    ];
3115    const READ: &[&str] = &[
3116        "read", "read_to_string", "read_to_end", "read_exact", "read_dir", "read_link",
3117        "metadata", "symlink_metadata", "exists", "try_exists", "canonicalize", "open",
3118        "read_at", "file_type", "permissions", "modified", "accessed", "created", "len",
3119    ];
3120    if WRITE.contains(&leaf) { return &["write"]; }
3121    if READ.contains(&leaf) { return &["read"]; }
3122    // `OpenOptions` carries the direction in its BUILDER chain, not its terminal verb, so `.open()` on one
3123    // says nothing here and is deliberately left to the READ arm above only when it is `File::open`
3124    // (unambiguously a read). Anything else: no claim.
3125    if leaf.starts_with("write") || leaf.starts_with("append") { return &["write"]; }
3126    if leaf.starts_with("read") { return &["read"]; }
3127    &[]
3128}
3129
3130pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
3131    // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
3132    // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
3133    // the under-report rule forbids it, so such heads keep the bare cliff.
3134    match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
3135        "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
3136        "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
3137        // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
3138        "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
3139        | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
3140        _ => &[],
3141    }
3142}
3143
3144/// Known machine-learning MODEL-provider hosts — the SPEC §1 ⟨0.13⟩ `Llm` host-literal refinement:
3145/// a statically-known `Net` request to one of these classifies `Llm` IN ADDITION to `Net` (Net is
3146/// never dropped — a model call IS network I/O, exactly as an `Exec`-refined subprocess keeps `Exec`),
3147/// just as a jdbc URL classifies `Db`. Matched by host, case-insensitive; a SUBDOMAIN of a listed host
3148/// counts. The reference engines share this table VERBATIM with candor-java's `Literals.MODEL_HOSTS`
3149/// (the analog of `classify_command_head`) so the `Net` boundary refines to `Llm` identically. An
3150/// UNKNOWN host stays bare `Net` — never guessed. Curated STARTER set; the §7 coverage ledger
3151/// discloses an uncovered provider like any other.
3152pub const MODEL_HOSTS: &[&str] = &[
3153    "api.openai.com",
3154    "api.anthropic.com",
3155    "generativelanguage.googleapis.com",
3156    "api.mistral.ai",
3157    "api.cohere.ai",
3158    "api.cohere.com",
3159    "api.groq.com",
3160    "api.together.xyz",
3161    "api.perplexity.ai",
3162    "openrouter.ai",
3163];
3164
3165/// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
3166/// `MODEL_HOSTS` entry counts). Strips a `:port` suffix first. Two special forms carry their own rule,
3167/// matching candor-java's `Literals.isModelHost` exactly: any host whose port is `11434` is a local
3168/// Ollama endpoint (a LOOPBACK host — `localhost`/`127.0.0.1`/`::1` — on port 11434); and an AWS Bedrock
3169/// runtime host (the model-inference service label `bedrock-runtime`/`bedrock-agent-runtime`).
3170pub fn is_model_host(host_literal: &str) -> bool {
3171    // Strip any `:port` (via the shared host_part) and lowercase for the name comparisons.
3172    let host = policy::host_part(host_literal).to_ascii_lowercase();
3173    // Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any
3174    // host on :11434" fabricated Llm on unrelated internal services on that port).
3175    if let Some((_, port)) = host_literal.rsplit_once(':') {
3176        if port == "11434" {
3177            return matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
3178        }
3179    }
3180    if MODEL_HOSTS.contains(&host.as_str()) {
3181        return true;
3182    }
3183    // A subdomain of a known model host counts (`eu.api.openai.com` → api.openai.com).
3184    if MODEL_HOSTS.iter().any(|m| host.ends_with(&format!(".{m}"))) {
3185        return true;
3186    }
3187    // AWS Bedrock runtime: the FIRST label is the model-inference service (`bedrock-runtime.<region>.
3188    // amazonaws.com`), NOT the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an
3189    // S3 bucket) and NOT the control-plane `bedrock.<region>.amazonaws.com`.
3190    host.ends_with(".amazonaws.com")
3191        && matches!(host.split('.').next(), Some("bedrock-runtime") | Some("bedrock-agent-runtime"))
3192}
3193
3194/// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
3195/// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with candor-java's `Literals.TELEMETRY_HOSTS` (like
3196/// `MODEL_HOSTS`). A benign observability endpoint. Matched by host, case-insensitive; a SUBDOMAIN of a
3197/// listed host counts. Tight, high-precision STARTER set — mis-including an exfil-capable host would
3198/// under-gate `deny Net[unknown-host]`.
3199pub const TELEMETRY_HOSTS: &[&str] = &[
3200    "sentry.io",
3201    "bugsnag.com",
3202    "rollbar.com",
3203    "segment.io",
3204    "segment.com",
3205    "mixpanel.com",
3206    "amplitude.com",
3207    "google-analytics.com",
3208    "analytics.google.com",
3209    "datadoghq.com",
3210    "datadoghq.eu",
3211    "newrelic.com",
3212    "nr-data.net",
3213    "honeycomb.io",
3214    "logtail.com",
3215    // ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
3216    // providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
3217    "posthog.com",
3218    "plausible.io",
3219    "usefathom.com",
3220    "heapanalytics.com",
3221    "fullstory.com",
3222    "hotjar.com",
3223    "logrocket.com",
3224    "cloudflareinsights.com",
3225];
3226
3227/// Whether an endpoint HOST literal is in `set` (case-insensitive; a subdomain of a listed host counts).
3228/// Strips a `:port` suffix first via `host_part`. The shared membership test for `TELEMETRY_HOSTS` and the
3229/// config-declared partner set (mirrors candor-java's `Literals.hostInSet`).
3230pub fn host_in_set(host_literal: &str, set: &[&str]) -> bool {
3231    let host = policy::host_part(host_literal).to_ascii_lowercase();
3232    set.contains(&host.as_str()) || set.iter().any(|e| host.ends_with(&format!(".{e}")))
3233}
3234
3235/// Whether an endpoint HOST literal is a known telemetry/analytics/APM host (`TELEMETRY_HOSTS`).
3236pub fn is_telemetry_host(host_literal: &str) -> bool {
3237    host_in_set(host_literal, TELEMETRY_HOSTS)
3238}
3239
3240/// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
3241/// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
3242/// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). A partner set
3243/// is per-project (config-declared). Never fabricated onto a safe class: an unresolved host is unknown-host.
3244/// Mirrors candor-java's `Literals.netDestClass`.
3245pub fn net_dest_class(host_literal: &str, partners: &std::collections::BTreeSet<String>) -> &'static str {
3246    if is_telemetry_host(host_literal) {
3247        return "known-telemetry";
3248    }
3249    if partner_for(host_literal, partners).is_some() || is_model_host(host_literal) {
3250        return "known-partner";
3251    }
3252    "unknown-host"
3253}
3254
3255/// ⟨0.31⟩ WHICH declared partner a host matched, or `None` — the SAME match `net_dest_class` decides on,
3256/// extracted so the DISCLOSURE and the DECISION cannot use different rules.
3257///
3258/// That is not a stylistic preference. The reverted first attempt at the `net-partner` disclosure
3259/// re-implemented this match against a normaliser that KEEPS the port, so an observed
3260/// `partner.example:443` never equalled a declared `partner.example` and the disclosure came back
3261/// SILENTLY EMPTY on every real run — while the verdicts it was reporting on had flipped. A disclosure
3262/// normalised differently from the decision it reports can only be wrong, and one function with two
3263/// callers is what makes writing one impossible.
3264pub fn partner_for(
3265    host_literal: &str,
3266    partners: &std::collections::BTreeSet<String>,
3267) -> Option<String> {
3268    if partners.is_empty() {
3269        return None;
3270    }
3271    let host = policy::host_part(host_literal).to_ascii_lowercase();
3272    if partners.contains(&host) {
3273        return Some(host);
3274    }
3275    partners
3276        .iter()
3277        .find(|p| host.ends_with(&format!(".{p}")))
3278        .cloned()
3279}
3280
3281/// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
3282pub const NET_DEST_CLASSES: &[&str] = &["known-telemetry", "known-partner", "unknown-host"];
3283
3284/// Curated Rust model-provider SDK crates — the SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface, the Rust analog
3285/// of candor-java's `Rules.MODEL_SDK_PACKAGES`. A resolved call into one of these crates classifies
3286/// `Llm` + `Net` (the caller adds both — a model dispatch IS network I/O). NO method-name gating: these
3287/// are single-purpose provider clients, so ANY call into the crate is a model dispatch (matches the java
3288/// reference's judgment call). Curated STARTER list; the §7 coverage ledger discloses the rest.
3289pub const MODEL_SDK_CRATES: &[&str] = &[
3290    "async_openai",           // async-openai — the de-facto OpenAI client
3291    "anthropic_sdk",          // anthropic-sdk
3292    "anthropic",              // anthropic (community client crate)
3293    "aws_sdk_bedrockruntime", // AWS Bedrock runtime (invoke/converse) — the model surface of the aws-sdk family
3294    "ollama_rs",              // ollama-rs — local Ollama client
3295    "langchain_rust",         // langchain-rust — the LangChain invoke surfaces
3296    "mistralai",              // mistralai (Mistral client)
3297    "genai",                  // genai — a multi-provider model client
3298];
3299
3300/// Whether a resolved call's CRATE is a curated model-provider SDK (`MODEL_SDK_CRATES`) → the SPEC §1
3301/// ⟨0.13⟩ `Llm` model-SDK classification (the caller adds both `Llm` and `Net`). Crate-level, no
3302/// method gating — a single-purpose client, matching candor-java's `isModelSdkOwner`.
3303pub fn is_model_sdk_crate(crate_name: &str) -> bool {
3304    MODEL_SDK_CRATES.contains(&crate_name)
3305}
3306
3307/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
3308/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
3309/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
3310/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
3311/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
3312/// method is the call path's last segment.
3313pub fn is_cmd_builder_method(method: &str) -> bool {
3314    matches!(
3315        method,
3316        "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
3317            | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
3318            | "groups" | "process_group"
3319    )
3320}
3321
3322/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
3323/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
3324/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
3325/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
3326/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
3327/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
3328/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
3329/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
3330/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
3331pub fn is_cmd_naming_method(method: &str) -> bool {
3332    matches!(method, "new" | "cmd")
3333}
3334
3335/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
3336/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
3337/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
3338/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
3339/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
3340/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
3341/// host was fixed at `connect`) never false-positives. **THAT LAST CLAIM WAS FALSE AND IS WITHDRAWN —
3342/// see R379 in the body: under-catching an establishing verb IS a broken gate whenever a benign sibling
3343/// literal sits in the same function, which is the exact scenario the first sentence names.** The list is
3344/// now an ALLOWLIST still — see the R379 body below: the denylist inversion was BUILT, A/B'd over
3345/// 1,545 crates and REFUSED (544 rows over-masked). An earlier draft of this line said "now a
3346/// DENYLIST", which was a leftover from the rejected cut and asserted the opposite of the code it
3347/// sits on — the comment-asserting-safety shape this register records. The arg is the method (path's
3348/// last segment).
3349pub fn is_net_establishing(method: &str) -> bool {
3350    // SOUNDNESS R379 — the DNS RESOLVER verbs were missing, and their absence was a GATE BYPASS.
3351    //
3352    // `classify` (see the `hickory_resolver` arm) already maps `::lookup_ip`, `::lookup` and any
3353    // `*_lookup` to `Net`, and asserts it. This list did not, so a `Net` call whose host is a runtime
3354    // value was never marked `incomplete` and a benign sibling literal certified it. Measured against
3355    // real hickory-resolver 0.26.2, on a fixture that builds clean:
3356    //
3357    //     pub async fn masked(r: &Resolver<TokioRuntimeProvider>, host: &str) {
3358    //         let _ = r.lookup_ip("good.example.com").await;   // the benign literal
3359    //         let _ = r.lookup_ip(host).await;                 // the runtime target
3360    //     }
3361    //
3362    // reported `hosts:['good.example.com'] incomplete:NONE`, and `allow Net good.example.com` exited
3363    // **0** over a DNS resolution of a caller-controlled hostname. `TcpStream::connect(host)` — one
3364    // verb over, same fixture — marked `incomplete:['Net']` and failed closed. The mechanism was
3365    // sound; only the enumeration was short.
3366    //
3367    // THE DOC ABOVE USED TO CLAIM under-catching here is "a missed mask (sound-with-disclosure), never
3368    // a broken gate". That is false and is withdrawn: it is a broken gate whenever a benign sibling
3369    // literal shares the function, which is the scenario the guard's own first sentence names.
3370    //
3371    // **AND IT STAYS AN ALLOWLIST, WHICH IS NOT THE FAMILY'S USUAL DIRECTION — the exception is
3372    // measured and it is the same one `is_cmd_naming_method` states two doc-blocks up.** Inverting to
3373    // a denylist of use-verbs was built and A/B'd over all 1,545 registry crates: **544 rows gained
3374    // `incomplete` and none lost it**, because a WHOLE-CRATE-Net crate classifies EVERY method as Net,
3375    // so a denylist masks essentially everything in one — `cap-std` 66 rows, `hickory-net` 44. The
3376    // triggers it added were conversions, accessors and combinators (`into` 24, `from_std` 20,
3377    // `as_socket` 15, `setsockopt` 32, `map_err` 7, `ok` 5, `try_clone` 4) — none of which hides a
3378    // host. Worse, it masked **`bind` (31)**, which this very file has `is_net_local_bind` to exclude
3379    // because a bind address is where the process LISTENS, never a destination it reaches. So here the
3380    // allowlist is the precise side and the denylist is the leaky one, exactly as recorded for Exec.
3381    //
3382    // The resolver family is written out rather than the one spelling in hand (R346): `_lookup`
3383    // catches `reverse_lookup`/`srv_lookup`/`mx_lookup`/`txt_lookup`/`ipv4_lookup`/`ipv6_lookup` and
3384    // the rest of the record-type verbs in one test, mirroring `classify`'s own `ends_with("_lookup")`.
3385    method.ends_with("_lookup")
3386        || matches!(
3387            method,
3388            "connect"
3389                | "connect_timeout"
3390                | "get"
3391                | "post"
3392                | "put"
3393                | "patch"
3394                | "delete"
3395                | "head"
3396                | "request"
3397                | "send_to"
3398                | "lookup_host"
3399                | "to_socket_addrs"
3400                // R379 — the DNS resolver verbs `classify` already calls Net.
3401                | "lookup"
3402                | "lookup_ip"
3403                | "resolve"
3404        )
3405}
3406
3407/// ⟨0.29⟩ A LOCAL BIND/LISTEN VERB — the address it names is where the process LISTENS, never a
3408/// destination it reaches.
3409///
3410/// **MEASURED, and it is a false all-clear rather than a naming quibble.** `UdpSocket::bind("0.0.0.0:0")`
3411/// put `0.0.0.0:0` into `hosts`, the DESTINATION surface `allow Net` gates on (§2), and — because a
3412/// literal had been captured — nothing marked the surface incomplete. So:
3413///
3414/// ```text
3415/// let s = UdpSocket::bind("0.0.0.0:0")?;   // local
3416/// s.send_to(b"secrets", dst);              // destination is a RUNTIME value
3417/// allow Net 0.0.0.0   ->   policy ✓, exit 0
3418/// ```
3419///
3420/// A local listen address certified a send to an endpoint nobody can see. That is the masking evasion
3421/// AS-EFF-008 exists to close, reached through a verb whose literal is not a destination at all.
3422///
3423/// **A BIND CANNOT BE CERTIFIED, EVER**, which is why this marks the surface incomplete rather than
3424/// merely withholding the literal: a server that binds and accepts talks to whoever connects, so its
3425/// destination set is not statically knowable even in principle. candor-java already behaves this way —
3426/// it publishes the bind address AND hedges — and matching the reference engine keeps the informative
3427/// half (an operator can still see what the service listens on) while making it non-certifying.
3428/// ⟨0.29⟩ The Net verbs whose LOCATOR is at argument **1**, not 0 — the rust analogue of candor-ts's
3429/// `NET_URL_ARG1_MEMBERS`.
3430///
3431/// **A REGRESSION FOUND IN REVIEW, and the direction matters.** The positional-literal rung replaced
3432/// `first_str_lit` ("the first string literal ANYWHERE in the call") with `positional_str_lit(args, 0)`
3433/// as the UNIVERSAL default. That is right for `Fs`/`Db`/`Exec`, whose locator is always argument 0 —
3434/// but `is_net_establishing` already listed two verbs whose locator is not: `reqwest::Client::request`
3435/// takes `(Method, url)` and `UdpSocket::send_to` takes `(buf, addr)`. MEASURED after the swap:
3436/// `c.request(Method::GET, "https://api.example.com/v1")` published NO `hosts` at all and could not be
3437/// certified, while `c.get("https://api.example.com/v1")` certified normally.
3438///
3439/// ⟨0.29⟩ MEASURED FOR `request` ONLY. `send_to` is a METHOD on a receiver the STABLE syntactic backend
3440/// does not type, so `s.send_to(buf, "203.0.113.9:53")` is not classified at all there and its arg-1
3441/// literal is still uncaptured on the floor; the position is right and the deep engine can use it. Said
3442/// plainly because the first version of this fix's changelog listed both verbs as measured.
3443///
3444/// The direction is SAFE — an uncaptured locator fails closed, it never certifies something invisible —
3445/// which is exactly why it needed a review to find: the gate stayed sound and quietly stopped being
3446/// USABLE for a common shape. candor-ts avoided this by making its resolver verb-aware in the same rung;
3447/// this is that discipline arriving one engine late.
3448pub fn is_net_host_arg1(method: &str) -> bool {
3449    matches!(method, "request" | "send_to")
3450}
3451
3452pub fn is_net_binding(method: &str) -> bool {
3453    matches!(method, "bind" | "listen" | "bind_to_device" | "incoming" | "accept")
3454}
3455
3456/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
3457/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
3458/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
3459/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
3460/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
3461/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
3462/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
3463/// is not here, so a missing literal there never false-positives. Under-catching an unusual
3464/// path-naming fn is a missed mask (sound-with-disclosure), ~~never a broken gate~~ — **AND THAT LAST
3465/// CLAUSE IS FALSE. SOUNDNESS R399.** R379 withdrew this exact sentence from `is_net_establishing` after
3466/// MEASURING it wrong, and R383 and R386 are counter-examples on this guard and its Db sibling: an
3467/// unlisted path-taking call contributes no locator, nothing marks the surface incomplete, and a benign
3468/// sibling literal then CERTIFIES the invisible destination — `allow Fs /etc/hostname` exit 0 over
3469/// `unixfs::chown(caller, …)`, with the control (`OpenOptions::open(caller)`) exiting 1. The sentence
3470/// survived here only because nobody re-read it after R379, and it is what licenses leaving the list
3471/// short. Under-catching is a BROKEN GATE, not a missed mask. The arg is the
3472/// method/fn leaf (the path's last segment).
3473pub fn is_fs_path_arg(leaf: &str) -> bool {
3474    matches!(
3475        leaf,
3476        // SOUNDNESS R399 — the POSIX ownership/root verbs take a path as argument 0 and were absent, so a
3477        // benign sibling literal certified them: `allow Fs /etc/hostname` exit 0 over
3478        // `unixfs::chown(caller, …)`, control `OpenOptions::open(caller)` exit 1. `std::os::unix::fs`.
3479        "chown"
3480            | "lchown"
3481            | "chroot"
3482        // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
3483            | "write"
3484            | "read"
3485            | "read_to_string"
3486            | "read_dir"
3487            | "read_link"
3488            | "copy"
3489            | "rename"
3490            | "remove_file"
3491            | "remove_dir"
3492            | "remove_dir_all"
3493            | "create_dir"
3494            | "create_dir_all"
3495            | "hard_link"
3496            | "soft_link"
3497            | "symlink"
3498            | "symlink_file"
3499            | "symlink_dir"
3500            | "symlink_metadata"
3501            | "canonicalize"
3502            | "metadata"
3503            | "set_permissions"
3504            | "exists"
3505            | "try_exists"
3506            // File / OpenOptions constructors taking a path argument
3507            | "open"
3508            | "create"
3509            | "create_new"
3510    )
3511}
3512
3513/// SOUNDNESS R334 — IS THIS PATH SEGMENT THE OS ENTROPY SOURCE ITSELF (as a VALUE, not a verb)?
3514///
3515/// **THE DEFECT THIS EXISTS FOR.** Every entropy rule in the `rand` block keys on the CALLEE — a verb
3516/// (`from_os_rng`, `next_u32`, `fill_bytes`) or the source type in RECEIVER position
3517/// (`contains("OsRng")`/`contains("SysRng")`, R320). The idiomatic way to seed a userspace CSPRNG from
3518/// the OS puts the source in ARGUMENT position, where neither key can see it. Executed, one fixture,
3519/// four rows:
3520///
3521/// ```text
3522/// StdRng::try_from_rng(&mut SysRng)   ABSENT   quinn-proto-0.11.17 endpoint.rs:81
3523/// StdRng::from_rng(&mut OsRng)        ABSENT   the same hole in the OLD spelling
3524/// StdRng::from_seed([0u8; 32])        ABSENT   correct — deterministic, and the control
3525/// StdRng::from_os_rng()               ['Rand'] the verb form
3526/// ```
3527///
3528/// Both spellings, so this is NOT a rename residual and predates R320 — whose "stale `OsRng` clause"
3529/// framing would have sent the audit straight past it.
3530///
3531/// **WHY THE ARGUMENT AND NOT THE VERB.** `from_rng`/`try_from_rng` cannot simply be charged: their
3532/// effect is decided by what is passed to them, `from_rng(&mut pcg)` is deterministic, and there are
3533/// 802 `from_seed`/`seed_from_u64` call sites in this machine's registry cache — the deterministic
3534/// population is the larger one. Charging the verb is R330's mistake one crate over. The discriminator
3535/// is the ARGUMENT, so that is what is read.
3536///
3537/// **THE OVER-APPROXIMATION IS DELIBERATE AND STATED.** Naming one of these types as a VALUE is charged
3538/// wherever it appears in an argument list, including moves that do not themselves draw (`Some(OsRng)`,
3539/// `Box::new(SysRng)`). That is sound and it is also the ONLY place candor can see it: a source handed
3540/// off into a struct field is drawn from in some later function whose own call list never mentions it.
3541/// A user type coincidentally named `OsRng` would be charged too — an over-report, the safe direction,
3542/// and cheaper than the silence it replaces.
3543pub fn is_entropy_source_ident(seg: &str) -> bool {
3544    matches!(seg, "OsRng" | "SysRng")
3545}
3546
3547/// SOUNDNESS R338 — DOES THIS CALL ALSO READ THE SYSTEM TRUST STORE? A SECOND effect, not a
3548/// replacement for the one `classify` returns.
3549///
3550/// **THE DEFECT.** candor charges `native_tls::TlsConnector::new` `Fs` because on the openssl backend
3551/// it loads the probed cert file/dir from disk. Several calibrated crates construct that connector
3552/// INSIDE a public entry point, so the consumer's call discloses the transport effect and drops the
3553/// `Fs` — `tungstenite::client_tls` reports `['Net']`, and `deny Fs` passes over a real trust-store
3554/// read. `wrap_stream` does `tls_connector.map_or_else(TlsConnector::new, Ok)`
3555/// (`tungstenite-0.30.0/src/tls.rs:54`), so a caller supplying no connector gets `TlsConnector::new()`.
3556/// (The row the coverage gate nominated — `tungstenite::tls::encryption::native_tls::wrap_stream` — is
3557/// unreachable from outside: `mod tls;` is private at `lib.rs:32`. The reachable form is one level up.)
3558///
3559/// **WHY THIS IS SO SHORT: the sweep found four more crates and every one was PRICED AND REJECTED,
3560/// which is the deliverable rather than a gap.** Callers of `native_tls::TlsConnector::new` in the
3561/// local registry are redis, tungstenite, ureq, minreq and tokio-websockets.
3562///   - `minreq`, `tokio_websockets` — NOT calibrated, so their calls already disclose rather than
3563///     claim purity. No rule can improve them.
3564///   - `redis` — `ActualConnection::new` (`redis-1.6.0/src/connection.rs:932`) constructs it only for a
3565///     `rediss://` address, and the consumer-visible verb is `Client::get_connection`. Charging that
3566///     `Fs` would fabricate on every PLAIN redis user, which is the majority. Rejected on the trade.
3567///   - `ureq` — `agent.rs:265` takes `crate::default_tls_config()`, and ureq's default features are
3568///     `["tls", "gzip"]`: `native-certs` is OFF, so the default build resolves to
3569///     `rtls::root_certs()`'s `#[cfg(not(feature = "native-certs"))]` arm, which is
3570///     `webpki_roots::TLS_SERVER_ROOTS` — COMPILED IN, no disk read (`ureq-2.12.1/src/rtls.rs:82`).
3571///     Charging it would fabricate on the default build. Rejected on the evidence.
3572///
3573/// What is left is tungstenite's two explicitly-TLS-named entry points, where no caller is
3574/// over-charged: `client_tls` passes `None` for the connector unconditionally, and
3575/// `client_tls_with_config`'s callers are by definition doing TLS. **`tungstenite::connect` is a stated
3576/// residual, not an oversight** — it serves `ws://` and `wss://` alike, so charging it would hit real
3577/// plain-WebSocket users; the scheme is often a string literal candor already captures in `str_arg`,
3578/// which is the shape a future fix should use rather than a blanket charge.
3579pub fn reads_trust_store(crate_name: &str, path: &str) -> bool {
3580    crate_name == "tungstenite"
3581        && (path.ends_with("::client_tls") || path.ends_with("::client_tls_with_config"))
3582}
3583
3584/// SOUNDNESS R330 — IS THIS `hash_password` CALL PROVABLY THE EXPLICIT-SALT ONE (password-hash 0.5.x)?
3585///
3586/// **THE DEFECT THIS EXISTS FOR, and it was mine, introduced by R318's fix the previous day.** R318
3587/// correctly found that `hash_password` mints OS entropy — at password-hash **0.6.x**, where
3588/// `fn hash_password(&self, password)` (`lib.rs:107`) has the body `let salt = try_generate_salt()?;`.
3589/// At **0.5.0** (`traits.rs:33`) and argon2 **0.5.3** (`lib.rs:572`) the identical name is
3590/// `fn hash_password(&self, password, salt)`: the caller supplies the salt and the call is deterministic
3591/// recomputation — PURE. So the `ends_with`-not-`contains` guard R318 relied on was keyed on the 0.6.x
3592/// name, and at 0.5.x the explicit-salt call IS named `hash_password`. Measured: two calls with the same
3593/// stored salt produce byte-identical hashes, and all four 0.5.x spellings were charged `Rand`.
3594///
3595/// **Why arity and not the resolved version.** R330's remedy offered both. The version is only knowable
3596/// where a lockfile resolves, and `calibration_exceeded` is ONE-SIDED — it fires when resolved > ceiling,
3597/// so it can never see a rule that mis-describes an OLDER release, which is exactly this direction. The
3598/// arity is written at the call site, needs no lockfile, and distinguishes the two signatures exactly.
3599///
3600/// **The direction is stated so it cannot drift:** this returns true only for the shape it can PROVE is
3601/// explicit-salt. An unrecorded arity (`argc == 0`, every synthesized edge and every pre-R330 cache
3602/// entry) returns false and the call stays charged. Narrowing a sound over-approximation by DENYLIST,
3603/// never by allowlist — the family rule.
3604pub fn password_hash_explicit_salt(crate_name: &str, path: &str, argc: u8, method: bool) -> bool {
3605    if !matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash")
3606        || !path.ends_with("::hash_password")
3607    {
3608        return false;
3609    }
3610    if argc == 0 {
3611        return false; // NOT RECORDED — see `Call::argc`. Unknown charges.
3612    }
3613    // `syn` hands a method call its args WITHOUT the receiver and a UFCS `Trait::method(&recv, ..)`
3614    // WITH it, so normalize to the non-receiver count before comparing: 0.5.x is `(password, salt)` = 2,
3615    // 0.6.x is `(password)` = 1.
3616    let non_receiver = if method { argc } else { argc - 1 };
3617    let hit = non_receiver == 2;
3618    // §E1 HIT COUNTER, the `CANDOR_ALIAS_DEBUG` pattern. A refinement that narrows a charge must be
3619    // able to prove it REACHED real code, because "nothing moved" and "the branch never ran" print the
3620    // same corpus A/B. This is what `bin/corpus-ab.py --mark` counts.
3621    if hit && std::env::var("CANDOR_ALIAS_DEBUG").is_ok() {
3622        eprintln!("R330EXPLICITSALT {path} argc={argc} method={method}");
3623    }
3624    hit
3625}
3626
3627/// ⟨0.29⟩ HOW MANY LEADING ARGUMENTS OF AN `Fs` PATH-TAKING CALL ARE PATHS.
3628///
3629/// **THE DEFECT THIS EXISTS FOR.** The literal harvester took the first string literal found ANYWHERE in
3630/// the argument list, so `fs::write(user_path, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the BYTES
3631/// BEING WRITTEN — as the destination surface, and `allow Fs /tmp/lit` certified a write to an
3632/// attacker-controlled path at exit 0. Measured on candor-scan and candor-ts; candor-java and
3633/// candor-swift read the path POSITION and fail closed correctly. Removing the sibling literal made the
3634/// same call fail closed, which is what identified the mechanism: any literal in the call, in any
3635/// position, defeated the runtime-path incompleteness marker.
3636///
3637/// So the surface is read from the PATH POSITIONS and nowhere else, and every one of them must be a
3638/// literal for the surface to be complete. Two-path operations are the reason this is an arity and not a
3639/// boolean: `fs::copy("/safe", user_path)` has a literal at position 0 and still writes somewhere
3640/// nobody can see, so requiring only position 0 would leave the identical hole one argument along.
3641pub fn fs_path_arity(leaf: &str) -> usize {
3642    match leaf {
3643        "copy" | "rename" | "hard_link" | "soft_link" | "symlink" | "symlink_file" | "symlink_dir" => 2,
3644        _ => 1,
3645    }
3646}
3647
3648/// SOUNDNESS R383 — THE METHOD FORMS WHOSE PATH IS AN ARGUMENT, not the receiver.
3649///
3650/// `scan.rs`'s Fs masking arm reads `!c.method && is_fs_path_arg(leaf)`. The `!c.method` gate exists for
3651/// a real reason — the path-STAT methods (`p.metadata()`, `p.exists()`) carry the path as the RECEIVER,
3652/// so masking them would be wrong — but it excluded EVERY method form, and `OpenOptions::open(path)` is
3653/// a method whose path IS the argument. Measured, with the instrument calibrated first: a function doing
3654/// `File::open("/etc/hostname")` beside `OpenOptions::new().read(true).open(caller_path)` reported
3655/// `paths:['/etc/hostname'] incomplete:NONE`; `deny Fs` exited 1 and **`allow Fs /etc/hostname` exited
3656/// 0** — a policy saying "only /etc/hostname" going green over a caller-supplied path. The FREE-fn
3657/// spelling of the same call is the isolating control: it marks incomplete and fails closed.
3658///
3659/// **`argc` does not discriminate; the RECEIVER TYPE does.** Instrumented, the four shapes are
3660/// `OpenOptions::open` (argc 1, path IS the arg), `File::read_to_string` and `File::write` (argc 1,
3661/// BUFFER arg) and `Path::exists` (argc 0, path is the receiver).
3662///
3663/// **The family is from a 1,545-crate sweep, not from the one spelling that was found (R346):** the only
3664/// path-taking METHOD pairs in the registry are `OpenOptions::open` (69 std + 2 tokio + 9
3665/// `cap_primitives`) and `DirBuilder::create` (5 std + tokio). Every other method on those same receivers
3666/// is a builder modifier (`read`/`write`/`truncate`/`mode`) or a combinator (`map`/`map_err`/`or_else`/
3667/// `with_context`), and `std::fs::File`'s 267 method hits are all use-verbs whose path was fixed at open.
3668///
3669/// Matched on the TAIL of the resolved call path, so std, tokio, `cap_primitives` and `fs_err` are all
3670/// covered by one rule without naming a crate. **Stays an ALLOWLIST:** R379 built the denylist inversion
3671/// for the Net twin and the corpus refused it, and here a denylist over method forms would mask all 267
3672/// `File` use-verbs and every combinator on a whole-crate-Fs receiver.
3673///
3674/// **SOUNDNESS R417 — THE CAPABILITY-`Dir` FAMILY WAS ABSENT, AND IT IS A LIVE GATE BYPASS.**
3675/// `cap_std::fs::Dir` (and its `fs_utf8`/`cap_async_std` twins, and `openat::Dir`) is a whole second
3676/// filesystem API whose path is an ARGUMENT to a METHOD on a directory handle — `d.write(caller, …)`,
3677/// `d.open(caller)`, `d.remove_file(caller)`. MEASURED, with the free-fn spelling as the isolating
3678/// control in the same fixture:
3679///
3680/// ```text
3681/// control(caller)  std::fs::write("/tmp/benign",…); std::fs::write(caller,…)
3682///                  → paths:['/tmp/benign'] incomplete:['Fs']   allow Fs /tmp/benign → exit 1  ✔
3683/// mixed(d,caller)  std::fs::write("/tmp/benign",…); d.write(caller,…)
3684///                  → paths:['/tmp/benign'] incomplete:NONE     allow Fs /tmp/benign → exit 0  ✘
3685/// ```
3686///
3687/// The call path resolves in full (`cap_std::fs::Dir::write`), so this is not a resolution gap — the
3688/// guard simply had two names in it. `alone(d,caller)` confirms the call IS classified `Fs`: the
3689/// effect was seen, the locator was not, and nothing said so.
3690///
3691/// **WHY THIS HALF IS A DENYLIST WHILE THE REST OF THE GUARD IS AN ALLOWLIST**, which is not a
3692/// contradiction but the point: the allowlist above exists because the receiver is UNKNOWN, so masking
3693/// by default would swallow `File`'s 267 use-verbs. Here the receiver is KNOWN to be a directory
3694/// capability, and on that receiver essentially every method names a path — 24 of the 49 declared on
3695/// `cap_std::fs::Dir` are already `is_fs_path_arg` leaves, and most of the remaining 25 (`open_with`,
3696/// `open_dir`, `is_file`, `is_dir`, `symlink_contents`, `read_link_contents`, `create_dir_with`,
3697/// the `bind_unix_*`/`connect_unix_*` socket-path verbs) take one too. An allowlist here would be the
3698/// R346→R348 hand-list vein by construction: every name I failed to write down is a silent bypass, and
3699/// R399 settled that an under-catch on this guard is a BROKEN GATE, not a missed mask.
3700///
3701/// So the default on a `Dir` receiver is MASK, and the exceptions are the handful of methods that
3702/// provably take no path: handle-to-handle conversions and the iterator/`self`-only verbs. Being wrong
3703/// about one of those costs an over-mask on a rare API (fails CLOSED, disclosed); being wrong the other
3704/// way is the exit-0 above. [[candor-denylist-over-allowlist]] — say which direction it fails in first.
3705///
3706/// Scoped on the RECEIVER SEGMENT being exactly `Dir`, so `cap_std`, `cap_std::fs_utf8`,
3707/// `cap_async_std` and `openat` are covered by one rule without naming a crate — the same tail-matching
3708/// discipline as the two names above. A non-filesystem type that happens to be called `Dir` would be
3709/// over-masked, which fails closed.
3710/// SOUNDNESS R414 / SPEC ⟨0.37⟩ — **a RECEIVER-form path stat names its destination, and every engine
3711/// in this family was silent on it.** `p.exists()`, `p.metadata()`, `p.canonicalize()` reach the
3712/// filesystem against the path they are invoked ON; the locator is the RECEIVER, not an argument.
3713/// MEASURED before the clause was written, one variable, a benign allowed literal beside a
3714/// caller-chosen path:
3715///
3716/// ```text
3717/// fn f(p: &Path) { fs::write("/tmp/benign", b"x"); p.exists(); }
3718///   -> paths:['/tmp/benign'] incomplete:NONE   allow Fs /tmp/benign -> exit 0, "nothing hidden"
3719/// ```
3720///
3721/// The ARGUMENT spelling of the identical reach (`fs::metadata(p)`) was already marked, which is what
3722/// makes this a drift rather than a policy: `is_fs_path_arg` deliberately excluded the method form
3723/// because its receiver was assumed to be an OPEN HANDLE. That assumption holds for `File` and is
3724/// false for `Path` — a `Path` is a path VALUE, and a stat on it is `Fs` reaching that value.
3725///
3726/// **NO CARVE-OUT LIST, and that is structural rather than lucky.** The pure `Path` algebra —
3727/// `join`, `parent`, `file_name`, `extension`, `to_str`, `display`, `starts_with`, `to_path_buf`,
3728/// `as_path` — is NOT classified `Fs` (measured: a function calling all nine reports `fs:['write']`
3729/// from an unrelated sibling call and nothing else), so it never reaches the branch that consults
3730/// this predicate. The carve-out falls out of the classification, exactly as it does on the java
3731/// side, instead of being a list that must be kept in sync with `std`.
3732///
3733/// Matched on the RECEIVER SEGMENT so `std::path` and the `camino` mirrors are covered by one rule
3734/// without naming a crate. Being generous here is the SAFE direction: the predicate is only ever
3735/// consulted for a call the classifier ALREADY resolved to `Fs`, so an extra receiver name can at
3736/// worst over-mask (fails closed, disclosed), while a missing one is the exit-0 above — R399's
3737/// ruling that an under-catch on a masking guard is a broken gate, not a missed mask.
3738pub fn is_fs_receiver_locator(call_path: &str) -> bool {
3739    let mut segs = call_path.rsplit("::");
3740    let (Some(_leaf), Some(recv)) = (segs.next(), segs.next()) else { return false };
3741    matches!(recv, "Path" | "PathBuf" | "Utf8Path" | "Utf8PathBuf")
3742}
3743
3744pub fn is_fs_path_arg_method(call_path: &str) -> bool {
3745    if call_path.ends_with("OpenOptions::open") || call_path.ends_with("DirBuilder::create") {
3746        return true;
3747    }
3748    let mut segs = call_path.rsplit("::");
3749    let (Some(leaf), Some(recv)) = (segs.next(), segs.next()) else { return false };
3750    if recv != "Dir" {
3751        return false;
3752    }
3753    // The no-path exceptions on a directory capability: handle↔handle conversions, the entry
3754    // iterators, and the verbs whose only operand is `self`. Everything else on a `Dir` names a path.
3755    //
3756    // EVERY NAME HERE WAS CHECKED AGAINST ITS SIGNATURE, because a wrong entry here is not a precision
3757    // loss, it is the exit-0 above. `read_dir` was in this list on the first cut on the reasonable-
3758    // sounding grounds that `entries()` is the self-only form — and `cap_std::fs::Dir::read_dir` is
3759    // `fn read_dir<P: AsRef<Path>>(&self, path: P)`, so that entry was a bypass I had just written.
3760    // Adding a name here requires reading its signature, not recognising it.
3761    !matches!(
3762        leaf,
3763        "try_clone"
3764            | "into_std_file"
3765            | "from_std_file"
3766            | "into_std"
3767            | "from_std"
3768            | "entries"
3769            | "entries_utf8"
3770            | "dir_metadata"
3771            | "reopen_dir"
3772            | "remove_open_dir"
3773            | "remove_open_dir_all"
3774            | "open_parent_dir"
3775            | "rewind"
3776            | "as_raw_fd"
3777            | "as_fd"
3778    )
3779}
3780
3781/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
3782/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
3783/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
3784/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
3785/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
3786/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
3787/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
3788/// structurally (never a maskable string literal) so a missing literal must not false-positive.
3789/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), ~~never a broken
3790/// gate~~ — **FALSE, SOUNDNESS R399**, and R386 three lines below is this guard's own counter-example.
3791/// `mysql`'s `query_drop`/`query_iter` are that crate's PRIMARY API, take `Q: AsRef<str>`, and were
3792/// absent: `allow Db users` exited 0 over `c.query_drop(caller_sql)` beside one literal query, with the
3793/// verb-only control exiting 1. The arg is the method leaf (the path's last segment).
3794pub fn is_db_query_arg(leaf: &str) -> bool {
3795    // SOUNDNESS R386 — THE `sqlite3_*` C API TAKES SQL AND WAS ABSENT, so a benign sibling literal
3796    // certified arbitrary caller-supplied SQL. `classify` in this same crate maps `sqlite3_exec`/
3797    // `sqlite3_step`/`sqlite3_prepare*` to `Db`; this list had none of them, while candor-swift covers
3798    // the whole family by prefix — a cross-engine divergence, which is how it was found.
3799    //
3800    // MEASURED, calibrated first (`deny Db` exits 1): `conn.execute("INSERT INTO users (id) VALUES (1)",
3801    // [])` beside `sqlite3_exec(db, caller_sql, …)` reported `tables:['users'] incomplete:NONE` and
3802    // **`allow Db users` exited 0**. R383 recorded this gap as "REAL BUT NOT LIVE" on the grounds that a
3803    // C-string literal never reaches `tables` — true of an ISOLATED function, and the wrong shape: the
3804    // literal does not have to come from the masked call, only from the same FUNCTION.
3805    //
3806    // ONLY THE SQL-BEARING SPELLINGS. `sqlite3_step`/`_close`/`_serialize`/`_deserialize` carry no query,
3807    // and `sqlite3_open*` takes a FILENAME — masking that would claim a Db surface for an Fs locator,
3808    // which is the over-mask this allowlist exists to avoid. Direction stays an ALLOWLIST for the reason
3809    // R379 measured: a denylist over a whole-crate-Db crate masks every lifecycle verb in it.
3810    if matches!(
3811        leaf,
3812        "sqlite3_exec"
3813            | "sqlite3_prepare"
3814            | "sqlite3_prepare_v2"
3815            | "sqlite3_prepare_v3"
3816            | "sqlite3_prepare16"
3817            | "sqlite3_prepare16_v2"
3818            | "sqlite3_prepare16_v3"
3819            // R386's OWN BOUNDARY DEFECT, found by a release panel: `sqlite3_get_table(db, zSql, …)`
3820            // takes SQL as its second argument and `classify` maps it to `Db`, and the first cut of
3821            // this list — which cites R346 — omitted it. Two of the family's SQL-bearing spellings
3822            // written down out of three. Reach on the local registry is ZERO call sites (only
3823            // declarations in `libsqlite3-sys` and `sqlite-wasm-rs`), so this is latent, not live.
3824            | "sqlite3_get_table"
3825    ) {
3826        return true;
3827    }
3828    matches!(
3829        leaf,
3830        "execute"
3831            | "execute_batch"
3832            | "execute_unprepared"
3833            | "batch_execute"
3834            | "simple_query"
3835            | "query"
3836            | "query_one"
3837            | "query_opt"
3838            | "query_raw"
3839            | "query_row"
3840            | "query_map"
3841            // SOUNDNESS R399 — `mysql`'s OWN PRIMARY API was absent, and this is a live gate bypass rather
3842            // than the "missed mask" the struck sentence above promised. All four take `Q: AsRef<str>`
3843            // (`mysql-28.0.2/src/conn/queryable.rs:29,50,126`), so the SQL is a maskable string argument of
3844            // THIS call, which is the exact condition this list encodes. Measured: `allow Db users` exits 0
3845            // over `c.query_drop(caller_sql)` beside one literal query; the control, the same fixture with
3846            // the verb changed to `query`, exits 1.
3847            | "query_drop"
3848            | "query_iter"
3849            | "query_first"
3850            | "query_fold"
3851            // postgres COPY: the statement is a SQL string argument (`postgres-0.19.14/src/client.rs:492,520`).
3852            | "copy_in"
3853            | "copy_out"
3854            | "query_and_then"
3855            | "query_typed"
3856            | "query_all"
3857            | "prepare"
3858            | "prepare_typed"
3859            | "prepare_cached"
3860            | "exec"
3861            | "exec_first"
3862            | "exec_iter"
3863            | "exec_map"
3864            | "exec_fold"
3865            | "exec_drop"
3866            | "exec_batch"
3867            | "prep"
3868            | "run_command"
3869    )
3870}
3871
3872/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
3873/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
3874/// treats it as a declared capability, exactly like its own `&Fs` token.
3875pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
3876    if !crate_name.starts_with("cap_") {
3877        return None;
3878    }
3879    Some(match type_name {
3880        "Dir" => "Fs",
3881        "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
3882        "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
3883        "SystemClock" | "MonotonicClock" => "Clock",
3884        _ => return None,
3885    })
3886}
3887
3888/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
3889/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
3890/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
3891/// open with a SQL statement keyword, and only identifiers in table position are taken —
3892/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
3893/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
3894/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
3895/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
3896/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
3897/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
3898/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
3899pub fn tables_in_sql(sql: &str) -> Vec<String> {
3900    const STMT: &[&str] =
3901        &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
3902    // Tokens that can FOLLOW a table-introducing keyword without being a table.
3903    const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
3904    // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
3905    const STOP: &[&str] = &[
3906        "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
3907        "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
3908        "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
3909        "from", "join", "into", "update", "delete", "insert",
3910    ];
3911    // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
3912    // list without fabricating from other comma-ridden positions (column lists, ON clauses).
3913    let cleaned: String = sql
3914        .to_lowercase()
3915        .chars()
3916        .flat_map(|c| match c {
3917            '(' | ')' | ';' => vec![' '],
3918            ',' => vec![' ', ',', ' '],
3919            _ => vec![c],
3920        })
3921        .collect();
3922    let toks: Vec<&str> = cleaned.split_whitespace().collect();
3923    let Some(first) = toks.first() else { return Vec::new() };
3924    if !STMT.contains(first) {
3925        return Vec::new(); // not SQL — nothing to certify, nothing fabricated
3926    }
3927    let ident = |t: &str| -> Option<String> {
3928        let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
3929        let mut chars = t.chars();
3930        let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
3931        let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
3932        (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
3933    };
3934    let mut out: Vec<String> = Vec::new();
3935    let mut push = |t: Option<String>| {
3936        if let Some(t) = t {
3937            if !out.contains(&t) {
3938                out.push(t);
3939            }
3940        }
3941    };
3942    for (i, tok) in toks.iter().enumerate() {
3943        let table_pos = match *tok {
3944            "from" | "join" | "into" | "table" => true,
3945            // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
3946            "update" | "truncate" => i == 0,
3947            _ => false,
3948        };
3949        if !table_pos {
3950            continue;
3951        }
3952        let mut j = i + 1;
3953        while j < toks.len() && SKIP.contains(&toks[j]) {
3954            j += 1;
3955        }
3956        let Some(next) = toks.get(j) else { continue };
3957        let Some(first) = ident(next) else { continue };
3958        push(Some(first));
3959        // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
3960        // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
3961        // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
3962        // list, whose parens are spaces by the time we tokenize).
3963        while j + 2 < toks.len() && toks[j + 1] == "," {
3964            let Some(more) = ident(toks[j + 2]) else { break };
3965            push(Some(more));
3966            j += 2;
3967        }
3968    }
3969    out
3970}
3971
3972#[cfg(test)]
3973mod tests {
3974    #[test]
3975    fn model_host_recognizes_known_providers_and_special_forms() {
3976        use super::is_model_host as m;
3977        // exact known hosts (case-insensitive), with/without a port
3978        assert!(m("api.openai.com"));
3979        assert!(m("API.OpenAI.com"));
3980        assert!(m("api.anthropic.com:443"));
3981        assert!(m("generativelanguage.googleapis.com"));
3982        assert!(m("api.mistral.ai"));
3983        assert!(m("api.cohere.ai"));
3984        assert!(m("api.cohere.com")); // BOTH cohere hosts
3985        assert!(m("api.groq.com"));
3986        assert!(m("api.together.xyz"));
3987        assert!(m("api.perplexity.ai"));
3988        assert!(m("openrouter.ai"));
3989        // a subdomain of a known host counts
3990        assert!(m("eu.api.openai.com"));
3991        // Ollama: :11434 on a LOOPBACK host only (max-review r3 — a remote host on 11434 is not Ollama)
3992        assert!(m("localhost:11434"));
3993        assert!(m("127.0.0.1:11434"));
3994        assert!(!m("ollama.internal:11434")); // a remote/internal service on 11434 is NOT a model host
3995        // Bedrock: the FIRST label is the model-inference service, not the substring "bedrock"
3996        assert!(m("bedrock-runtime.us-east-1.amazonaws.com"));
3997        assert!(m("bedrock-runtime.eu-west-1.amazonaws.com"));
3998        assert!(m("bedrock-agent-runtime.us-east-1.amazonaws.com"));
3999        // NOT model hosts (never guessed)
4000        assert!(!m("example.com"));
4001        assert!(!m("api.stripe.com"));
4002        assert!(!m("localhost:8080")); // a non-Ollama local port
4003        assert!(!m("s3.us-east-1.amazonaws.com")); // amazonaws but not bedrock
4004        assert!(!m("bedrock-backups.s3.amazonaws.com")); // an S3 bucket merely NAMED bedrock — not the runtime
4005        assert!(!m("bedrock.us-east-1.amazonaws.com")); // the Bedrock CONTROL plane — not model inference
4006        assert!(!m("openai.com.evil.com")); // suffix trick — not a subdomain of a known host
4007    }
4008
4009    #[test]
4010    fn model_sdk_crate_is_crate_level_no_method_gating() {
4011        use super::is_model_sdk_crate as s;
4012        assert!(s("async_openai"));
4013        assert!(s("aws_sdk_bedrockruntime"));
4014        assert!(s("ollama_rs"));
4015        assert!(s("langchain_rust"));
4016        assert!(!s("reqwest"));
4017        assert!(!s("aws_sdk_s3"));
4018    }
4019
4020    #[test]
4021    fn sql_table_extraction_is_conservative() {
4022        use super::tables_in_sql as t;
4023        assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
4024        assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
4025                   vec!["ledger.entries", "customers"]);
4026        assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
4027        assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
4028        assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
4029        assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
4030        assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
4031        // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
4032        assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
4033        // a subquery in FROM position yields nothing for that position
4034        assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
4035        // not SQL -> nothing (never fabricate)
4036        assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
4037        assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
4038        // comma-ADJACENT continuation: a FROM list takes every table in the chain…
4039        assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
4040        // …but an alias breaks it (under-report, never a guess)…
4041        assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
4042        // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
4043        assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
4044        // a subquery after the comma stops the chain too
4045        assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
4046    }
4047
4048    use super::*;
4049
4050    /// The routed-handle list's MEMBERSHIP RULE, enforced rather than merely documented.
4051    ///
4052    /// (a) Every routed type must have a whole-type rule that answers an ARBITRARY method on it.
4053    /// If it did not, routing it would hand the scanner a path the classifier drops — motion with no
4054    /// effect, and the silent under-report would survive the fix that was supposed to close it. The
4055    /// probe leaf is deliberately a name no carve-out lists, so it measures the BASE rule.
4056    ///
4057    /// (b) The named exclusions stay excluded. `ReadDir` cannot have a verb-precise rule written for it
4058    /// (its `next` is a syscall and is spelled like every other iterator's), and `Metadata`/`DirEntry`/
4059    /// `Permissions`/`FileType` are pure DATA under the coarse `std::fs::` prefix — routing any of them
4060    /// charges an effect for reading a field. `OpenOptions`/`DirBuilder` LEFT this list by the route the
4061    /// list's own comment prescribed: their type-keyed setter carve-outs were written first, so the
4062    /// whole-type rule under them now answers only for the terminal verb.
4063    #[test]
4064    fn std_effect_handles_have_a_whole_type_rule_and_exclude_the_pure_surfaces() {
4065        for ty in STD_EFFECT_HANDLES {
4066            let probe = format!("{ty}::__candor_probe_verb");
4067            assert!(
4068                classify("std", &probe).is_some(),
4069                "`{ty}` is routed into the classifier by receiver inference, but an arbitrary method \
4070                 on it classifies to NOTHING — the routing cannot close any under-report"
4071            );
4072            assert!(is_std_effect_handle(ty), "the predicate must agree with the list");
4073        }
4074        for ty in [
4075            "std::fs::ReadDir",
4076            "std::fs::Metadata", "std::fs::DirEntry", "std::fs::Permissions", "std::fs::FileType",
4077            "std::path::Path", "std::path::PathBuf", "std::vec::Vec",
4078        ] {
4079            assert!(!is_std_effect_handle(ty),
4080                    "`{ty}` has a pure surface the coarse prefix rules would charge — it must not be \
4081                     routed as a handle (Path/PathBuf route through their own verb-precise carve-out)");
4082        }
4083    }
4084
4085    /// The pure read-backs on the ROUTED types, which is what makes (a) above safe: a whole-type rule
4086    /// answering every method is only acceptable because the genuinely-pure methods were carved out
4087    /// first. Receiver routing is a second door into these rules, so it must not re-fabricate.
4088    #[test]
4089    fn routed_std_handles_keep_their_pure_accessor_carve_outs() {
4090        for p in [
4091            "std::process::Command::get_program", "std::process::Command::get_args",
4092            "std::process::Command::get_envs", "std::process::Command::get_current_dir",
4093            "std::process::Child::id",
4094            "std::net::TcpStream::local_addr", "std::net::TcpStream::peer_addr",
4095            "std::net::TcpStream::nodelay", "std::net::TcpStream::ttl",
4096            "std::net::UdpSocket::take_error",
4097            "std::fs::File::as_raw_fd", "std::fs::File::into_raw_fd",
4098            "std::os::unix::net::UnixStream::as_raw_fd",
4099            // The OPTION-BUILDER setters (SPEC §1 ⟨0.32⟩): flags in a struct, no file named, nothing
4100            // opened. `create` is here as `OpenOptions`' setter and is a VERB on `DirBuilder` below —
4101            // one leaf, two answers, which is why these carve-outs are keyed on the TYPE.
4102            "std::fs::OpenOptions::new", "std::fs::OpenOptions::read", "std::fs::OpenOptions::write",
4103            "std::fs::OpenOptions::append", "std::fs::OpenOptions::truncate",
4104            "std::fs::OpenOptions::create", "std::fs::OpenOptions::create_new",
4105            "std::fs::OpenOptions::mode", "std::fs::OpenOptions::custom_flags",
4106            "std::fs::DirBuilder::new", "std::fs::DirBuilder::recursive", "std::fs::DirBuilder::mode",
4107        ] {
4108            assert_eq!(classify("std", p), None, "`{p}` is a pure read-back");
4109        }
4110        // …and the verbs on the same types still answer, or the carve-outs would have eaten the rule.
4111        for (p, want) in [
4112            ("std::process::Command::spawn", "Exec"),
4113            ("std::process::Child::wait", "Exec"),
4114            ("std::fs::File::write_all", "Fs"),
4115            ("std::net::TcpStream::write_all", "Net"),
4116            ("std::os::unix::net::UnixStream::send", "Ipc"),
4117            // the TERMINAL VERBS of the two option-builders — the whole point of routing them.
4118            ("std::fs::OpenOptions::open", "Fs"),
4119            ("std::fs::DirBuilder::create", "Fs"),
4120        ] {
4121            assert_eq!(classify("std", p), Some(want), "`{p}` must stay {want}");
4122        }
4123    }
4124
4125    #[test]
4126    fn db_crates_are_calibrated() {
4127        // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
4128        // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
4129        for c in DB_CRATES {
4130            assert!(
4131                CALIBRATED_CRATES.contains(&c),
4132                "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
4133            );
4134        }
4135    }
4136
4137    /// The two coverage lists mean OPPOSITE things and must stay disjoint.
4138    ///
4139    /// `CALIBRATED_CRATES` = "classify has effect rules here". `REVIEWED_PURE_CRATES` = "read it, it
4140    /// performs nothing". A crate in both would be asserting both at once, and the ledger consults them
4141    /// with an OR — so the contradiction would resolve silently to "covered" and nobody would look again.
4142    #[test]
4143    fn reviewed_pure_and_calibrated_are_disjoint() {
4144        for c in REVIEWED_PURE_CRATES {
4145            assert!(!CALIBRATED_CRATES.contains(&c),
4146                    "`{c}` is in BOTH lists — it cannot be rule-covered AND effect-free");
4147            assert!(!PATH_CALIBRATED_CRATES.contains(&c), "`{c}` is in BOTH lists (path-calibrated)");
4148            assert!(!CALIBRATED_PREFIXES.iter().any(|p| c.starts_with(p)),
4149                    "`{c}` is covered by a calibrated PREFIX as well as the pure list");
4150        }
4151    }
4152
4153    /// A reviewed-pure crate must actually classify as pure — the mirror of `calibrated_crates_are_live`.
4154    ///
4155    /// The list makes candor BELIEVE these crates rather than disclose them, so if someone later adds a
4156    /// rule for one, the claim "performs no effect of its own" is dead and the entry has to be re-read,
4157    /// not silently outvoted by the rule. Probed with the same tails the liveness test uses, which is a
4158    /// broad sweep of the effectful verb shapes candor knows.
4159    #[test]
4160    fn reviewed_pure_crates_classify_as_pure() {
4161        for c in REVIEWED_PURE_CRATES {
4162            for t in CALIBRATION_PROBE_TAILS {
4163                assert!(classify(c, &format!("{c}{t}")).is_none(),
4164                        "`{c}` is listed REVIEWED-PURE but classify() gives it an effect on `{c}{t}` — \
4165                         one of the two is wrong, and the list is the claim");
4166            }
4167        }
4168    }
4169
4170    /// ⟨CALIBRATION CEILING — the table must agree with the REVIEW RECORD⟩ SOUNDNESS R328.
4171    ///
4172    /// `CALIBRATED_CEILINGS` is the version each crate's rules were actually written against. Nothing
4173    /// derived it from, or checked it against, the review record — and three separate failures followed
4174    /// in one day: a wholesale table rewrite silently DROPPED nine entries while the commit message
4175    /// claimed four were absent "each with a stated reason"; `git2` was ceilinged ABOVE the only version
4176    /// its rules record reading (the SILENT direction, and 0.21 added `author_from_env` /
4177    /// `committer_from_env` / `Oid::hash_file_ext`, none of them ruled); and `sqlx_core` sat BELOW the
4178    /// version its own comment says was verified, which is also an impossible sibling of `sqlx 0.9.0`'s
4179    /// `=0.9.0` pin. All three were invisible to 410 tests and 29 gates.
4180    ///
4181    /// This asserts the one invariant that catches all of them: **a crate whose rules cite a reviewed
4182    /// version must carry a ceiling, and it must be THAT version.** Citations are read with wrapped
4183    /// comment lines joined and both name spellings tried — R315, where a line-by-line dashed-only
4184    /// sweep silently under-counted the citations by three.
4185    #[test]
4186    fn calibration_ceilings_match_the_recorded_review() {
4187        let src = include_str!("lib.rs");
4188        // join wrapped comment continuations, then collapse whitespace: a citation is prose and prose wraps.
4189        let flat = src
4190            .replace("\n    //", " ")
4191            .replace("\n        //", " ")
4192            .replace("\n            //", " ");
4193        let flat: String = flat.split_whitespace().collect::<Vec<_>>().join(" ");
4194        let ceilings: std::collections::HashMap<&str, &str> =
4195            CALIBRATED_CEILINGS.iter().copied().collect();
4196        // Absences are deliberate and enumerated in the table's own comment; keep the list HERE too so
4197        // dropping one silently is impossible — that is exactly what happened.
4198        let deliberate = ["postgres", "async_fs", "getrandom", "rand_core"];
4199        let mut problems: Vec<String> = Vec::new();
4200        for c in CALIBRATED_CRATES {
4201            if deliberate.contains(&c) {
4202                continue;
4203            }
4204            // EVERY citation, both verbs, both name spellings, and slash-separated lists —
4205            // "verified against sqlx-core 0.8.6/0.9.0" records TWO reviewed versions, and a crate may
4206            // be cited in more than one comment ("validated against rusqlite 0.39" elsewhere). An
4207            // extractor that stops at the first hit under-collects, and under-collecting here makes a
4208            // correct ceiling look like it is above the record — R315's lesson, one level up.
4209            let mut cited: Vec<String> = Vec::new();
4210            for form in [c.to_string(), c.replace('_', "-")] {
4211                for verb in ["verified against ", "validated against "] {
4212                    let pat = format!("{verb}{form} ");
4213                    let mut from = 0usize;
4214                    while let Some(i) = flat[from..].find(&pat) {
4215                        let at = from + i + pat.len();
4216                        let tail: String = flat[at..]
4217                            .chars()
4218                            .take_while(|ch| ch.is_ascii_digit() || *ch == '.' || *ch == '/')
4219                            .collect();
4220                        for v in tail.split('/') {
4221                            let v = v.trim_end_matches('.').to_string();
4222                            if v.split('.').count() >= 2 && !cited.contains(&v) {
4223                                cited.push(v);
4224                            }
4225                        }
4226                        from = at;
4227                    }
4228                }
4229            }
4230            if cited.is_empty() {
4231                continue;
4232            }
4233            match ceilings.get(c) {
4234                None => problems.push(format!(
4235                    "{c}: rules cite {} but there is NO ceiling — the exemption is unconditional",
4236                    cited.join("/")
4237                )),
4238                // A comment may legitimately record MORE THAN ONE reviewed version
4239                // ("verified against sqlx-core 0.8.6/0.9.0"), so matching any of them is fine, and a
4240                // ceiling BELOW the newest review is merely noisy. The failure that matters is a
4241                // ceiling ABOVE EVERY recorded review: that grants reviewed-purity over source nobody
4242                // read, which is the SILENT direction and the whole reason this table exists.
4243                Some(have) => {
4244                    let ok = cited.iter().any(|v| have.starts_with(v) || v.starts_with(*have))
4245                        || cited.iter().any(|v| calibration_exceeded_raw(v, have));
4246                    if !ok {
4247                        problems.push(format!(
4248                            "{c}: ceiling {have} is ABOVE every recorded review ({}) — it grants \
4249                             reviewed-purity over source no comment records anyone reading",
4250                            cited.join("/")
4251                        ));
4252                    }
4253                }
4254            }
4255        }
4256        assert!(
4257            problems.is_empty(),
4258            "CALIBRATED_CEILINGS disagrees with the review record:\n  {}\n\n\
4259             A ceiling ABOVE the recorded review grants reviewed-purity over source nobody read (SILENT); \
4260             a ceiling BELOW it is noisy but safe. Either way the table and the record must agree, or \
4261             neither is evidence.",
4262            problems.join("\n  ")
4263        );
4264    }
4265
4266    #[test]
4267    fn calibrated_crates_are_live() {
4268        // Conversely, every crate advertised as calibrated must actually be matched by classify() for
4269        // some representative path — a dead entry would silently suppress a real coverage warning.
4270        for c in CALIBRATED_CRATES {
4271            assert!(
4272                CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
4273                "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
4274            );
4275        }
4276    }
4277
4278    #[test]
4279    fn async_http_stack_classifies() {
4280        // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
4281        // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
4282        assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
4283        assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
4284        assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
4285        assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
4286        assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
4287        assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
4288        assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
4289        assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
4290        assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
4291        assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
4292        assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
4293        assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
4294        // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
4295        assert_eq!(classify("num_cpus", "num_cpus::get"), None);
4296        assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
4297        // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
4298        assert_eq!(classify("hyper", "hyper::Request::builder"), None);
4299        assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
4300        assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
4301        assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
4302    }
4303
4304    #[test]
4305    fn coverage_differential_crates_classify() {
4306        // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
4307        // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
4308        // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
4309
4310        // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
4311        assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
4312        assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
4313        assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
4314        assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
4315        assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
4316
4317        // native-tls variants — handshake is Net; builder is pure.
4318        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
4319        assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
4320        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
4321
4322        // etcetera — dir resolution reads env; the args data type is pure.
4323        assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
4324        assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
4325        assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
4326        assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
4327
4328        // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
4329        assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
4330        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
4331        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
4332        assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
4333        assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
4334
4335        // walkdir — charged at construction (`WalkDir::new`, mirroring `ignore`/`glob`), NOT solely at
4336        // the lazy `next()`/`metadata()` read: candor-scan's receiver-typing blocklist hard-blocks
4337        // `.into_iter()`, so a typed `IntoIter::next` receiver is unreachable from the idiomatic
4338        // `WalkDir::new(p).into_iter()...` chain — `WalkDir::new` is the only point every real usage
4339        // (for-loop, `.count()`, untyped `.next()`) actually reaches. `IntoIter::next`/`DirEntry::metadata`
4340        // stay Some("Fs") as a secondary rule for the narrower explicit-type-annotation case. Builder
4341        // setters (`into_iter` itself, `max_depth`, …) and cached accessors stay pure.
4342        assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), Some("Fs"));
4343        assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
4344        assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
4345        assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
4346        assert_eq!(classify("walkdir", "walkdir::WalkDir::max_depth"), None); // builder setter
4347        assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
4348
4349        // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
4350        assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
4351        assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
4352        assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
4353        assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
4354        assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
4355
4356        // execute — the execute* verbs spawn (Exec); command/shell builders pure.
4357        assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
4358        assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
4359        assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
4360        assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
4361        assert_eq!(classify("execute", "execute::shell"), None);
4362
4363        // ctrlc — install signal handler (Ipc).
4364        assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
4365        assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
4366
4367        // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
4368        assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
4369        assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
4370        assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
4371        assert_eq!(classify("clap", "clap::Command::new"), None); // builder
4372        assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
4373        assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
4374        // R59-class audit: `Arg::env` calls `env::var_os` DIRECTLY at builder time (verified against
4375        // clap_builder 4.6.6) — unambiguous (the crate's only `::env` method), so classify it rather
4376        // than leave a real effect to fall silently into the CALIBRATED_CRATES purity exemption.
4377        assert_eq!(classify("clap", "clap::Arg::env"), Some("Env"));
4378        assert_eq!(classify("clap", "clap::Arg::env_os"), Some("Env"));
4379
4380        // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
4381        assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
4382        assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
4383        assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
4384        assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
4385        assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
4386
4387        // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
4388        // TUI — the tty is a user dialogue channel (Ipc), the ruling dialoguer/console already carry.
4389        // Each verb below was read off the crate source (crossterm-0.28.1, ratatui-0.29.0), not guessed.
4390        assert_eq!(classify("crossterm", "crossterm::ExecutableCommand::execute"), Some("Ipc"));
4391        assert_eq!(classify("crossterm", "crossterm::QueueableCommand::queue"), Some("Ipc"));
4392        assert_eq!(classify("crossterm", "crossterm::event::read"), Some("Ipc"));
4393        assert_eq!(classify("crossterm", "crossterm::event::poll"), Some("Ipc"));
4394        assert_eq!(classify("crossterm", "crossterm::terminal::enable_raw_mode"), Some("Ipc"));
4395        // a tty IOCTL must not fall through: in a CALIBRATED crate an unmatched path is a purity CLAIM
4396        assert_eq!(classify("crossterm", "crossterm::terminal::size"), Some("Ipc"));
4397        // the Command VALUE types are pure — they describe an action, they do not perform one
4398        assert_eq!(classify("crossterm", "crossterm::style::Print"), None);
4399        assert_eq!(classify("crossterm", "crossterm::cursor::MoveTo"), None);
4400
4401        // ratatui: the backlog said "mark reviewed-pure"; the SOURCE says `Terminal::draw` ends in a
4402        // backend flush, so the write surface is Ipc and only the render surface is pure.
4403        assert_eq!(classify("ratatui", "ratatui::Terminal::draw"), Some("Ipc"));
4404        assert_eq!(classify("ratatui", "ratatui::Terminal::flush"), Some("Ipc"));
4405        assert_eq!(classify("ratatui", "ratatui::Terminal::clear"), Some("Ipc"));
4406        assert_eq!(classify("ratatui", "ratatui::Terminal::hide_cursor"), Some("Ipc"));
4407        // REGRESSION: `widgets::canvas` is an in-memory grid. `Context::draw` ends in `::draw` and was
4408        // FABRICATING Ipc — caught in review, measured on a fixture, and a hot path (per shape, per frame).
4409        assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::draw"), None);
4410        assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::layer"), None);
4411        // …while the real write surface still classifies, including a DIRECT backend call (which is why
4412        // the carve-out is a denylist on canvas rather than an allowlist on `Terminal::`).
4413        assert_eq!(classify("ratatui", "ratatui::backend::CrosstermBackend::flush"), Some("Ipc"));
4414        // the BULK of the 3,345 disclosed calls — widgets, layout, style — are genuinely pure
4415        assert_eq!(classify("ratatui", "ratatui::widgets::Paragraph::new"), None);
4416        assert_eq!(classify("ratatui", "ratatui::layout::Layout::split"), None);
4417        assert_eq!(classify("ratatui", "ratatui::style::Style::fg"), None);
4418        assert_eq!(classify("ratatui", "ratatui::buffer::Buffer::set_string"), None);
4419
4420        // tracing_subscriber — two effects, both read off 0.3.23. The filing said "Log/Fs"; the Fs half
4421        // is wrong (the crate ACCEPTS a File as a writer, it never opens one).
4422        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::init"), Some("Log"));
4423        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::try_init"), Some("Log"));
4424        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::init"), Some("Log"));
4425        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_default_env"), Some("Env"));
4426        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_env"), Some("Env"));
4427        // builders DESCRIBE a subscriber; they do not install one
4428        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::layer"), None);
4429        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::with_target"), None);
4430        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::new"), None);
4431
4432        assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
4433        assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
4434        assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
4435        assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
4436        assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
4437
4438        // dialoguer — interact* is tty I/O (Ipc); builders pure.
4439        assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
4440        assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
4441        assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
4442        assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
4443        // widened-CORE audit: `Editor::new`/`::default` read VISUAL/EDITOR at construction, independent
4444        // of `Editor::edit`'s already-covered Exec (spawning that editor is a separate, later step).
4445        assert_eq!(classify("dialoguer", "dialoguer::Editor::new"), Some("Env"));
4446        assert_eq!(classify("dialoguer", "dialoguer::Editor::default"), Some("Env"));
4447        assert_eq!(classify("dialoguer", "dialoguer::Editor::edit"), Some("Exec"));
4448        assert_eq!(classify("dialoguer", "dialoguer::Editor::extension"), None); // pure setter
4449
4450        // console — Term I/O is Ipc, detection is Env, Style is pure.
4451        assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
4452        assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
4453        assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
4454        assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
4455        assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
4456        // R59-class audit: `Term` also implements raw `io::{Read,Write}` (console 0.15.11) — the same
4457        // tty channel as `write_line`/`read_key`, reached through the generic trait methods instead of
4458        // the crate's own named convenience methods. Crate-gated and `Term::`-scoped, so it cannot spread.
4459        assert_eq!(classify("console", "console::Term::write"), Some("Ipc"));
4460        assert_eq!(classify("console", "console::Term::flush"), Some("Ipc"));
4461        assert_eq!(classify("console", "console::Term::read"), Some("Ipc"));
4462
4463        // terminal_colorsaurus — tty colour query (Ipc).
4464        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
4465        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
4466
4467        // backoff — retry sleeps + reads the clock (Clock); config pure.
4468        assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
4469        assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
4470        assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
4471
4472        // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
4473        assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
4474        assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
4475        assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
4476
4477        // wild — argv readers (Env).
4478        assert_eq!(classify("wild", "wild::args"), Some("Env"));
4479        assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
4480
4481        // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
4482        assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
4483        assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
4484        assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
4485
4486        // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
4487        assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
4488        assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
4489    }
4490
4491    #[test]
4492    fn log_tracing_emit_macros_classify_pre_expansion() {
4493        // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
4494        // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
4495        assert_eq!(classify("log", "log::info"), Some("Log"));
4496        assert_eq!(classify("log", "log::error"), Some("Log"));
4497        assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
4498        assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
4499        // pure data-type surface stays None (no fabricated Log)
4500        assert_eq!(classify("log", "log::Level::as_str"), None);
4501        assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
4502    }
4503
4504    #[test]
4505    fn classify_core_effects() {
4506        // A representative smoke test of the classifier's main families, so the published crate is not
4507        // shipped untested (these used to live only in the nightly-only src/lib.rs).
4508        assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
4509        // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
4510        // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
4511        assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
4512        assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
4513        assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
4514        assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
4515        assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
4516        assert_eq!(classify("std", "std::path::Path::parent"), None);
4517        assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
4518        assert_eq!(classify("std", "std::env::var"), Some("Env"));
4519        assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
4520        // one-shot convenience fns send immediately → Net.
4521        assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
4522        assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
4523        // the URL-BEARING builder methods classify Net too — the DOMINANT idiom is the builder chain
4524        // `Client::new().post(url).send()`, whose URL literal rides the `.post(url)` step (NOT `.send()`),
4525        // so the endpoint (and the Llm host refinement) only get captured if the URL-naming step is Net.
4526        assert_eq!(classify("reqwest", "reqwest::Client::get"), Some("Net"));
4527        assert_eq!(classify("reqwest", "reqwest::Client::post"), Some("Net"));
4528        assert_eq!(classify("reqwest", "reqwest::Client::put"), Some("Net"));
4529        assert_eq!(classify("reqwest", "reqwest::Client::delete"), Some("Net"));
4530        assert_eq!(classify("reqwest", "reqwest::Client::request"), Some("Net"));
4531        // the PURE builder surface stays None (no URL, no dispatch).
4532        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
4533        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::json"), None);
4534        assert_eq!(classify("reqwest", "reqwest::ClientBuilder::build"), None);
4535        // RAW POSIX SOCKETS — the lowest network tier, pinned as a regression guard (four-way close:
4536        // swift got a raw-socket regression this week from a bare-identifier collision; rust never had
4537        // the gap because it classifies path-QUALIFIED via the syscall-leaf table, but pin it so the
4538        // `socket`/`connect` Net rows can't silently drop). `libc::connect`/`libc::socket` are the direct
4539        // FFI syscalls; `nix::sys::socket::connect` is the safe wrapper; both bottom out in the NET table.
4540        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
4541        assert_eq!(classify("libc", "libc::socket"), Some("Net"));
4542        assert_eq!(classify("libc", "libc::bind"), Some("Net"));
4543        assert_eq!(classify("libc", "libc::accept"), Some("Net"));
4544        // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
4545        assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
4546        assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
4547        assert_eq!(classify("nix", "nix::sys::socket::socket"), Some("Net"));
4548        assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
4549        assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
4550        assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
4551        // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
4552        assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
4553        assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
4554        assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
4555        assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
4556        // pnet raw packet capture: channel openers are Net, packet construction stays pure.
4557        assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
4558        assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
4559        assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
4560        assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
4561        assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
4562        // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
4563        assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
4564        assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
4565        assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
4566        assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
4567        assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
4568        assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
4569        assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
4570        // THE FIX: `Walk::new`/`Walk::from_iter` are `WalkBuilder::new(path).build()` /
4571        // `WalkBuilder::from_iter(paths).build()` in ignore's own source — the crate's documented
4572        // convenience constructors, and its own top-level doc example (`for entry in Walk::new(path)`).
4573        // A corpus round caught `deny Fs` exiting 0 over exactly that idiom.
4574        assert_eq!(classify("ignore", "ignore::Walk::new"), Some("Fs"));
4575        assert_eq!(classify("ignore", "ignore::Walk::from_iter"), Some("Fs"));
4576        // NO FABRICATION: a same-named `Walk::new` from an unrelated crate must not gain Fs — crate-gated.
4577        assert_eq!(classify("walkdir", "walkdir::Walk::new"), None);
4578        assert_eq!(classify("some_local_crate", "some_local_crate::Walk::new"), None);
4579        // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
4580        assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
4581        assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
4582        assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
4583        assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
4584        assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
4585        assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
4586        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
4587        // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
4588        assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
4589        assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
4590        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
4591        assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
4592        assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
4593        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
4594        // THE FIX: three more real `Connection::open*` constructors an exact-suffix list missed
4595        // (`open_in_memory_with_flags` doesn't END in `open_with_flags`), plus the documented blob-I/O
4596        // API (`blob_open`/`Blob::reopen`), each calling the real sqlite3 FFI directly.
4597        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags"), Some("Db"));
4598        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_with_flags_and_vfs"), Some("Db"));
4599        assert_eq!(
4600            classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags_and_vfs"),
4601            Some("Db")
4602        );
4603        assert_eq!(classify("rusqlite", "rusqlite::Connection::blob_open"), Some("Db"));
4604        assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::reopen"), Some("Db"));
4605        // NO FABRICATION: a same-crate, DIFFERENT type's `open`-prefixed method (rusqlite's own private
4606        // `pragma::Sql::open_brace`, which pushes one char to a string buffer) must not gain Db — the
4607        // fix keys on the `Connection::` segment, not a bare leaf prefix.
4608        assert_eq!(classify("rusqlite", "rusqlite::pragma::Sql::open_brace"), None);
4609        // THE COVERAGE-GATE SWEEP FIX: the online-backup API and incremental-BLOB positional I/O,
4610        // verified against rusqlite 0.32.1 (each calls an `ffi::sqlite3_*` leaf already in this file's
4611        // own FFI-leaf DB table, but the `crate_name == "rusqlite"` block above returned before ever
4612        // reaching it — same shape as the git2 fix elsewhere in this file).
4613        assert_eq!(classify("rusqlite", "rusqlite::Backup::new"), Some("Db"));
4614        assert_eq!(classify("rusqlite", "rusqlite::Backup::new_with_names"), Some("Db"));
4615        assert_eq!(classify("rusqlite", "rusqlite::Backup::step"), Some("Db"));
4616        assert_eq!(classify("rusqlite", "rusqlite::Backup::run_to_completion"), Some("Db"));
4617        assert_eq!(classify("rusqlite", "rusqlite::Connection::backup"), Some("Db"));
4618        assert_eq!(classify("rusqlite", "rusqlite::Connection::restore"), Some("Db"));
4619        assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at"), Some("Db"));
4620        assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at_exact"), Some("Db"));
4621        assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at"), Some("Db"));
4622        assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at_exact"), Some("Db"));
4623        assert_eq!(classify("rusqlite", "rusqlite::Blob::write_at"), Some("Db"));
4624        assert_eq!(classify("rusqlite", "rusqlite::Blob::write_all_at"), Some("Db"));
4625        assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle"), Some("Db"));
4626        assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle_owned"), Some("Db"));
4627        assert_eq!(classify("rusqlite", "rusqlite::Connection::extension_init2"), Some("Db"));
4628        assert_eq!(classify("rusqlite", "rusqlite::init_auto_extension"), Some("Db"));
4629        // …and the REAL module-qualified spellings (`Backup`/`Blob`/`init_auto_extension` are not
4630        // re-exported at rusqlite's crate root, unlike `Connection` — a real consumer's source, proven
4631        // against a compiling fixture, must use these, not the short forms above).
4632        assert_eq!(classify("rusqlite", "rusqlite::backup::Backup::new"), Some("Db"));
4633        assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::read_at"), Some("Db"));
4634        assert_eq!(classify("rusqlite", "rusqlite::auto_extension::init_auto_extension"), Some("Db"));
4635        // NO FABRICATION: `InnerConnection`/`RawStatement` live in PRIVATE modules (lib.rs `mod
4636        // inner_connection;`/`mod raw_statement;`, no `pub`) — no external consumer can ever name either
4637        // type, so no rule was added despite self-scan flagging them too.
4638        assert_eq!(classify("rusqlite", "rusqlite::InnerConnection::close"), None);
4639        assert_eq!(classify("rusqlite", "rusqlite::RawStatement::step"), None);
4640        // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
4641        assert_eq!(classify("postgres", "postgres::Client::open"), None);
4642        assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
4643        // THE FIX: `Config::connect_raw` does the same protocol handshake as `Config::connect`, over a
4644        // caller-supplied stream instead of one it dials itself.
4645        assert_eq!(classify("tokio_postgres", "tokio_postgres::Config::connect_raw"), Some("Db"));
4646        // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
4647        assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
4648        assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
4649        // THE FIX: `Connection::establish` is diesel's own name for `::connect` — the crate's canonical,
4650        // most-used connection entry point (`SqliteConnection::establish(url)` in every diesel
4651        // quickstart), implemented by really opening the backend handle — but shared no verb spelling
4652        // with `::connect`, so it read pure with zero `coverage.uncovered` disclosure (diesel is
4653        // calibrated, so the miss reads as reviewed-pure, not as a gap).
4654        assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish"), Some("Db"));
4655        assert_eq!(classify("diesel", "diesel::pg::PgConnection::establish"), Some("Db"));
4656        assert_eq!(classify("diesel", "diesel::mysql::MysqlConnection::establish"), Some("Db"));
4657        assert_eq!(classify("diesel", "diesel::Connection::establish"), Some("Db"));
4658        // NO FABRICATION: diesel's own private plumbing shares the substring but not the exact verb.
4659        assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish_inner"), None);
4660        assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
4661        // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
4662        assert_eq!(classify("sqlx", "sqlx::query"), None);
4663        // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
4664        // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
4665        // pure, same principle as the `log` facade).
4666        assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
4667        assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
4668        assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
4669        assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
4670        assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
4671        assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
4672        assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
4673        assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
4674        assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
4675        // git2 (high-level Rust API, not the `raw::git_*` FFI tier tested above): remote verbs are Net.
4676        assert_eq!(classify("git2", "git2::Remote::fetch"), Some("Net"));
4677        assert_eq!(classify("git2", "git2::Remote::push"), Some("Net"));
4678        assert_eq!(classify("git2", "git2::Remote::download"), Some("Net"));
4679        assert_eq!(classify("git2", "git2::Remote::connect"), Some("Net"));
4680        assert_eq!(classify("git2", "git2::Remote::connect_auth"), Some("Net"));
4681        assert_eq!(classify("git2", "git2::Remote::ls"), Some("Net"));
4682        assert_eq!(classify("git2", "git2::Remote::upload"), Some("Net"));
4683        // THE COVERAGE-GATE SWEEP FIX: `Repository::open`/`init` (and every other local .git-directory
4684        // operation below) used to read PURE here — the comment this replaced called that "honest, no
4685        // network", but candor-scan self-scanning git2's OWN source (eval/coverage-gate) proved these
4686        // reach a REAL Fs effect (`raw::git_repository_open`/`_init_ext`, already in this file's FFI-tier
4687        // table) that a real consumer's call to the safe wrapper could never surface, because THIS
4688        // block's early `return None` sat in front of that table for every `crate_name == "git2"` call.
4689        // "local, no network" was true and beside the point: Fs was the effect being missed. See MEMORY /
4690        // the commit that added this block for the full sweep (Config/Index/Odb/PackBuilder/Reference/
4691        // Repository/TreeBuilder — every FQN below calls a listed FS FFI leaf; verified against git2
4692        // 0.20.4 source).
4693        assert_eq!(classify("git2", "git2::Repository::open"), Some("Fs"));
4694        assert_eq!(classify("git2", "git2::Repository::init"), Some("Fs"));
4695        assert_eq!(classify("git2", "git2::Repository::open_bare"), Some("Fs"));
4696        assert_eq!(classify("git2", "git2::Repository::open_ext"), Some("Fs"));
4697        assert_eq!(classify("git2", "git2::Repository::open_from_env"), Some("Fs"));
4698        assert_eq!(classify("git2", "git2::Repository::discover"), Some("Fs"));
4699        assert_eq!(classify("git2", "git2::Repository::discover_path"), Some("Fs"));
4700        assert_eq!(classify("git2", "git2::Repository::init_bare"), Some("Fs"));
4701        assert_eq!(classify("git2", "git2::Repository::init_opts"), Some("Fs"));
4702        assert_eq!(classify("git2", "git2::Repository::blob_path"), Some("Fs"));
4703        assert_eq!(classify("git2", "git2::Repository::checkout_head"), Some("Fs"));
4704        assert_eq!(classify("git2", "git2::Repository::checkout_index"), Some("Fs"));
4705        assert_eq!(classify("git2", "git2::Repository::checkout_tree"), Some("Fs"));
4706        assert_eq!(classify("git2", "git2::Repository::commit"), Some("Fs"));
4707        assert_eq!(classify("git2", "git2::Repository::reference"), Some("Fs"));
4708        assert_eq!(classify("git2", "git2::Repository::tag"), Some("Fs"));
4709        assert_eq!(classify("git2", "git2::Config::add_file"), Some("Fs"));
4710        assert_eq!(classify("git2", "git2::Config::open"), Some("Fs"));
4711        assert_eq!(classify("git2", "git2::Config::open_default"), Some("Fs"));
4712        assert_eq!(classify("git2", "git2::Index::add_all"), Some("Fs"));
4713        assert_eq!(classify("git2", "git2::Index::add_path"), Some("Fs"));
4714        assert_eq!(classify("git2", "git2::Index::read"), Some("Fs"));
4715        assert_eq!(classify("git2", "git2::Index::write"), Some("Fs"));
4716        assert_eq!(classify("git2", "git2::Index::write_tree"), Some("Fs"));
4717        assert_eq!(classify("git2", "git2::Index::write_tree_to"), Some("Fs"));
4718        assert_eq!(classify("git2", "git2::Odb::read"), Some("Fs"));
4719        assert_eq!(classify("git2", "git2::Odb::reader"), Some("Fs"));
4720        assert_eq!(classify("git2", "git2::Odb::write"), Some("Fs"));
4721        assert_eq!(classify("git2", "git2::Odb::writer"), Some("Fs"));
4722        assert_eq!(classify("git2", "git2::PackBuilder::write"), Some("Fs"));
4723        assert_eq!(classify("git2", "git2::Reference::delete"), Some("Fs"));
4724        assert_eq!(classify("git2", "git2::Reference::set_target"), Some("Fs"));
4725        assert_eq!(classify("git2", "git2::TreeBuilder::write"), Some("Fs"));
4726        // `Remote::list`/`RemoteConnection::list` call `raw::git_remote_ls` directly — the same
4727        // reference-advertisement Net effect `Remote::ls` was meant to catch, under the name git2 0.20
4728        // actually uses (there is no `ls` method left in the crate at all — that suffix rule is dead
4729        // weight, kept rather than removed since deleting it is a separate, unrelated cleanup).
4730        assert_eq!(classify("git2", "git2::Remote::list"), Some("Net"));
4731        assert_eq!(classify("git2", "git2::RemoteConnection::list"), Some("Net"));
4732        // `Cred::credential_helper` spawns a real `sh -c "<helper> get"` subprocess to resolve
4733        // `credential.helper` config (cred.rs:370-390) — a real consumer's most common way to honor it.
4734        assert_eq!(classify("git2", "git2::Cred::credential_helper"), Some("Exec"));
4735        assert_eq!(classify("git2", "git2::CredentialHelper::execute"), Some("Exec"));
4736        // NO FABRICATION controls: a `write`/`read`/`open` on some OTHER git2 type (not FQN-listed above)
4737        // must stay pure — this fix is FQN-exact, not a suffix re-widen.
4738        assert_eq!(classify("git2", "git2::Repository::state"), None);
4739        assert_eq!(classify("git2", "git2::Blob::content"), None);
4740        // tonic's SERVER half (the coverage-gate sweep found only the client verbs were covered):
4741        // `Router::serve`/`::serve_with_shutdown` bind a real listening socket via `TcpIncoming::new`
4742        // (`StdTcpListener::bind`), verified against tonic 0.12.3 source.
4743        assert_eq!(classify("tonic", "tonic::transport::server::Router::serve"), Some("Net"));
4744        assert_eq!(
4745            classify("tonic", "tonic::transport::server::Router::serve_with_shutdown"),
4746            Some("Net")
4747        );
4748        assert_eq!(classify("tonic", "tonic::TcpIncoming::new"), Some("Net"));
4749        // NO FABRICATION control: `Server<L>::serve_with_shutdown` (mod.rs:530) is a DIFFERENT,
4750        // `pub(crate)` method on a different type — not reachable by any consumer, and this fix does not
4751        // fabricate an effect for it by widening past the `Router::` FQN.
4752        assert_eq!(classify("tonic", "tonic::transport::server::Server::serve_with_shutdown"), None);
4753        // THE FIX: `Repository::clone`/`clone_recurse` and `RepoBuilder::clone` ARE libgit2's real
4754        // network clone — a corpus round found `git2::Repository::clone(url, path)` reporting ZERO
4755        // effects and passing `deny Net` at exit 0, because the bare `::clone` denylist meant to keep
4756        // out `Remote`'s derived `Clone` dup (below) also excluded the one thing the old comment named
4757        // as NOT meant to be excluded.
4758        assert_eq!(classify("git2", "git2::Repository::clone"), Some("Net"));
4759        assert_eq!(classify("git2", "git2::Repository::clone_recurse"), Some("Net"));
4760        assert_eq!(classify("git2", "git2::build::RepoBuilder::clone"), Some("Net"));
4761        // THE CONTROL: `Remote::clone` is the DERIVED `Clone` trait impl (an Arc/handle duplication, no
4762        // libgit2 call) — it must stay pure. Re-fabricating Net here would undo the very over-charge the
4763        // original exclusion existed to prevent. FQN-exact matching (not a bare `::clone` re-widen) keeps
4764        // this pure while catching `Repository::clone` above.
4765        assert_eq!(classify("git2", "git2::Remote::clone"), None);
4766        // THE SWEEP FIX: a fourth+fifth true positive in the SAME gap — `Submodule::clone`/
4767        // `Submodule::update` call `raw::git_submodule_clone`/`git_submodule_update` directly (the exact
4768        // leaves already in this file's FFI-tier NET table), but git2's documented submodule-init idiom
4769        // calls the safe Rust wrapper, which carried no rule of its own.
4770        assert_eq!(classify("git2", "git2::Submodule::clone"), Some("Net"));
4771        assert_eq!(classify("git2", "git2::Submodule::update"), Some("Net"));
4772        // NO FABRICATION: git2's many pure `update_*` setters (CheckoutBuilder/DiffOptions/StatusOptions)
4773        // must not gain Net — FQN-exact, not a bare `::update` substring.
4774        assert_eq!(classify("git2", "git2::build::CheckoutBuilder::update_only"), None);
4775        assert_eq!(classify("git2", "git2::DiffOptions::update_index"), None);
4776        assert_eq!(classify("git2", "git2::Submodule::update_strategy"), None);
4777        // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
4778        // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
4779        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
4780        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
4781        assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
4782        assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
4783        assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
4784        assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
4785        assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
4786        assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
4787        // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
4788        // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
4789        assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
4790        assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
4791        assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
4792        assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
4793        assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
4794        assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
4795        assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
4796        // R59-class audit: `file_list` is `Get`/`Set`'s sibling terminal beside `text`/`image`/`html`
4797        // (verified against arboard 3.6.1 — the only `file_list` in the crate, so no ambiguity survives
4798        // the crate gate); `Clear::default` is `clear()`'s own alternate entry point.
4799        assert_eq!(classify("arboard", "arboard::Get::file_list"), Some("Clipboard"));
4800        assert_eq!(classify("arboard", "arboard::Set::file_list"), Some("Clipboard"));
4801        assert_eq!(classify("arboard", "arboard::Clear::default"), Some("Clipboard"));
4802        // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
4803        // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
4804        assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
4805        assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
4806        assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
4807        assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
4808        assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
4809        assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
4810        assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
4811        // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
4812        // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
4813        assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
4814        assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
4815        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
4816        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
4817        assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
4818        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
4819        assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
4820        assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
4821        assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
4822        assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
4823        // FFI tiers (matched by distinctive leaf, alias-independent)
4824        assert_eq!(classify("libc", "libc::open"), Some("Fs"));
4825        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
4826        assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
4827        assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
4828        // SOUNDNESS R166 — the PROCESS-GLOBAL extension registry. `rusqlite::register_auto_extension`
4829        // calls `ffi::sqlite3_auto_extension` directly and read `[]` on the 0.34.0 binary: an explicit
4830        // empty effect set over a call that changes what every LATER `sqlite3_open` executes.
4831        assert_eq!(classify("ffi", "ffi::sqlite3_auto_extension"), Some("Db"));
4832        assert_eq!(classify("ffi", "ffi::sqlite3_cancel_auto_extension"), Some("Db"));
4833        assert_eq!(classify("ffi", "ffi::sqlite3_reset_auto_extension"), Some("Db"));
4834        assert_eq!(classify("ffi", "ffi::sqlite3_enable_load_extension"), Some("Db"));
4835        // …and the database-CONTENT I/O the same sweep found unlisted beside it.
4836        assert_eq!(classify("ffi", "ffi::sqlite3_deserialize"), Some("Db"));
4837        assert_eq!(classify("ffi", "ffi::sqlite3_serialize"), Some("Db"));
4838        assert_eq!(classify("ffi", "ffi::sqlite3_db_cacheflush"), Some("Db"));
4839        assert_eq!(classify("ffi", "ffi::sqlite3_file_control"), Some("Db"));
4840        assert_eq!(classify("ffi", "ffi::sqlite3_wal_autocheckpoint"), Some("Db"));
4841        // CONTROLS for the boundary this list deliberately stops at — an in-memory accessor and a
4842        // per-connection callback INSTALLER stay unclassified, so the additions above are not read as
4843        // "every `sqlite3_*` is Db". Both pass before and after R166; recorded as boundary, not coverage.
4844        assert_eq!(classify("ffi", "ffi::sqlite3_column_int64"), None);
4845        assert_eq!(classify("ffi", "ffi::sqlite3_create_function_v2"), None);
4846        assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
4847        // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
4848        // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
4849        // `src/build.rs` module was being dropped as if it were the Cargo build script).
4850        assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
4851        assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
4852        assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
4853        assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
4854        // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
4855        // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
4856        // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
4857        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
4858        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
4859        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
4860        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
4861        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
4862        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
4863        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
4864        // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
4865        assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
4866        assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
4867        assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
4868        assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
4869        assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
4870        assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
4871        // pure crates stay pure
4872        assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
4873        assert_eq!(classify("std", "std::vec::Vec::push"), None);
4874
4875        // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
4876        // [24] std::net socket accessors are pure; the I/O verbs stay Net.
4877        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
4878        assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
4879        assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
4880        assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
4881        assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
4882        // [37] std DNS resolution is Net (was floored).
4883        assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
4884        assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
4885        // [23] std::process getters are pure; spawn/new stay Exec.
4886        assert_eq!(classify("std", "std::process::Command::get_program"), None);
4887        assert_eq!(classify("std", "std::process::Command::get_args"), None);
4888        assert_eq!(classify("std", "std::process::Child::id"), None);
4889        assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
4890        // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
4891        assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
4892        assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
4893        // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
4894        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
4895        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
4896        assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
4897        // THE FIX: `Database::connect_proxy` (the `proxy`-feature sibling of `Database::connect`) opens
4898        // a live `DatabaseConnection` through a caller-supplied `ProxyDatabaseTrait` — same effect,
4899        // missing from an allowlist keyed on the plain `connect` spelling.
4900        assert_eq!(classify("sea_orm", "sea_orm::Database::connect_proxy"), Some("Db"));
4901        // THE COVERAGE-GATE SWEEP: the transaction and pagination families, verified against sea-orm
4902        // 1.1.20 source.
4903        assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::transaction"), Some("Db"));
4904        assert_eq!(
4905            classify("sea_orm", "sea_orm::DatabaseConnection::transaction_with_config"),
4906            Some("Db")
4907        );
4908        // BONUS (found proving the above reachable with a real fixture, not in the original ratchet):
4909        assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::ping"), Some("Db"));
4910        assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::begin"), Some("Db"));
4911        assert_eq!(
4912            classify("sea_orm", "sea_orm::DatabaseConnection::begin_with_config"),
4913            Some("Db")
4914        );
4915        assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::commit"), Some("Db"));
4916        assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::rollback"), Some("Db"));
4917        assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::begin"), Some("Db"));
4918        assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::ping"), Some("Db"));
4919        assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::transaction"), Some("Db"));
4920        assert_eq!(classify("sea_orm", "sea_orm::SqlxPostgresPoolConnection::begin"), Some("Db"));
4921        assert_eq!(classify("sea_orm", "sea_orm::SqlxSqlitePoolConnection::ping"), Some("Db"));
4922        assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch"), Some("Db"));
4923        assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch_and_next"), Some("Db"));
4924        assert_eq!(classify("sea_orm", "sea_orm::Paginator::into_stream"), Some("Db"));
4925        assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_pages"), Some("Db"));
4926        assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_items_and_pages"), Some("Db"));
4927        assert_eq!(classify("sea_orm", "sea_orm::Insert::exec_with_returning_keys"), Some("Db"));
4928        assert_eq!(classify("sea_orm", "sea_orm::Inserter::exec_with_returning_many"), Some("Db"));
4929        assert_eq!(classify("sea_orm", "sea_orm::TryInsert::exec_with_returning_keys"), Some("Db"));
4930        // NO FABRICATION: the mock/proxy backends share every one of these verb names but perform no
4931        // real I/O (mock) or dispatch through a caller-supplied trait (proxy) — FQN-exact, not a bare
4932        // `::ping`/`::begin`/`::transaction`/`::commit`/`::rollback` suffix, keeps them pure/unknown.
4933        assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::ping"), None);
4934        assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::begin"), None);
4935        assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::commit"), None);
4936        assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::ping"), None);
4937    }
4938
4939    #[test]
4940    fn lettre_tls_setup_and_connection_family_is_covered() {
4941        // THE COVERAGE-GATE SWEEP, verified against lettre 0.11.23 source. `build_rustls` calls
4942        // `rustls_native_certs::load_native_certs()` (a real OS-trust-store read) + `tracing::debug!`.
4943        assert_eq!(
4944            classify("lettre", "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"),
4945            Some("Fs")
4946        );
4947        assert_eq!(classify("lettre", "lettre::TlsParametersBuilder::build"), Some("Fs"));
4948        assert_eq!(classify("lettre", "lettre::TlsParameters::new"), Some("Fs"));
4949        assert_eq!(classify("lettre", "lettre::TlsParameters::new_rustls"), Some("Fs"));
4950        assert_eq!(classify("lettre", "lettre::SmtpTransport::from_url"), Some("Fs"));
4951        assert_eq!(classify("lettre", "lettre::SmtpTransport::relay"), Some("Fs"));
4952        assert_eq!(classify("lettre", "lettre::SmtpTransport::starttls_relay"), Some("Fs"));
4953        assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::from_url"), Some("Fs"));
4954        assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::relay"), Some("Fs"));
4955        assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::starttls_relay"), Some("Fs"));
4956        assert_eq!(classify("lettre", "lettre::FileTransport::read"), Some("Fs"));
4957        // The sealed `Executor` trait impl — `#[doc(hidden)]`, a narrower surface, but genuinely
4958        // reachable (both the trait and `AsyncStd1Executor` are `pub`, re-exported at the crate root).
4959        assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::connect"), Some("Net"));
4960        assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_read"), Some("Fs"));
4961        assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_write"), Some("Fs"));
4962        // Only reachable via the module-qualified path — neither type is re-exported at the crate root.
4963        assert_eq!(
4964            classify(
4965                "lettre",
4966                "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
4967            ),
4968            Some("Net")
4969        );
4970        assert_eq!(
4971            classify(
4972                "lettre",
4973                "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
4974            ),
4975            Some("Net")
4976        );
4977        // NO FABRICATION: `NetworkStream` lives in a PRIVATE module (`mod net;`, never re-exported) — no
4978        // external consumer can ever name it, so no rule was added despite self-scan flagging its
4979        // `shutdown`/`set_read_timeout`/`set_write_timeout` (propagated from the coarse
4980        // `std::net::TcpStream` whole-handle rule).
4981        assert_eq!(classify("lettre", "lettre::NetworkStream::shutdown"), None);
4982        assert_eq!(classify("lettre", "lettre::NetworkStream::set_read_timeout"), None);
4983    }
4984
4985    #[test]
4986    fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
4987        // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
4988        // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
4989        assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
4990        assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
4991        assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
4992        assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
4993        assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
4994        assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
4995    }
4996
4997    #[test]
4998    fn redis_connection_manager_config_builder_is_pure() {
4999        // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
5000        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
5001        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
5002        // the LIVE manager still round-trips (Db).
5003        assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
5004        assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
5005    }
5006
5007    #[test]
5008    fn redis_get_connection_info_is_a_pure_accessor_not_swept_by_the_substring() {
5009        // coverage-gate sweep (2026-08-27): `contains("::get_connection")` also matched
5010        // `Client::get_connection_info` (a pure accessor over an already-stored field) because it is a
5011        // literal substring of that name — the real connection-getters stay caught.
5012        assert_eq!(classify("redis", "redis::Client::get_connection_info"), None);
5013        assert_eq!(classify("redis", "redis::Client::get_connection"), Some("Db"));
5014        assert_eq!(classify("redis", "redis::Client::get_async_connection"), Some("Db"));
5015        assert_eq!(
5016            classify("redis", "redis::Client::get_multiplexed_async_connection"),
5017            Some("Db")
5018        );
5019    }
5020
5021    #[test]
5022    fn aws_config_load_from_env_is_loads_own_convenience_wrapper() {
5023        // THE FIX: `load_from_env()` is the crate's OWN "convenience wrapper" (its doc comment's words)
5024        // around `from_env().load().await` — the exact already-modelled effect one call down — but
5025        // "load_from_env".ends_with("::load") is false.
5026        assert_eq!(classify("aws_config", "aws_config::load"), Some("Net"));
5027        assert_eq!(classify("aws_config", "aws_config::load_defaults"), Some("Net"));
5028        assert_eq!(classify("aws_config", "aws_config::load_from_env"), Some("Net"));
5029        assert_eq!(classify("aws_config", "aws_config::from_env"), None); // pure builder
5030    }
5031
5032    #[test]
5033    fn tungstenite_stream_first_handshake_entry_points_are_net() {
5034        // THE FIX: `client`/`client_with_config`/`client_tls`/`client_tls_with_config` (client.rs:176,
5035        // 159; tls.rs:163,179) and `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config`
5036        // (server.rs:23-63) each run `{Client,Server}Handshake::start(stream, ..).handshake()` — the real
5037        // WS upgrade I/O over an already-open stream. tungstenite's OWN documented way to run over a
5038        // caller-managed TCP/TLS/mio stream, missing from a verb list keyed only on `connect`.
5039        assert_eq!(classify("tungstenite", "tungstenite::client::client"), Some("Net"));
5040        assert_eq!(classify("tungstenite", "tungstenite::client::client_with_config"), Some("Net"));
5041        assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls"), Some("Net"));
5042        assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls_with_config"), Some("Net"));
5043        assert_eq!(classify("tungstenite", "tungstenite::server::accept"), Some("Net"));
5044        assert_eq!(classify("tungstenite", "tungstenite::server::accept_with_config"), Some("Net"));
5045        assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr"), Some("Net"));
5046        assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr_with_config"), Some("Net"));
5047        assert_eq!(classify("tungstenite", "tungstenite::client::connect_with_config"), Some("Net"));
5048    }
5049
5050    #[test]
5051    fn mysql_conn_new_is_the_real_connect() {
5052        // THE FIX: `mysql::Conn::new` (conn/mod.rs:342) calls `connect_stream()?; connect()?` directly;
5053        // `mysql_async::Conn::new` (conn/mod.rs:921) is the async equivalent. Each crate's own primary
5054        // connection constructor, the crate's own first doctest example — but `::new` never appeared in
5055        // the verb list.
5056        assert_eq!(classify("mysql", "mysql::Conn::new"), Some("Db"));
5057        assert_eq!(classify("mysql_async", "mysql_async::Conn::new"), Some("Db"));
5058        // NO FABRICATION: the SAME two crates' unrelated pure `new`s (Opts/PoolConstraints/TxOpts/…)
5059        // must not gain Db — scoped to the `Conn::` segment, not a bare `::new` suffix.
5060        assert_eq!(classify("mysql", "mysql::Opts::new"), None);
5061        assert_eq!(classify("mysql", "mysql::PoolConstraints::new"), None);
5062        assert_eq!(classify("mysql_async", "mysql_async::TxOpts::new"), None);
5063    }
5064
5065    #[test]
5066    fn mongodb_with_options_is_the_same_effect_as_with_uri_str() {
5067        // THE FIX: `with_uri_str` (client.rs:179) is `ClientOptions::parse(uri).await?;
5068        // Client::with_options(options)` one call down — `with_options` (client.rs:188) is where the
5069        // topology/monitoring actually spins up, and mongodb's own doc calls it the entry point for a
5070        // caller who already holds parsed `ClientOptions`. Verified against mongodb 3.8.1 (both the
5071        // async `client.rs` and sync `sync/client.rs` `Client`).
5072        assert_eq!(classify("mongodb", "mongodb::Client::with_uri_str"), Some("Db"));
5073        assert_eq!(classify("mongodb", "mongodb::Client::with_options"), Some("Db"));
5074        assert_eq!(classify("mongodb", "mongodb::sync::Client::with_options"), Some("Db"));
5075        assert_eq!(classify("mongodb", "mongodb::Collection::find_one"), Some("Db"));
5076        // NO FABRICATION: an unrelated crate's `with_options` builder must not gain Db — crate-gated.
5077        assert_eq!(classify("some_other_crate", "some_other_crate::Widget::with_options"), None);
5078    }
5079
5080    #[test]
5081    fn pure_fd_transfer_is_not_an_effect() {
5082        // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
5083        // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
5084        // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
5085        // `as_raw_fd` all fabricated effects.)
5086        assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
5087        assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
5088        assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
5089        assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
5090        assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
5091        assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
5092        assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
5093        assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
5094        assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
5095        // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
5096        assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
5097        assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
5098        assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
5099        assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
5100        // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
5101        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
5102        assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
5103        assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
5104        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
5105        assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
5106    }
5107
5108    /// THE PLATFORM `fs` MODULES. `std::fs::` was the whole filesystem rule, so every function in
5109    /// `std::os::{unix,windows,wasi}::fs` read PURE — measured with EXECUTED ground truth (a `cargo run`
5110    /// that created a real symlink and stat'd it back), `deny Fs` over a crate whose only filesystem
5111    /// write was `std::os::unix::fs::symlink` exited 0.
5112    ///
5113    /// The controls are the point: the `*Ext` DATA/BUILDER traits must stay pure, or the fix trades a
5114    /// silent under-report for a fabrication on every `m.uid()` — and `FileExt` must NOT, because
5115    /// `read_at`/`write_at` are the positional-I/O syscalls this rule exists to catch.
5116    #[test]
5117    fn the_platform_fs_modules_are_filesystem_io_and_their_data_traits_are_not() {
5118        for p in [
5119            "std::os::unix::fs::symlink", "std::os::unix::fs::chown", "std::os::unix::fs::lchown",
5120            "std::os::unix::fs::fchown", "std::os::unix::fs::chroot",
5121            "std::os::windows::fs::symlink_dir", "std::os::windows::fs::symlink_file",
5122            "std::os::windows::fs::junction_point",
5123            "std::os::unix::fs::FileExt::read_at", "std::os::unix::fs::FileExt::write_at",
5124            "std::os::windows::fs::FileExt::seek_read", "std::os::windows::fs::FileExt::seek_write",
5125        ] {
5126            assert_eq!(classify("std", p), Some("Fs"), "{p} performs filesystem I/O");
5127        }
5128        for p in [
5129            "std::os::unix::fs::MetadataExt::uid", "std::os::unix::fs::MetadataExt::mode",
5130            "std::os::unix::fs::PermissionsExt::mode", "std::os::unix::fs::PermissionsExt::set_mode",
5131            "std::os::unix::fs::OpenOptionsExt::custom_flags",
5132            "std::os::unix::fs::DirBuilderExt::mode",
5133            "std::os::unix::fs::DirEntryExt::ino", "std::os::unix::fs::FileTypeExt::is_fifo",
5134            "std::os::windows::fs::MetadataExt::file_attributes",
5135        ] {
5136            assert_eq!(classify("std", p), None,
5137                       "{p} reads or configures data already in hand — charging it would FABRICATE Fs");
5138        }
5139        // `std::os::unix::net` is Ipc and must be untouched by a rule keyed one segment along.
5140        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
5141    }
5142
5143    #[test]
5144    fn command_head_refines_the_exec_cliff() {
5145        use super::classify_command_head as h;
5146        // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
5147        assert_eq!(h("curl"), &["Net"]);
5148        assert_eq!(h("telnet"), &["Net"]);
5149        assert_eq!(h("sftp"), &["Net"]);
5150        assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
5151        assert_eq!(h("mongo"), &["Db"]);
5152        assert_eq!(h("cqlsh"), &["Db"]);
5153        // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
5154        assert_eq!(h("candor-scan"), &["Env", "Fs"]);
5155        assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
5156        // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
5157        // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
5158        // cliff rather than fabricate an effect for the common case.
5159        assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
5160        assert_eq!(h("make"), &[] as &[&str]);
5161        assert_eq!(h("npm"), &[] as &[&str]);
5162        assert_eq!(h("git"), &[] as &[&str]);
5163        assert_eq!(h("rsync"), &[] as &[&str]);
5164        // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
5165        // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
5166        assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
5167        assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
5168        assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
5169        // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
5170        // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
5171        // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
5172        // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
5173        // name a program, so only they may refine.
5174        assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
5175        assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
5176        assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
5177    }
5178
5179    /// SOUNDNESS R379 — THE MASKING GUARD'S ALLOWLIST, AND THE DENYLIST THAT WAS MEASURED AND REJECTED.
5180    ///
5181    /// The verbs here take the HOST as an argument, so a runtime value in that position is invisible to
5182    /// the gate and the surface must be marked incomplete. The DNS resolver verbs were missing, and
5183    /// their absence was a GATE BYPASS, not a missed disclosure: `allow Net good.example.com` exited 0
5184    /// over `r.lookup_ip(caller_host)` sitting beside a benign literal, measured against real
5185    /// hickory-resolver 0.26.2.
5186    /// SOUNDNESS R386 — the `sqlite3_*` C API takes SQL, and its absence let a benign SIBLING literal
5187    /// certify arbitrary caller-supplied SQL.
5188    ///
5189    /// R383 recorded this gap as "REAL BUT NOT LIVE" because a C-string literal never reaches `tables`.
5190    /// That is true of an ISOLATED function and is the WRONG SHAPE: the literal only has to come from
5191    /// the same FUNCTION, not the same call. Measured, calibrated first (`deny Db` exits 1):
5192    /// `conn.execute("INSERT INTO users (id) VALUES (1)", [])` beside `sqlite3_exec(db, caller_sql, …)`
5193    /// gave `tables:['users'] incomplete:NONE` and `allow Db users` exited 0.
5194    #[test]
5195    fn db_query_arg_covers_the_sqlite3_sql_bearing_family() {
5196        // Every spelling that TAKES SQL — the whole family, not the one that was measured (R346).
5197        for f in ["sqlite3_exec", "sqlite3_prepare", "sqlite3_prepare_v2", "sqlite3_prepare_v3",
5198                  "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
5199                  // R386's own boundary, found by a release panel: SQL is arg 1 here too.
5200                  "sqlite3_get_table"] {
5201            assert!(is_db_query_arg(f), "{f} carries SQL — a runtime query there is invisible (R386)");
5202        }
5203        // THE OVER-MASK CONTROLS, and they carry as much weight as the fix. These take no query, so
5204        // masking them would fail `allow Db` closed over nothing. `sqlite3_open*` is the sharpest: it
5205        // takes a FILENAME, so masking it would claim a Db surface for an Fs locator.
5206        for f in ["sqlite3_step", "sqlite3_close", "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16",
5207                  "sqlite3_serialize", "sqlite3_deserialize", "sqlite3_finalize", "sqlite3_reset",
5208                  // the backup/blob handle verbs carry no query either — the other half of the family
5209                  "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_blob_open", "sqlite3_blob_read"] {
5210            assert!(!is_db_query_arg(f),
5211                    "{f} carries no SQL — masking it claims an incomplete surface over nothing, and for \
5212                     the open* family it would claim a Db surface for a FILENAME");
5213        }
5214        // The pre-existing high-level verbs must survive.
5215        for f in ["execute", "query", "prepare", "prepare_cached", "simple_query"] {
5216            assert!(is_db_query_arg(f), "{f} was already covered and must stay covered");
5217        }
5218    }
5219
5220    /// SOUNDNESS R383 — the METHOD forms whose path is an ARGUMENT. The guard's `!c.method` gate
5221    /// excluded every method form because the path-STAT methods carry the path as their RECEIVER;
5222    /// `OpenOptions::open(path)` is the counter-example, and `allow Fs <lit>` exited 0 over a
5223    /// caller-supplied path because of it.
5224    #[test]
5225    fn fs_path_arg_methods_are_the_receiver_pairs_not_the_leaf() {
5226        // The path-taking pairs, matched on the TAIL so std/tokio/cap_primitives/fs_err all qualify.
5227        for p in ["std::fs::OpenOptions::open", "tokio::fs::OpenOptions::open",
5228                  "cap_primitives::fs::OpenOptions::open", "fs_err::OpenOptions::open",
5229                  "std::fs::DirBuilder::create", "tokio::fs::DirBuilder::create"] {
5230            assert!(is_fs_path_arg_method(p), "{p} takes its path as an ARGUMENT — R383");
5231        }
5232        // THE CONTROLS, and they are the row: every other method on the SAME receivers is a builder
5233        // modifier or a combinator, and `File`'s methods are use-verbs whose path was fixed at open.
5234        // Masking these is the over-mask a denylist would have caused (R379 measured that cost).
5235        for p in ["std::fs::File::read_to_string", "std::fs::File::write", "std::fs::File::write_all",
5236                  "std::fs::File::read", "std::fs::File::seek", "std::path::Path::exists",
5237                  "std::fs::OpenOptions::read", "std::fs::OpenOptions::truncate",
5238                  "std::fs::OpenOptions::map_err", "tokio::fs::OpenOptions::mode"] {
5239            assert!(!is_fs_path_arg_method(p),
5240                    "{p} hides no path — it is a builder modifier, a combinator, or a use-verb whose \
5241                     path was fixed at open. Masking it is the over-mask this allowlist avoids.");
5242        }
5243    }
5244
5245    #[test]
5246    fn net_establishing_allowlist() {
5247        // Host-bearing verbs establish — a runtime value here is invisible, so the gate must fail closed.
5248        for m in ["connect", "connect_timeout", "get", "post", "put", "patch", "delete", "head",
5249                  "request", "send_to", "lookup_host", "to_socket_addrs"] {
5250            assert!(is_net_establishing(m), "`{m}` takes the host as an argument and must establish");
5251        }
5252        // R379: the DNS resolver verbs `classify` already maps to Net. The WHOLE FAMILY, not the one
5253        // spelling that was found — `_lookup` is a suffix test mirroring `classify`'s own.
5254        for m in ["lookup", "lookup_ip", "resolve", "reverse_lookup", "srv_lookup", "mx_lookup",
5255                  "txt_lookup", "ipv4_lookup", "ipv6_lookup", "ns_lookup", "soa_lookup"] {
5256            assert!(is_net_establishing(m),
5257                    "`{m}` resolves a caller-supplied host — R379. Without it, `allow Net <benign \
5258                     literal>` exits 0 over a DNS lookup of a runtime hostname.");
5259        }
5260        // USE-verbs on an already-established connection: the host was fixed at `connect`, so a missing
5261        // literal here hides nothing and must NOT flag — else `connect("h").write(..)` fails closed.
5262        for m in ["write", "read", "send", "flush", "recv", "peek", "write_all", "read_to_end",
5263                  "write_vectored", "shutdown", "status", "headers", "text", "json"] {
5264            assert!(!is_net_establishing(m), "`{m}` is a use-verb on an established connection");
5265        }
5266    }
5267
5268    /// R379 — WHY THIS STAYS AN ALLOWLIST, which is NOT this family's usual direction.
5269    ///
5270    /// `candor-denylist-over-allowlist` says narrow a sound over-approximation with a denylist, and
5271    /// R350 made exactly that inversion one list over. It is wrong HERE, for the reason
5272    /// `is_cmd_naming_method` states two doc-blocks above for Exec: a WHOLE-CRATE-Net crate classifies
5273    /// every method as Net, so a denylist of use-verbs masks essentially everything in one. Measured by
5274    /// building the inversion and A/B-ing all 1,545 registry crates: **544 rows gained `incomplete`,
5275    /// none lost it** — `cap-std` 66, `hickory-net` 44 — and the triggers were conversions, accessors
5276    /// and combinators that hide no host.
5277    ///
5278    /// These are the measured leaks, pinned so the inversion cannot be re-attempted without meeting
5279    /// them. `bind` is the sharpest: this file has `is_net_local_bind` precisely because a bind address
5280    /// is where the process LISTENS, never a destination it reaches.
5281    #[test]
5282    fn the_rejected_denylist_leaks_are_not_establishing() {
5283        for m in ["bind", "into", "from", "from_std", "as_socket", "setsockopt", "getsockopt",
5284                  "map_err", "map", "ok", "try_clone", "complete_io"] {
5285            assert!(!is_net_establishing(m),
5286                    "`{m}` hides no host and must not mark a surface incomplete. It was a MEASURED \
5287                     trigger of the rejected denylist inversion (544 rows gained `incomplete` over the \
5288                     registry, 0 lost) — a whole-crate-Net crate classifies every method as Net, which \
5289                     is why the allowlist is the precise side here. See R379.");
5290        }
5291    }
5292
5293    #[test]
5294    fn fs_path_arg_allowlist() {
5295        // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
5296        // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
5297        // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
5298        // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
5299        assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
5300        assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
5301        assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
5302        assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
5303        // handle ops / pure builders take NO path arg — never path-naming.
5304        assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
5305        assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
5306    }
5307
5308    #[test]
5309    fn db_query_arg_allowlist() {
5310        // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
5311        // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
5312        // ops carry no SQL string and must NOT flag.
5313        assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
5314        assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
5315        assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
5316        // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
5317        assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
5318        assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
5319        // connection / lifecycle ops take no SQL — must NOT flag.
5320        assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
5321        assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
5322    }
5323}
5324
5325#[cfg(test)]
5326mod fs_kind_tests {
5327    use super::fs_kind;
5328
5329    /// SPEC §2 `fs`. Most of what these assert is what the classifier REFUSES to say: §2 requires the
5330    /// field be "omitted rather than guessed", because an empty or partial `fs` reads as a positive claim
5331    /// ("reads but never writes") — the §4 trust contract's forbidden direction.
5332    #[test]
5333    fn write_verbs() {
5334        for p in ["std::fs::write", "std::fs::create_dir_all", "std::fs::remove_file",
5335                  "File::create", "std::fs::set_permissions", "f::write_all"] {
5336            assert_eq!(fs_kind(p), &["write"], "{p} mutates the disk");
5337        }
5338    }
5339
5340    #[test]
5341    fn read_verbs() {
5342        for p in ["std::fs::read_to_string", "std::fs::read_dir", "std::fs::metadata",
5343                  "File::open", "std::fs::canonicalize", "f::read_to_end"] {
5344            assert_eq!(fs_kind(p), &["read"], "{p} observes without mutating");
5345        }
5346    }
5347
5348    /// A copy/rename reads the source AND writes the destination — one call, both kinds.
5349    #[test]
5350    fn two_locator_verbs_are_both() {
5351        for p in ["std::fs::copy", "std::fs::rename", "std::fs::hard_link"] {
5352            assert_eq!(fs_kind(p), &["read", "write"], "{p}");
5353        }
5354    }
5355
5356    /// THE LOAD-BEARING CASE. A verb that does not reveal direction must contribute NOTHING — not a
5357    /// default, not a guess. Anything here returning a kind would let a function claim "reads but never
5358    /// writes" on the strength of a verb that said neither.
5359    #[test]
5360    fn unrevealing_verbs_make_no_claim() {
5361        for p in ["std::fs::OpenOptions", "some_crate::do_thing", "std::fs::File", "f::seek"] {
5362            assert!(fs_kind(p).is_empty(), "{p} must not claim a direction it did not reveal");
5363        }
5364    }
5365
5366    /// `OpenOptions::open` is `File::open`'s leaf and NOT its verb: the direction lives in the builder
5367    /// chain (`.read(true)`/`.write(true)`), which `fs_kind` cannot see. A `write(true)` builder claiming
5368    /// `["read"]` is the forbidden direction, so the type wins over the leaf here. `File::open` — where
5369    /// `open` DOES mean read — is unaffected, which is the whole reason this is keyed on the type.
5370    #[test]
5371    fn openoptions_open_claims_no_direction_but_file_open_still_reads() {
5372        for p in ["std::fs::OpenOptions::open", "fs_err::OpenOptions::open",
5373                  "tokio::fs::OpenOptions::open"] {
5374            assert!(fs_kind(p).is_empty(),
5375                    "{p}'s direction was set by the builder chain, which this function cannot see");
5376        }
5377        assert_eq!(fs_kind("std::fs::File::open"), &["read"], "`File::open` is unambiguously a read");
5378    }
5379}