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/// Crates REVIEWED AND FOUND TO PERFORM NO EFFECT OF THEIR OWN — the κ ledger treats them as covered, so
122/// their calls stop being disclosed blind spots.
123///
124/// SEPARATE FROM `CALIBRATED_CRATES` BY NECESSITY, not taste. That list means "classify has effect rules
125/// here", and `calibrated_crates_are_live` fails any entry no rule matches — "a dead entry would silently
126/// suppress a real coverage warning". A genuinely pure crate has no rule to be live, so it cannot go
127/// there; without this list the only way to silence its noise would be to invent a rule, which is worse.
128///
129/// **THIS LIST MANUFACTURES PURITY CLAIMS, so an entry needs evidence and not a reputation.** A crate here
130/// stops being disclosed and starts being believed. Each of these was checked against its source in the
131/// local cargo registry for `std::{fs,net,process,env}` and stdio use, and every apparent hit was a DOC
132/// COMMENT (serde_json's `/// [`File`]: std::fs::File`, serde_yml's `///  io::stdout()`):
133///
134///   serde_json 1.0.151, serde_yml 0.0.12, toml 1.1.3, regex 1.13.1, sha2 0.11.0
135///
136/// `color_eyre` was on the same filing and is NOT here — FETCHED AND CHECKED 2026-08-03, and it is not
137/// pure. It reads `RUST_BACKTRACE` / `RUST_LIB_BACKTRACE` / `RUST_SPANTRACE` / `COLORBT_SHOW_HIDDEN`
138/// (Env, `config.rs:939..1175`) and **opens source files to render code snippets** (Fs,
139/// `config.rs:248`). It is also not CALIBRATED, deliberately: the `File::open` sits inside
140/// `impl fmt::Display for SourceSection`, reached when a report is RENDERED rather than through any named
141/// verb a caller invokes — so there is no path for a rule to match, and calibrating the crate would turn
142/// that render path into an unmatched path, i.e. a PURITY CLAIM over the file read. Its calls therefore
143/// stay disclosed as a blind spot. The noise is real; it is also honest, and that is the right trade.
144///
145/// THE SERIALIZER CAVEAT, worth stating because it is the one that looks wrong: `serde_json::from_reader`
146/// and `to_writer` do move bytes — but through a handle the CALLER had to obtain, and obtaining it (a
147/// `File::open`, a `TcpStream::connect`) is already classified on the caller. The crate performs no
148/// syscall of its own, so charging it would double-count an effect the caller already carries.
149pub const REVIEWED_PURE_CRATES: [&str; 5] = ["serde_json", "serde_yml", "toml", "regex", "sha2"];
150
151/// The completeness gate's escape hatch (`eval/coverage-gate/`, `tests/coverage_gate.rs`): exact
152/// (crate, consumer-facing path) pairs a human has read and found genuinely pure, despite `candor-scan`
153/// self-scanning the crate's own vendored source finding a reachable Fs/Net/Db/Exec effect there. SEPARATE
154/// from `REVIEWED_PURE_CRATES` — that list exempts a WHOLE crate from the coverage LEDGER's crate-level
155/// disclosure; this one exempts a single ENTRY from the completeness GATE's per-function differential.
156/// The gate's generator run (2026-08-27) found 669 self-scan-confirmed entries `classify()` already
157/// recognizes (checked in as `covered.tsv`, asserted every push) and 251 it did not yet recognize under
158/// any guessed spelling (checked in as `open.tsv`, a ratchet — NOT individually hand-verified, see that
159/// file's header). An `open.tsv` row belongs here ONLY once read against the crate's real source and
160/// confirmed to perform no effect (the SAME evidence bar `REVIEWED_PURE_CRATES` documents) — until then
161/// it stays in the ratchet as an open question, not a silent assumption. The five below were the first
162/// batch, from the 2026-08-27 coverage-gate triage:
163///
164///   - `curl::Multi::timeout` — this file's own `curl` rule (above) already documents WHY: `Easy::
165///     timeout` is a pure `CURLOPT_TIMEOUT` setter sharing the `::timeout` leaf, and an under-report on
166///     the rare event-loop kick beats mis-tagging every consumer that sets a timeout.
167///   - `execute::command` / `execute::shell` — this file's own `execute` rule (above) already documents
168///     WHY: both free functions only BUILD a `std::process::Command` (no `.spawn()`/`.output()`/
169///     `.status()` call in either body) and return it to the caller, who spawns it themselves — the
170///     caller's own `Command::spawn()` carries the effect. Self-scan's raw "Exec" signal comes from
171///     `std::process::Command::new`'s coarse whole-type rule (constructing a `Command` is Exec by
172///     default, narrowed only for crates like `async_process` that document their own pure-setter
173///     surface), not from an actual subprocess launch in either function.
174///   - `elasticsearch::Response::content_type` — a pure accessor reading a header off an ALREADY-received
175///     response (`self.response.headers().get(..)`, http/response.rs:61) — no I/O of its own. Self-scan's
176///     raw "Net" signal is the underlying HTTP client response type's coarse whole-type propagation, not
177///     a second network round-trip.
178///   - `rusqlite::Context::get_connection` — this file's own rusqlite rule (above) already documents WHY:
179///     it hands back a `ConnectionRef` BORROWED from the ALREADY-established connection a running SQL
180///     function executes within (`ffi::sqlite3_context_db_handle`, not a syscall) — the same "handle
181///     accessor, not a syscall" shape as `TcpStream::local_addr`.
182///
183/// The next two (2026-08-28, the widened-CORE audit's `Ipc` batch — see `eval/coverage-gate/generate.py`
184/// for why `Ipc` only entered the trigger set that day) are a DIFFERENT reason than the five above: not
185/// "no effect", but genuinely UNREACHABLE — the item really does perform the effect self-scan found, but
186/// no external consumer can ever name it, because the module it lives in is declared with no `pub` at
187/// all and neither the module nor the item is re-exported anywhere in the crate root (checked against
188/// real source, both ways):
189///   - `dialoguer::Paging::render_prompt` — `dialoguer::paging` is a bare `mod paging;` (dialoguer
190///     0.12.0, lib.rs:62: `use paging::Paging;`, not `pub use`), so `pub struct Paging` and its `pub fn
191///     render_prompt` (which DOES call `Term::flush`, a real Ipc effect per this file's own `console`
192///     rule) are internal machinery every `Select`/`MultiSelect`-style prompt uses to render its own
193///     paginated view — the effect is already charged at THOSE public entry points' own `::interact*`
194///     rule (above), not double-counted here.
195///   - `mysql::Stream::connect_socket` — `mysql::io` is a bare `mod io;` (mysql 28.0.0, lib.rs:897, no
196///     `pub use` of `io::*` or `Stream` anywhere), so `pub enum Stream` and its `pub fn connect_socket`
197///     (a real Unix-domain-socket `connect()`, Ipc) are reachable only from within the crate's own
198///     connection-establishment code — a real consumer can't name `mysql::Stream` at all, let alone call
199///     a bare constructor on it.
200pub const REVIEWED_PURE_ENTRIES: &[(&str, &str)] = &[
201    ("curl", "curl::Multi::timeout"),
202    ("execute", "execute::command"),
203    ("execute", "execute::shell"),
204    ("elasticsearch", "elasticsearch::Response::content_type"),
205    ("rusqlite", "rusqlite::Context::get_connection"),
206    ("dialoguer", "dialoguer::Paging::render_prompt"),
207    ("mysql", "mysql::Stream::connect_socket"),
208];
209
210/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
211/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
212/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
213/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
214/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
215/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
216pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
217    "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
218    "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
219    "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
220    "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
221    "::X::draw",
222    "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
223    // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
224    // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
225    "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
226    // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
227    // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
228    "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
229    "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
230    "::write_line", "::background_color", "::retry", "::build",
231];
232
233/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
234/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
235pub const DB_CRATES: [&str; 11] = [
236    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
237    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
238];
239
240/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
241/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
242/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
243/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
244/// 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
245/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
246/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
247/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
248/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
249/// fabricated Net/Fs/Ipc.)
250const PURE_FD_TRANSFER: &[&str] = &[
251    "from_raw_fd", "from_raw_socket", "from_raw_handle",
252    "into_raw_fd", "into_raw_socket", "into_raw_handle",
253    "as_raw_fd", "as_raw_socket", "as_raw_handle",
254    "into_std",
255    // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
256    // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
257    // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
258    "from_pathname",
259];
260
261/// The std I/O **handle** types whose methods the scanner's receiver inference may route into
262/// `classify` as `Type::method`.
263///
264/// WHY A LIST AND NOT "std". The std rules in `classify` are a MIXTURE: a few are type-precise with
265/// reviewed pure-accessor carve-outs (`std::net::TcpStream` → Net minus `local_addr`/`peer_addr`/…;
266/// `std::process::Command` → Exec minus `get_program`/`get_args`/…), but most are coarse MODULE
267/// prefixes (`std::fs::` → Fs) written for free functions and constructors. Applied to arbitrary
268/// method calls, a coarse prefix charges an effect for reading a struct field: `std::fs::Metadata`,
269/// `DirEntry`, `Permissions` and `FileType` all sit under `std::fs::` and are pure DATA. So the
270/// scanner used to skip EVERY std-rooted receiver — which shut the door on the pure data types and on
271/// the real handles alike, and `fn run(cmd: &mut Command) { cmd.spawn(); }` certified PURE while it
272/// spawned a process (a silent false all-clear; the `cc` crate's `command_helpers::spawn` was a live
273/// instance). This list re-opens the door for the handles ONLY.
274///
275/// MEMBERSHIP RULE, and the reason it is safe to apply a whole-type rule to an inferred method call:
276/// a type belongs here only when EVERY method on it is either the effect itself or an already-carved-
277/// out pure read-back. That holds for an open descriptor's handle — a `File`/`TcpStream`/`Child` does
278/// nothing but I/O on the thing it owns — and the `PURE_FD_TRANSFER` guard at the top of `classify`
279/// exempts the `as_raw_fd`/`into_std` family for all of them before any prefix rule runs.
280///
281/// THE OPTION-BUILDERS, `OpenOptions` and `DirBuilder`, were absent for exactly this rule and now
282/// satisfy it: each has a type-keyed carve-out in `classify` subtracting its pure setters, so only its
283/// TERMINAL VERB (`open` / `create`) is Fs. The exclusion was written as "a narrow residual, not the
284/// reported hole" and MEASURED otherwise — `fn load(o: &OpenOptions, p: &Path) { o.open(p) }` opened a
285/// file and reported NOTHING, the same silent false all-clear as the `Command` parameter, while a
286/// setter-only `OpenOptions::new().read(true)` reported `Fs` for opening nothing. The order the old
287/// comment prescribed is the order taken: the verb-precise rules went in FIRST, and this list widened
288/// onto them. `ReadDir` stays out — its `next` IS a syscall, but nothing distinguishes it from any
289/// other iterator's `next`, so no verb-precise rule can be written and the honest miss stands.
290///
291/// `std::path::Path`/`PathBuf` are NOT here: they are pure data with an effectful STAT sub-surface —
292/// the exact inverse shape — and they route through their own verb-precise carve-out in the scanner.
293const STD_EFFECT_HANDLES: &[&str] = &[
294    // Exec — the whole type is the subprocess boundary (`new` names a program, `arg`/`env` build the
295    // invocation, `spawn`/`output`/`status` run it); the read-back getters are already carved out.
296    "std::process::Command",
297    "std::process::Child",
298    // Net — an open socket. `connect`/`accept`/`read`/`write`/`shutdown`/`set_*` are socket syscalls;
299    // `local_addr`/`peer_addr`/`nodelay`/`ttl`/`take_error` are carved out.
300    "std::net::TcpStream",
301    "std::net::TcpListener",
302    "std::net::UdpSocket",
303    // Ipc — a Unix-domain socket, the same shape as the TCP/UDP handles above.
304    "std::os::unix::net::UnixStream",
305    "std::os::unix::net::UnixListener",
306    "std::os::unix::net::UnixDatagram",
307    // Fs — an open file. Every method reads, writes, syncs, truncates or stats it; the descriptor
308    // conversions are carved out by `PURE_FD_TRANSFER`.
309    "std::fs::File",
310    // Fs — the option-builders, whose PURE setters are subtracted by the type-keyed carve-outs in
311    // `classify` so that only the terminal verb (`OpenOptions::open`, `DirBuilder::create`) routes to Fs.
312    "std::fs::OpenOptions",
313    "std::fs::DirBuilder",
314];
315
316/// Is `ty` a std I/O handle type whose methods may be routed into `classify` by the scanner's
317/// receiver inference? See `STD_EFFECT_HANDLES` for the membership rule and the named exclusions.
318pub fn is_std_effect_handle(ty: &str) -> bool {
319    STD_EFFECT_HANDLES.contains(&ty)
320}
321
322/// Classify a resolved callee by the crate it belongs to and its full path.
323pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
324    // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
325    // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
326    if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
327        return None;
328    }
329    if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
330        // Only request dispatch is network I/O; builder setters/accessors are pure.
331        if path.ends_with("::send") || path.ends_with("::send_with") {
332            return Some("Net");
333        }
334        return None;
335    }
336    // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
337    // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
338    // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
339    // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
340    //
341    // THE FIX: `load_from_env()` (lib.rs:170/188, both the `behavior-version-latest` and deprecated
342    // forms) is `from_env().load().await` / `load_defaults(latest).await` one call down — the crate's OWN
343    // "convenience wrapper" (its own doc comment's phrase) around the already-modelled `.load()` — but
344    // `"load_from_env".ends_with("::load")` is false, so the wrapper read pure. Same vein as
345    // `ignore::Walk::new`.
346    if crate_name == "aws_config" {
347        if path.ends_with("::load") || path.ends_with("::load_defaults") || path.ends_with("::load_from_env") {
348            return Some("Net");
349        }
350        return None;
351    }
352    // git2 (libgit2 FFI): remote operations contact the network; everything else is local
353    // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
354    // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
355    // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
356    // client reporting it makes no network calls.)
357    //
358    // `Repository::clone`/`clone_recurse`/`clone_local`/`init` (via `RepoBuilder::clone`) IS the
359    // thing the comment above named and then excluded anyway: the bare `::clone` denylist meant
360    // to keep out `Remote::clone` (the derived trait dup) also swallowed `Repository::clone` —
361    // libgit2's actual network clone, and arguably git2's single most common entry point. A
362    // fabrication corpus round caught it: `git2::Repository::clone(url, path)` reported ZERO
363    // effects and passed `deny Net` at exit 0. FQN-exact (like the `reqwest::get` disambiguation
364    // a few lines up) so the fix doesn't just re-widen `::clone` and reintroduce the very
365    // over-charge the comment was written to prevent: `Remote::clone` (and `Repository::clone`'s
366    // own `Clone` derive, if it has one) must still read pure.
367    if crate_name == "git2" {
368        if path.ends_with("::fetch")
369            || path.ends_with("::push")
370            || path.ends_with("::download")
371            || path.ends_with("::connect")
372            || path.ends_with("::connect_auth")
373            || path.ends_with("::ls")
374            || path.ends_with("::upload")
375            || path == "git2::Repository::clone"
376            || path == "git2::Repository::clone_recurse"
377            || path == "git2::build::RepoBuilder::clone"
378            // A FOURTH+FIFTH true positive in the same gap the `Repository::clone` fix left open:
379            // `Submodule::clone`/`Submodule::update` (submodule.rs:39,234) call `raw::git_submodule_clone`/
380            // `git_submodule_update` DIRECTLY — the exact FFI leaves already in this file's own
381            // FFI-tier NET table below, but only when a caller names the raw `ffi`/`raw::` leaf itself.
382            // git2's documented, standard submodule-init idiom (`sub.clone(None)` /
383            // `sub.update(true, None)`) never does that — it calls the safe wrapper, which carried no
384            // rule of its own and read pure under `deny Net`. FQN-exact, matching the `Repository::clone`
385            // fix's own discipline: a bare `::update`/`::clone` substring would sweep in git2's many pure
386            // `update_*` setters (`CheckoutBuilder::update_only`, `DiffOptions::update_index`, …) and the
387            // derive-`Clone` dup on every other git2 type.
388            || path == "git2::Submodule::clone"
389            || path == "git2::Submodule::update"
390            // `Remote::list`/`RemoteConnection::list` (remote.rs:378,755) call `raw::git_remote_ls`
391            // directly — the reference-advertisement fetch already in the FFI-tier NET table below,
392            // reached the same way `Submodule::clone`/`update` were: the safe wrapper's OWN name
393            // (`list`, not `ls`) never matched the `::ls` suffix this block already carried (which, on
394            // git2 0.20, matches no real method at all — dead weight kept for whatever older API it once
395            // fit). FQN-exact so a `list` on some other git2 type (none exist today, but the same
396            // discipline as `Submodule::clone` above) can't be swept in by a bare suffix later.
397            || path == "git2::Remote::list"
398            || path == "git2::RemoteConnection::list"
399        {
400            return Some("Net");
401        }
402        // THE COVERAGE-GATE SWEEP (2026-08-27): every one of these is a public entry point whose body
403        // calls a `raw::git_*` FFI leaf ALREADY in this file's own FS FFI-tier table below (config.rs/
404        // index.rs/odb.rs/packbuilder.rs/reference.rs/repo.rs/treebuilder.rs, verified against git2
405        // 0.20.4 source) — but that table is UNREACHABLE for a real consumer's call, because it only
406        // fires when self-scan resolves the FFI leaf's OWN crate (`libgit2_sys`, via git2's internal
407        // `use libgit2_sys as raw`), never for `crate_name == "git2"`, which returns from THIS block
408        // first. A consumer never spells `raw::git_repository_open`; they call `git2::Repository::open`,
409        // which fell through this entire branch to `None` — the exact `ignore::Walk::new` shape (a
410        // lower-level rule the top-level entry point never reaches) but affecting git2's single most
411        // common local operations (`Repository::open`/`init`), not an edge case. FQN-exact, not a
412        // `::open`/`::write`/`::read` suffix — those verbs are common enough elsewhere (`Odb::reader`
413        // isn't `::read`, `TreeBuilder`/`Index`/`PackBuilder::write` share the name with plenty of pure
414        // builder setters on other types) that a bare suffix would either miss these or over-charge.
415        if path == "git2::Config::add_file"
416            || path == "git2::Config::open"
417            || path == "git2::Config::open_default"
418            || path == "git2::Index::add_all"
419            || path == "git2::Index::add_path"
420            || path == "git2::Index::read"
421            || path == "git2::Index::write"
422            || path == "git2::Index::write_tree"
423            || path == "git2::Index::write_tree_to"
424            || path == "git2::Odb::read"
425            || path == "git2::Odb::reader"
426            || path == "git2::Odb::write"
427            || path == "git2::Odb::writer"
428            || path == "git2::PackBuilder::write"
429            || path == "git2::Reference::delete"
430            || path == "git2::Reference::set_target"
431            || path == "git2::Repository::blob_path"
432            || path == "git2::Repository::checkout_head"
433            || path == "git2::Repository::checkout_index"
434            || path == "git2::Repository::checkout_tree"
435            || path == "git2::Repository::commit"
436            || path == "git2::Repository::discover"
437            || path == "git2::Repository::discover_path"
438            || path == "git2::Repository::init"
439            || path == "git2::Repository::init_bare"
440            || path == "git2::Repository::init_opts"
441            || path == "git2::Repository::open"
442            || path == "git2::Repository::open_bare"
443            || path == "git2::Repository::open_ext"
444            || path == "git2::Repository::open_from_env"
445            || path == "git2::Repository::reference"
446            || path == "git2::Repository::tag"
447            || path == "git2::TreeBuilder::write"
448        {
449            return Some("Fs");
450        }
451        // `Cred::credential_helper` (cred.rs:121) calls `CredentialHelper::execute` (cred.rs:326),
452        // which — on every code path — spawns a REAL `sh -c "<helper> get"` (or, if `sh` itself fails to
453        // spawn, the helper binary directly) via `std::process::Command::spawn` to resolve
454        // `credential.helper`-configured auth (cred.rs:370-390): a real subprocess launch a caller
455        // authenticating against a private remote is one call away from (`Cred::credential_helper(cfg,
456        // url, user)` is the crate's own documented way to honor `credential.helper`), not a lower-level
457        // implementation detail. FQN-exact: `execute` alone is far too generic to key on blindly.
458        if path == "git2::Cred::credential_helper" || path == "git2::CredentialHelper::execute" {
459            return Some("Exec");
460        }
461        return None;
462    }
463    // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
464    // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
465    // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
466    // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
467    // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
468    // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
469    // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
470    // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
471    // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
472    // gmtime) are also skipped.
473    //
474    // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
475    // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
476    // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
477    // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
478    // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
479    // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
480    // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
481    // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
482    // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
483    // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
484    // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
485    // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
486    // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
487    if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
488        let f = path.rsplit("::").next().unwrap_or(path);
489        // path / directory / metadata syscalls (incl. *64 and *at variants)
490        const FS: &[&str] = &[
491            "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
492            "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
493            "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
494            "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
495            "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
496            "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
497            "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
498            "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
499            "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
500            "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
501            "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
502            "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
503            "realpath",
504        ];
505        // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
506        // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
507        const NET: &[&str] = &[
508            "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
509            "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
510            "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
511        ];
512        // process creation / replacement / reaping
513        const EXEC: &[&str] = &[
514            "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
515            "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
516            "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
517        ];
518        // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
519        const IPC: &[&str] = &[
520            "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
521            "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
522            "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
523        ];
524        const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
525        const CLOCK: &[&str] = &[
526            "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
527            // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
528            // which sets it via `clock_settime`).
529            "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
530        ];
531        const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
532        if FS.contains(&f) {
533            return Some("Fs");
534        }
535        if NET.contains(&f) {
536            return Some("Net");
537        }
538        if EXEC.contains(&f) {
539            return Some("Exec");
540        }
541        if IPC.contains(&f) {
542            return Some("Ipc");
543        }
544        if ENV.contains(&f) {
545            return Some("Env");
546        }
547        if CLOCK.contains(&f) {
548            return Some("Clock");
549        }
550        if RAND.contains(&f) {
551            return Some("Rand");
552        }
553        return None;
554    }
555    // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
556    // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
557    // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
558    // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
559    // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
560    // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
561    // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
562    // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
563    // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
564    {
565        let leaf = path.rsplit("::").next().unwrap_or(path);
566        if let Some(rest) = leaf.strip_prefix("sqlite3_") {
567            let _ = rest;
568            // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
569            //
570            // SOUNDNESS R166 — MEMBERSHIP CRITERION, stated because the list was extended and the next
571            // reader needs to know what "already audited" covers. A leaf belongs here when it either
572            // (a) reads or writes DATABASE CONTENT (disk or the in-memory image), or (b) mutates the
573            // registry of code the engine will later RUN — the extension/auto-extension registry, whose
574            // effect is not local to the calling frame. The audit boundary was the WHOLE `sqlite3_*`
575            // surface rusqlite 0.40.2 mentions (185 distinct symbols diffed against this list), not the
576            // one name the row was filed for: `sqlite3_auto_extension` arrived with its `_cancel_`/
577            // `_reset_` siblings and with four content-I/O leaves nobody had looked at.
578            //
579            // DELIBERATELY STILL ABSENT, so this is not read as "the surface is covered": the
580            // CALLBACK-INSTALLING entry points (`sqlite3_create_function[_v2]`,
581            // `sqlite3_create_window_function`, `sqlite3_create_collation_v2`, `sqlite3_create_module_v2`,
582            // `sqlite3_*_hook`, `sqlite3_set_authorizer`, `sqlite3_progress_handler`, `sqlite3_trace_v2`).
583            // Those register a UDF/hook on ONE connection for the life of that connection; they perform no
584            // database I/O themselves, and the opaque-callback boundary is already disclosed by the
585            // scanner's own `Unknown` at the call site that hands the function pointer across. The
586            // auto-extension registry is the case that differs: it is PROCESS-GLOBAL and outlives every
587            // connection, so its consequence cannot be attributed to a caller's own frame at all.
588            // Unexamined and named as such: the libgit2 (`git_*`) table below got no equivalent sweep.
589            const DB: &[&str] = &[
590                "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
591                "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
592                "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
593                "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
594                "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
595                "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
596                // (b) the PROCESS-GLOBAL extension registry — every one of these changes what a LATER
597                // `sqlite3_open` on any connection will execute, and `sqlite3_load_extension` (already
598                // above) is the same capability one call along.
599                "sqlite3_auto_extension", "sqlite3_cancel_auto_extension", "sqlite3_reset_auto_extension",
600                "sqlite3_enable_load_extension",
601                // (a) further DATABASE-CONTENT I/O the original list missed: whole-image load/save,
602                // a forced page-cache flush to disk, a direct VFS file operation, and arming the
603                // automatic WAL checkpoint writes whose manual twin is already listed.
604                "sqlite3_deserialize", "sqlite3_serialize", "sqlite3_db_cacheflush",
605                "sqlite3_file_control", "sqlite3_wal_autocheckpoint",
606            ];
607            return DB.contains(&leaf).then_some("Db");
608        }
609        if leaf.starts_with("git_") {
610            // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
611            // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
612            // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
613            const NET: &[&str] = &[
614                "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
615                "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
616                "git_submodule_clone", "git_submodule_update",
617            ];
618            // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
619            const FS: &[&str] = &[
620                "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
621                "git_repository_init", "git_repository_init_ext", "git_repository_discover",
622                "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
623                "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
624                "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
625                "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
626                "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
627                "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
628                "git_commit_create_v", "git_reference_create", "git_reference_set_target",
629                "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
630                "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
631                "git_packbuilder_write",
632            ];
633            if NET.contains(&leaf) {
634                return Some("Net");
635            }
636            if FS.contains(&leaf) {
637                return Some("Fs");
638            }
639            return None;
640        }
641        if leaf.starts_with("curl_") {
642            // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
643            // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
644            // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
645            // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
646            // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
647            // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
648            // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
649            const NET: &[&str] = &[
650                "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
651                "curl_multi_perform", "curl_multi_socket_action",
652            ];
653            return NET.contains(&leaf).then_some("Net");
654        }
655        if let Some(op) = leaf.strip_prefix("SSL_") {
656            // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
657            // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
658            // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
659            // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
660            // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
661            const SSL_NET: &[&str] = &[
662                "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
663                "peek_ex", "shutdown",
664            ];
665            return SSL_NET.contains(&op).then_some("Net");
666        }
667    }
668    // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
669    // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
670    // were silently classified network-free because reqwest wasn't recognized.)
671    if crate_name == "reqwest" || crate_name == "isahc" {
672        // The dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot CONVENIENCE functions
673        // `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send immediately — they're
674        // an EXACT match (not `Client::get`, the builder) to avoid false-positiving the builder path.
675        // (Found running on `xh`: a one-shot `reqwest::get(url)` was classified network-free.)
676        if path.ends_with("::send")
677            || path.ends_with("::execute")
678            || path == "reqwest::get"
679            || path == "reqwest::blocking::get"
680            || path == "isahc::get"
681        {
682            return Some("Net");
683        }
684        // THE URL-BEARING BUILDER METHODS: `Client::{get,post,put,delete,patch,head,request}(URL)`.
685        // Real code almost never uses `reqwest::get(url)`; the DOMINANT idiom is the builder chain
686        // `Client::new().post(url).send()` / `Client::builder().build()?.post(url).send()`. The `.send()`
687        // already classifies `Net` — but the URL literal rides the `.post(url)` call, NOT `.send()`, so
688        // without classifying the URL-naming step `Net` the endpoint is NEVER captured and the `Llm`
689        // host refinement can't fire (ebman's `api.anthropic.com` call read as bare Net, undisclosed as
690        // Llm — the dogfood silent under-report). Classifying these `Net` (idempotent with the eventual
691        // `.send()`) makes the scanner capture the URL from their string arg. `request(method, url)`'s
692        // url is its SECOND arg — the scanner's first-string-literal capture still gets it when the
693        // method is a literal string, and misses it (honest under-report) when the method is an
694        // expression. The pure builder surface (`::header`, `::json`, `::body`, `::query`, …) stays None.
695        if path.ends_with("::get")
696            || path.ends_with("::post")
697            || path.ends_with("::put")
698            || path.ends_with("::delete")
699            || path.ends_with("::patch")
700            || path.ends_with("::head")
701            || path.ends_with("::request")
702        {
703            return Some("Net");
704        }
705        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against isahc 2.0.1 — isahc-only, since
706        // reqwest has no equivalent free-function async family:
707        //
708        // `get_async`/`head_async`/`post_async`/`put_async`/`delete_async`/`send_async` (lib.rs:357-500)
709        // are the crate's async one-shot convenience functions — `HttpClient::shared().get_async(uri)`
710        // etc., the exact async mirror of the already-covered `isahc::get`, dispatching immediately.
711        // `HttpClient::new`/`HttpClientBuilder::build` (client.rs:629,444) are genuinely NOT lazy like
712        // reqwest's `Client::new`: `build()` unconditionally calls `agent_builder.spawn()`
713        // (agent/mod.rs:63), which spawns a background OS thread running `AgentContext::run` — curl's
714        // multi-handle event loop that DOES perform the real socket reads/writes for every request later
715        // sent through this client. Confirmed via self-scan on isahc's own source: `AgentBuilder::spawn`
716        // reaches `Net` through that same call chain, not a guess.
717        if crate_name == "isahc"
718            && (path == "isahc::get_async"
719                || path == "isahc::head_async"
720                || path == "isahc::post_async"
721                || path == "isahc::put_async"
722                || path == "isahc::delete_async"
723                || path == "isahc::send_async"
724                || path.ends_with("HttpClient::new")
725                || path.ends_with("HttpClientBuilder::build"))
726        {
727            return Some("Net");
728        }
729        // `reqwest::multipart::Part::file` (blocking/multipart.rs:223) opens the given path directly
730        // (`std::fs::File::open`) to attach it as a form part — genuinely Fs, not Net (the eventual
731        // upload is charged at `.send()`, already covered above).
732        //
733        // NOT modelled: `h3_client::pool::PoolClient::send_request` (async_impl/h3_client/pool.rs:207)
734        // — `mod async_impl;` is PRIVATE in reqwest's lib.rs (only `self::async_impl::multipart` is
735        // selectively re-exported), so `PoolClient` is unreachable from outside the crate; the real
736        // HTTP/3 dispatch a consumer can actually reach is the already-covered `::send`/`::execute`.
737        // Removed from open.tsv rather than guessed.
738        if crate_name == "reqwest" && path.ends_with("::Part::file") {
739            return Some("Fs");
740        }
741        return None;
742    }
743    if crate_name == "ureq" && path.ends_with("::call") {
744        return Some("Net");
745    }
746    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ureq 2.12.1 (the version this ratchet's
747    // rows were generated from — 3.x, also cached locally, rewrote this surface beyond recognition and
748    // is a different question for a future pass). `::call` above is the no-body dispatch terminal;
749    // these are its siblings:
750    //
751    // `Request::send`/`send_bytes`/`send_form`/`send_json`/`send_string` (request.rs:78-296) are the
752    // WITH-BODY dispatch terminals — `do_call(Payload::..)`, the exact same terminal `call` reaches with
753    // an empty payload. Missing them left every `agent.post(url).send_json(&body)` (the dominant idiom
754    // for a JSON API call) silently pure.
755    // `ureq::agent`/`request`/`request_url` (lib.rs:513,539,561) are real gaps in a stranger way: each
756    // one's own body contains a REACHABLE (if `is_test(true)` was ever called — itself a `pub fn` a
757    // consumer or another dependency could invoke) call to `testserver::test_agent()`, which binds a
758    // real listening `TcpStream` (see `TestServer::new` below) — self-scan's whole-body reachability
759    // treats that as real, and per this project's over-approximate-rather-than-miss discipline (a
760    // toggleable global flag routing traffic through an internal listener is exactly the kind of path a
761    // security-conscious caller wants disclosed, not silently pure), so does this rule. `agent()` is also
762    // the crate's OWN base for `request`/`request_url` (`agent().request(..)`), and `request`/
763    // `request_url` additionally carry the URL argument — matching the reqwest/isahc precedent above of
764    // classifying the URL-bearing constructor `Net` too (idempotent with the eventual `.call()`/`.send()`)
765    // so the destination is captured even if a caller never re-touches the returned `Request`.
766    if crate_name == "ureq"
767        && (path.ends_with("::send")
768            || path.ends_with("::send_bytes")
769            || path.ends_with("::send_form")
770            || path.ends_with("::send_json")
771            || path.ends_with("::send_string")
772            || path == "ureq::agent"
773            || path == "ureq::request"
774            || path == "ureq::request_url")
775    {
776        return Some("Net");
777    }
778    // NOT modelled (both removed from open.tsv, not guessed):
779    //   `ureq::Arc::connect` — the ratchet's generator strips generic arguments from an impl header, so
780    //   `impl TlsConnector for Arc<rustls::ClientConfig>` (rtls.rs:88) produced the type name "Arc" — a
781    //   bogus path (`ureq::Arc` names nothing; `Arc` is std's, not ureq's) that no consumer could ever
782    //   write. The trait method itself is real (ureq's own internal TLS dispatch, invoked by `.call()`,
783    //   already covered), but no FQN a real caller would spell resolves through this guess.
784    //   `ureq::TestServer::new` — `TestServer` (testserver.rs:62) lives in `mod testserver;` (lib.rs:444),
785    //   PRIVATE with no re-export, so `ureq::TestServer` is unreachable from outside the crate (the
786    //   listener it binds is real, but only `agent()`'s internal `is_test` branch above can reach it).
787    // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
788    // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
789    // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
790    // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
791    // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
792    // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
793    if crate_name == "curl"
794        && (path.ends_with("::perform")
795            || path.ends_with("::send")
796            || path.ends_with("::recv")
797            || path.ends_with("::upkeep")
798            || path.ends_with("::action"))
799    {
800        return Some("Net");
801    }
802    // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
803    // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
804    // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
805    // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
806    // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
807    // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
808    match crate_name {
809        // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
810        "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
811        // hyper-util's pooled legacy Client + its TCP connectors.
812        "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
813        // hickory (trust-dns) resolver — issues DNS queries over the network.
814        "hickory_resolver"
815            if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
816                || path.ends_with("::resolve") => return Some("Net"),
817        // HTTP/3 over QUIC.
818        "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
819            || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
820        // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
821        // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
822        // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
823        // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
824        "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
825            || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
826            || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
827            || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
828            || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
829            || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
830            || path.ends_with("::finish") => return Some("Net"),
831        // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
832        "tokio_rustls" | "native_tls"
833            if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
834            return Some("Net"),
835        // AF_VSOCK host<->guest sockets — inter-process / VM comms.
836        "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
837            return Some("Ipc"),
838        // Loads the OS trust store from disk (cert files / keychain).
839        "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
840        // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
841        // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
842        // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
843        // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
844        // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
845        // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
846        "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
847            || path.ends_with("::increase_nofile_limit") => return Some("Env"),
848        // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
849        // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
850        // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
851        // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
852        // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
853        // read_tls), so flagging it would over-report Net on the pure decrypt step.
854        "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
855            || path.ends_with("::complete_io") => return Some("Net"),
856        // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
857        // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
858        "native_tls_crate" | "tokio_native_tls"
859            if path.ends_with("::connect") || path.ends_with("::accept")
860                || path.ends_with("::handshake") => return Some("Net"),
861        _ => {}
862    }
863    // THE COVERAGE-GATE SWEEP (2026-08-27) — two crate-root CONSTRUCTORS the verb-keyed match above
864    // never reached because neither ends in connect/accept/handshake, verified against their real
865    // per-platform impl (native-tls 0.2.18, rustls 0.23.43):
866    //
867    // `native_tls_crate::TlsConnector::new` (lib.rs:481, the PUBLIC newtype wrapper around the private
868    // per-platform `imp::TlsConnector`) reads the system trust store — on the openssl backend it loads
869    // the probed cert file/dir from disk before any socket exists. `Identity::from_pkcs8` (lib.rs:178,
870    // wrapping `imp::Identity::from_pkcs8`) on the security-framework (macOS) backend creates a real
871    // temporary keychain file on disk to import the PKCS8 key into.
872    // `rustls::KeyLogFile::new` (key_log_file.rs:88) reads `$SSLKEYLOGFILE` and, if set, opens (creating
873    // if needed) that file in append mode — a real, if opt-in, disk write path independent of the sync
874    // TLS record-layer I/O the `rustls` arm above already covers.
875    if crate_name == "native_tls_crate"
876        && (path.ends_with("TlsConnector::new") || path.ends_with("Identity::from_pkcs8"))
877    {
878        return Some("Fs");
879    }
880    if crate_name == "rustls" && path.ends_with("KeyLogFile::new") {
881        return Some("Fs");
882    }
883    // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
884    // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
885    // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
886    // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
887    // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
888    // classified pure — a message-queue client reporting no I/O.)
889    if crate_name == "async_nats" {
890        if path.ends_with("::connect")
891            || path.contains("::publish")
892            || path.ends_with("::subscribe")
893            || path.ends_with("::queue_subscribe")
894            || path.contains("::request")
895            || path.ends_with("::flush")
896        {
897            return Some("Net");
898        }
899        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against async-nats 0.35.1 (the version this
900        // ratchet's rows were generated from — 0.50.0, also cached locally, removed/relocated this
901        // exact surface, a separate question for a future pass):
902        //
903        // `connect_with_options` (lib.rs:891) is the crate's OWN real entry point `connect()` calls one
904        // hop down — the actual handshake, missing because the verb list above matches the bare
905        // `::connect` suffix, not the `_with_options` sibling.
906        // `ConnectOptions::credentials_file`/`with_credentials_file` (options.rs:417,438) load a real
907        // credentials file off disk via `auth_utils::load_creds` before authenticating — Fs, not Net
908        // (the network round-trip these feed happens later, at `connect`).
909        // `ServerAddr::socket_addrs` (lib.rs:1473) does `tokio::net::lookup_host` — a real DNS query.
910        if path == "async_nats::connect_with_options" {
911            return Some("Net");
912        }
913        if path == "async_nats::ConnectOptions::credentials_file"
914            || path == "async_nats::ConnectOptions::with_credentials_file"
915        {
916            return Some("Fs");
917        }
918        if path == "async_nats::ServerAddr::socket_addrs" {
919            return Some("Net");
920        }
921        return None;
922    }
923    if crate_name == "lapin" {
924        if path.ends_with("::connect")
925            || path.ends_with("::create_channel")
926            || path.contains("::basic_")
927            || path.contains("::queue_")
928            || path.contains("::exchange_")
929            || path.contains("::tx_")
930            || path.ends_with("::confirm_select")
931            || path.ends_with("::close")
932        {
933            return Some("Net");
934        }
935        return None;
936    }
937    // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
938    // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
939    //
940    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against lettre 0.11.23 source — the TLS-setup and
941    // connection-establishment family this crate's own `send` rule never reached:
942    //
943    // `TlsParameters::new`/`new_rustls` (client/tls.rs:598,617) and `TlsParametersBuilder::build`/
944    // `build_rustls` (tls.rs:325,446) — `build_rustls` calls `rustls_native_certs::load_native_certs()`
945    // (a real OS-trust-store read) plus `tracing::debug!`; `build()` dispatches to it under the
946    // (textually-visible, cfg-gated) `rustls` feature. `SmtpTransport`/`AsyncSmtpTransport::{from_url,
947    // relay,starttls_relay}` (transport.rs:90,114,243; async_transport.rs:135,169,306) each construct a
948    // `TlsParameters` directly (`relay`/`starttls_relay`) or via the crate's OWN `pub(crate)
949    // from_connection_url` (`from_url`) — real TLS setup a consumer never spells `TlsParameters` to
950    // reach. `FileTransport::read` (transport/file/mod.rs:216) calls `std::fs::read` directly.
951    if crate_name == "lettre" {
952        if path.ends_with("::send") || path.ends_with("::send_raw") {
953            return Some("Net");
954        }
955        if path == "lettre::TlsParametersBuilder::build"
956            || path == "lettre::transport::smtp::client::TlsParametersBuilder::build"
957            || path == "lettre::TlsParametersBuilder::build_rustls"
958            || path == "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"
959            || path == "lettre::TlsParameters::new"
960            || path == "lettre::transport::smtp::client::TlsParameters::new"
961            || path == "lettre::TlsParameters::new_rustls"
962            || path == "lettre::transport::smtp::client::TlsParameters::new_rustls"
963            || path == "lettre::SmtpTransport::from_url"
964            || path == "lettre::SmtpTransport::relay"
965            || path == "lettre::SmtpTransport::starttls_relay"
966            || path == "lettre::AsyncSmtpTransport::from_url"
967            || path == "lettre::AsyncSmtpTransport::relay"
968            || path == "lettre::AsyncSmtpTransport::starttls_relay"
969            || path == "lettre::FileTransport::read"
970        {
971            return Some("Fs");
972        }
973        // `AsyncStd1Executor::{connect,fs_read,fs_write}` (executor.rs:223,260,265, the sealed
974        // `Executor` trait impl — `#[doc(hidden)]` in lettre's own source, so this is a narrower, less-
975        // visited surface than the others above, but genuinely reachable: both the trait and the type
976        // are `pub`, re-exported at the crate root) dial a real connection / read+write real files.
977        // `AsyncSmtpConnection`/`AsyncNetworkStream::connect_asyncstd1` (client/async_connection.rs:121,
978        // client/async_net.rs:207) dial a real `async-std` TCP connection — reachable only via the
979        // module-qualified path (`transport::smtp::client::`), since neither type is re-exported at the
980        // crate root (unlike `TlsParameters`'s sibling forms, which at least reach `client::` — the dual
981        // spellings above, same shape as rusqlite's `Blob`/`Backup`).
982        if path == "lettre::AsyncStd1Executor::connect"
983            || path == "lettre::AsyncSmtpConnection::connect_asyncstd1"
984            || path == "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
985            || path == "lettre::AsyncNetworkStream::connect_asyncstd1"
986            || path == "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
987        {
988            return Some("Net");
989        }
990        if path == "lettre::AsyncStd1Executor::fs_read" || path == "lettre::AsyncStd1Executor::fs_write" {
991            return Some("Fs");
992        }
993        // NOT included: `NetworkStream::{shutdown,set_read_timeout,set_write_timeout}` (client/net.rs)
994        // LOOK like three more hits (self-scan flags them too, propagating from the coarse
995        // `std::net::TcpStream` whole-handle rule) but `mod net;` (client/mod.rs:50) is PRIVATE and
996        // `NetworkStream` is never re-exported (only imported crate-internally via a bare `use
997        // self::net::NetworkStream;`, mod.rs:35) — no external consumer can ever name the type, so no
998        // rule was added despite self-scan flagging it (the same `InnerConnection`/`RawStatement` shape
999        // as the rusqlite fix elsewhere in this file).
1000        return None;
1001    }
1002    // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
1003    // and the socket read/write/send are network; Message constructors are pure. (Found on a
1004    // tungstenite consumer: connect + send + read classified pure.)
1005    //
1006    // THE FIX: `connect` only names the URL-dialing client entry point. tungstenite ALSO ships the
1007    // stream-first client/server handshake free functions — `client`/`client_with_config` (client.rs:176,
1008    // 152: "use this if you need a nonblocking handshake ... or a custom stream") and
1009    // `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config` (server.rs:23-63) — each of
1010    // which does `{Client,Server}Handshake::start(stream, ..).handshake()`, the REAL WS upgrade
1011    // handshake read/write over an already-open stream. These are the documented way to run tungstenite
1012    // over a caller-managed TCP/TLS/mio stream (exactly the shape most async runtimes use, since
1013    // tungstenite itself is sync/transport-agnostic) — missing from a verb list keyed only on the
1014    // dial-it-yourself `connect` spelling. Same vein as `git2::Submodule::clone`/`ignore::Walk::new`: an
1015    // established effect's SIBLING entry point absent from the allowlist.
1016    if crate_name == "tungstenite" {
1017        if path.ends_with("::connect")
1018            || path.ends_with("::connect_with_config")
1019            || path.ends_with("::client")
1020            || path.ends_with("::client_with_config")
1021            || path.ends_with("::client_tls")
1022            || path.ends_with("::client_tls_with_config")
1023            || path.ends_with("::accept")
1024            || path.ends_with("::accept_with_config")
1025            || path.ends_with("::accept_hdr")
1026            || path.ends_with("::accept_hdr_with_config")
1027            || path.ends_with("::read")
1028            || path.ends_with("::write")
1029            || path.ends_with("::send")
1030            || path.ends_with("::close")
1031            || path.ends_with("::flush")
1032            || path.ends_with("::read_message")
1033            || path.ends_with("::write_message")
1034        {
1035            return Some("Net");
1036        }
1037        return None;
1038    }
1039    // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
1040    // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
1041    if crate_name == "elasticsearch" && path.ends_with("::send") {
1042        return Some("Net");
1043    }
1044    // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
1045    // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
1046    // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
1047    //
1048    // THE COVERAGE-GATE FIX: the rule above only ever covered the CLIENT half. `Router::serve`/
1049    // `::serve_with_shutdown` (transport/server/mod.rs:783,811) are the SERVER's own most common entry
1050    // points — a tonic quickstart's last line — and both bind a real listening socket via
1051    // `TcpIncoming::new` (incoming.rs:197: `StdTcpListener::bind(addr)`), the crate's other bare-`pub`
1052    // constructor (`Server<L>::serve_with_shutdown` at mod.rs:530 is a DIFFERENT, `pub(crate)` method on
1053    // a different receiver — not reachable, and not this one). A `deny Net` on a gRPC SERVER app missed
1054    // its own listen call entirely.
1055    if crate_name == "tonic" {
1056        if path.ends_with("::connect")
1057            || path.ends_with("::unary")
1058            || path.ends_with("::server_streaming")
1059            || path.ends_with("::client_streaming")
1060            || path.ends_with("::streaming")
1061            || path.ends_with("::Router::serve")
1062            || path.ends_with("::Router::serve_with_shutdown")
1063            || path == "tonic::TcpIncoming::new"
1064            || path == "tonic::transport::server::TcpIncoming::new"
1065        {
1066            return Some("Net");
1067        }
1068        return None;
1069    }
1070    // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
1071    // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
1072    // repro needs librdkafka/cmake, deferred.)
1073    if crate_name == "rdkafka" {
1074        if path.ends_with("::send")
1075            || path.ends_with("::send_result")
1076            || path.ends_with("::recv")
1077            || path.ends_with("::poll")
1078            || path.ends_with("::subscribe")
1079            || path.ends_with("::commit")
1080            || path.ends_with("::commit_message")
1081            || path.ends_with("::commit_consumer_state")
1082            || path.ends_with("::store_offset")
1083            || path.ends_with("::seek")
1084            || path.ends_with("::fetch_metadata")
1085            || path.ends_with("::fetch_watermarks")
1086            || path.ends_with("::flush")
1087        {
1088            return Some("Net");
1089        }
1090        return None;
1091    }
1092    // cap-std: capability-oriented std. I/O goes *through* a held capability handle
1093    // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
1094    // cap-std project's real I/O is detected and matches the capability it declared
1095    // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
1096    if crate_name.starts_with("cap_") {
1097        if path.contains("::net::Unix") || path.contains("::os::") {
1098            return Some("Ipc");
1099        }
1100        if path.contains("::net") {
1101            return Some("Net");
1102        }
1103        if path.contains("::time") {
1104            return Some("Clock");
1105        }
1106        if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
1107            return Some("Fs");
1108        }
1109        return None;
1110    }
1111    // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
1112    // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
1113    // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
1114    // `unix`.
1115    if path.starts_with("tokio::net::Unix")
1116        || path.starts_with("std::os::unix::net")
1117        || path.starts_with("async_std::os::unix::net")
1118        || path.starts_with("async_net::unix")
1119    {
1120        return Some("Ipc");
1121    }
1122    // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
1123    // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
1124    // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
1125    // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
1126    // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
1127    // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
1128    // scanning bandwhich — a packet sniffer — which reported Net 0.)
1129    if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
1130        if path.ends_with("::channel") || path.ends_with("::transport_channel") {
1131            return Some("Net");
1132        }
1133        return None;
1134    }
1135    // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
1136    // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
1137    // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
1138    // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
1139    // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
1140    // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
1141    // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
1142    //
1143    // `Walk::new`/`Walk::from_iter` are the crate's own documented convenience constructors — literally
1144    // `WalkBuilder::new(path).build()` / `WalkBuilder::from_iter(paths).build()` in `ignore`'s own source
1145    // (walk.rs:1128-1146) — but a verb list keyed only on `WalkBuilder::build`/`build_parallel`/
1146    // `WalkParallel::run`/`add_ignore` never sees `Walk::new(root)` (the crate's own top-level doc example
1147    // and the SAME shape as the already-fixed `walkdir::WalkDir::new`/`git2::Repository::clone`: a
1148    // documented public entry point that reaches the modelled effect through a wrapper the allowlist
1149    // didn't name). `deny Fs` over `for e in Walk::new(root) { .. }` exited 0. Charged at the SAME
1150    // construction site as `WalkBuilder::build` — no receiver typing needed, it's a plain `Expr::Call` on
1151    // `ignore::Walk::new`/`ignore::Walk::from_iter`, crate-gated so an unrelated `Walk::new` (a different
1152    // crate, or a local type) cannot match this arm.
1153    if crate_name == "ignore" {
1154        if path == "ignore::WalkBuilder::build"
1155            || path == "ignore::WalkBuilder::build_parallel"
1156            || path.ends_with("::WalkParallel::run")
1157            // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
1158            // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
1159            // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
1160            // surface, so it was silently pure under the covered-crate floor.
1161            || path == "ignore::WalkBuilder::add_ignore"
1162            || path == "ignore::Walk::new"
1163            || path == "ignore::Walk::from_iter"
1164        {
1165            return Some("Fs");
1166        }
1167        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ignore 0.4.33 — the gitignore-matcher
1168        // construction family, a DIFFERENT public surface from the walk-builder family above:
1169        //
1170        // `Gitignore::new` (gitignore.rs:103) opens and reads the given file directly via
1171        // `GitignoreBuilder::add`. `Gitignore::global` (gitignore.rs:141) reads `$PWD` then delegates to
1172        // `GitignoreBuilder::new(cwd).build_global()`. `GitignoreBuilder::add` (gitignore.rs:405) is the
1173        // crate's own per-file loader — `File::open` + line-by-line read — the same shape `add_ignore`
1174        // above was fixed for, on the SIBLING type. `GitignoreBuilder::build_global`/
1175        // `gitconfig_excludes_path` (gitignore.rs:377,583) resolve git's global excludesfile: read
1176        // `GIT_CONFIG_GLOBAL`/`XDG_CONFIG_HOME`/`HOME` env vars, then open and parse whichever gitconfig
1177        // file they point at. `WalkBuilder::build_matchers` (walk.rs:675) is the incremental-walk
1178        // sibling of the already-covered `build_parallel` — same `self.build_ignore()` call, different
1179        // caller-facing verb. `IncrementalIgnore::matched`/`matched_with_errors` (incremental.rs:194,213)
1180        // are the newer per-directory incremental-walk API: each lazily loads that directory's ignore
1181        // files (and, when `max_filesize` is set, `stat`s the candidate file) as the walk descends.
1182        //
1183        // `Gitignore`/`GitignoreBuilder`/`gitconfig_excludes_path` live in `pub mod gitignore;`
1184        // (lib.rs:59) but — unlike `Walk`/`WalkBuilder` two lines up (`pub use crate::walk::{..}`,
1185        // lib.rs:52) — are NOT re-exported at the crate root, so the only real, compilable spelling is
1186        // the module-qualified one (proven by a consumer fixture: `ignore::Gitignore::new` does not
1187        // exist, `ignore::gitignore::Gitignore::new` does). `IncrementalIgnore` IS re-exported at the
1188        // root (`pub use crate::incremental::{..}`, lib.rs:51), so the bare form is correct there.
1189        if path == "ignore::gitignore::Gitignore::new"
1190            || path == "ignore::gitignore::Gitignore::global"
1191            || path == "ignore::gitignore::GitignoreBuilder::add"
1192            || path == "ignore::gitignore::GitignoreBuilder::build_global"
1193            || path == "ignore::gitignore::gitconfig_excludes_path"
1194            || path == "ignore::WalkBuilder::build_matchers"
1195            || path == "ignore::IncrementalIgnore::matched"
1196            || path == "ignore::IncrementalIgnore::matched_with_errors"
1197        {
1198            return Some("Fs");
1199        }
1200        return None;
1201    }
1202    // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
1203    // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
1204    // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
1205    // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
1206    // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
1207    // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
1208    if crate_name == "notify" {
1209        if path.ends_with("Watcher::new")
1210            || path.ends_with("::recommended_watcher")
1211            || path.ends_with("::watch")
1212            || path.ends_with("::unwatch")
1213            // `ReadDirectoryChangesWatcher::create` (windows.rs:475, `pub use windows::
1214            // ReadDirectoryChangesWatcher` at the crate root) is a SECOND, directly-callable
1215            // constructor distinct from the `Watcher::new` trait method above — it takes a raw
1216            // meta-event channel `Watcher::new`'s impl builds internally by calling THIS same
1217            // `create`, then spawns the real ReadDirectoryChangesW watch server thread.
1218            || path.ends_with("ReadDirectoryChangesWatcher::create")
1219        {
1220            return Some("Fs");
1221        }
1222        return None;
1223    }
1224    // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
1225    // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
1226    // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
1227    if path.ends_with("::to_socket_addrs")
1228        || path == "std::net::lookup_host"
1229        || path.ends_with("ToSocketAddrs::to_socket_addrs")
1230    {
1231        return Some("Net");
1232    }
1233    // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
1234    // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
1235    if path.starts_with("std::net::TcpStream")
1236        || path.starts_with("std::net::TcpListener")
1237        || path.starts_with("std::net::UdpSocket")
1238        || path.starts_with("tokio::net::")
1239    {
1240        // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
1241        // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
1242        // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
1243        // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
1244        if path.ends_with("::local_addr")
1245            || path.ends_with("::peer_addr")
1246            || path.ends_with("::nodelay")
1247            || path.ends_with("::ttl")
1248            || path.ends_with("::take_error")
1249        {
1250            return None;
1251        }
1252        return Some("Net");
1253    }
1254    // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
1255    // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
1256    // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
1257    // network-free — a network tool confidently reporting 0 Net.)
1258    if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
1259        return Some("Net");
1260    }
1261    // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
1262    // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
1263    // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
1264    // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
1265    // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
1266    // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
1267    if path.starts_with("async_std::net::")
1268        || path.starts_with("mio::net::")
1269        || crate_name == "async_net"
1270    {
1271        return Some("Net");
1272    }
1273    // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
1274    // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
1275    // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
1276    // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
1277    if DB_CRATES.contains(&crate_name) {
1278        // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
1279        // execution (round-trips to the server). sqlx is the outlier where bare `query()`
1280        // only BUILDS — it keeps the narrow set below. (Found by running on a real
1281        // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
1282        if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
1283            const PG: [&str; 20] = [
1284                "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
1285                "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
1286                "::copy_in", "::copy_out", "::transaction", "::connect",
1287                // `Config::connect_raw` (tokio-postgres config.rs:739) does the SAME protocol handshake
1288                // as `Config::connect` over a caller-supplied stream (a Unix socket, a TLS-terminated
1289                // proxy) instead of dialing one itself — real Db/Net I/O, missing because the verb
1290                // doesn't end in the plain `connect` spelling.
1291                "::connect_raw",
1292                // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
1293                // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
1294                // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
1295                // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
1296                // tokio_postgres 0.7.10+.
1297                "::query_row", "::query_map", "::query_and_then", "::execute_batch",
1298                "::prepare_cached", "::query_typed",
1299            ];
1300            if PG.iter().any(|v| path.ends_with(v)) {
1301                return Some("Db");
1302            }
1303            // THE COVERAGE-GATE SWEEP (2026-08-27): `CancelToken::cancel_query` (tokio-postgres
1304            // cancel_token.rs:34) opens a BRAND NEW connection to the server to send a raw CancelRequest
1305            // packet — real socket I/O, but not a query round-trip on an existing connection like the PG
1306            // verb list above, so bucketed `Net` rather than `Db`.
1307            if crate_name == "tokio_postgres" && path.ends_with("CancelToken::cancel_query") {
1308                return Some("Net");
1309            }
1310            // rusqlite only: opening the database IS the connection establishment (`Connection::
1311            // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
1312            //
1313            // THE FIX: rusqlite 0.32's `Connection` type has SIX `open*` constructors, not three —
1314            // `open_with_flags_and_vfs` and `open_in_memory_with_flags_and_vfs` call the real
1315            // `InnerConnection::open_with_flags` (the sqlite3_open_v2 FFI) directly (lib.rs:492,530), and
1316            // `open_in_memory_with_flags` (lib.rs:515) calls `Connection::open_with_flags(":memory:", ..)`
1317            // — but an exact-suffix allowlist keyed on the THREE simplest names never matches any of
1318            // them (`"open_in_memory_with_flags".ends_with("::open_with_flags")` is false: the suffix
1319            // check needs the string to END in that literal tail, and `_and_vfs`/`_with_flags` add
1320            // characters AFTER it). The same shape as `ignore::Walk::new`: a sibling spelling of an
1321            // already-modelled constructor missing from the allowlist, silently pure because rusqlite is
1322            // calibrated. Match on the `Connection::open` PREFIX rather than enumerating every suffix —
1323            // every current and future `Connection::open*` constructor opens a real handle. Scoped to the
1324            // `Connection::` segment specifically (not a bare leaf prefix) so an unrelated same-crate
1325            // `open`-prefixed method on a DIFFERENT type (e.g. the private `pragma::Sql::open_brace`,
1326            // which pushes one char to a string buffer — no I/O) cannot be swept in by accident.
1327            //
1328            // Same sighting, different constructor: `Connection::blob_open` and `Blob::reopen` call
1329            // `ffi::sqlite3_blob_open`/`sqlite3_blob_reopen` directly (blob/mod.rs:218,251) — genuinely
1330            // Db, and `sqlite3_blob_open`/`sqlite3_blob_reopen` are already in this file's own FFI-leaf DB
1331            // table above (matched only when a caller names the raw `ffi::` leaf directly) — but rusqlite's
1332            // own documented safe wrapper, the incremental-BLOB-I/O API every rusqlite blob consumer
1333            // actually calls, carried no rule of its own.
1334            if crate_name == "rusqlite"
1335                && (path.contains("::Connection::open")
1336                    || path.ends_with("::Connection::blob_open")
1337                    || path.ends_with("::Blob::reopen"))
1338            {
1339                return Some("Db");
1340            }
1341            // THE COVERAGE-GATE SWEEP (2026-08-27): same shape again, and the SAME root cause as
1342            // `git2`'s — this `if crate_name == "rusqlite"` block returns unconditionally, so it never
1343            // reaches this file's own `sqlite3_*` FFI-leaf table below even though every one of these
1344            // calls a leaf ALREADY listed there (`sqlite3_backup_init`/`_step`, `sqlite3_blob_read`/
1345            // `_write`). Verified against rusqlite 0.32.1 source. `Backup::new`/`new_with_names` are the
1346            // crate's own online-backup constructors (backup.rs:187,200); `step`/`run_to_completion`
1347            // drive it; `Connection::backup`/`restore` (backup.rs:61,99) are the one-call convenience
1348            // wrappers around the same API and need their OWN rule since a consumer calling THEM never
1349            // names `Backup` at all. `Blob::{read,write}_at[_exact]`/`raw_read_at[_exact]`/
1350            // `write_all_at` (blob/pos_io.rs) are the incremental-BLOB positional-I/O methods, each
1351            // calling `ffi::sqlite3_blob_read`/`_write` directly. `Connection::from_handle`/
1352            // `_owned`/`extension_init2` and the free fn `init_auto_extension` (lib.rs:950,999,966;
1353            // auto_extension.rs:26) wrap a caller-supplied raw `*mut ffi::sqlite3` into a live
1354            // `Connection` — the loadable-extension entry points, same "produces a live connection"
1355            // effect as `Connection::open`, just over a handle the caller already has instead of one
1356            // this call opens itself (the `tokio_postgres::connect_raw`/`sea_orm::connect_proxy` shape).
1357            //
1358            // NOT included: `InnerConnection::*` and `RawStatement::step` LOOK like six more hits (self-
1359            // scan flags them too) but `mod inner_connection;`/`mod raw_statement;` (lib.rs:119,127) are
1360            // PRIVATE — despite `pub struct InnerConnection`/`RawStatement`, neither type has any public
1361            // path a real consumer could ever name or obtain a value of, so no rule was added for them
1362            // (a rule would be dead weight, never reachable). `Context::get_connection` (functions.rs)
1363            // is ALSO left out, deliberately: it returns a `ConnectionRef` accessor to an
1364            // ALREADY-established connection (`ffi::sqlite3_context_db_handle`, not an I/O verb) — the
1365            // same "handle accessor, not a syscall" shape as `TcpStream::local_addr`, not confirmed
1366            // enough to charge without risking the over-charge this file's history has repeatedly warned
1367            // against. THE COVERAGE-GATE SWEEP (2026-08-27) resolved this from "left in the ratchet"
1368            // to a formal call: `REVIEWED_PURE_ENTRIES` now carries `("rusqlite",
1369            // "rusqlite::Context::get_connection")` so the gate stops re-flagging it every refresh.
1370            //
1371            // TWO SPELLINGS EACH for `Backup`/`Blob`/`init_auto_extension`: none of the three is
1372            // re-exported at rusqlite's crate root (unlike `Connection`, declared directly in lib.rs), so
1373            // the ONLY real spelling a consumer can write is the module-qualified one
1374            // (`rusqlite::backup::Backup::new`, `rusqlite::blob::Blob::read_at`,
1375            // `rusqlite::auto_extension::init_auto_extension`) — but the coverage-gate ratchet recorded
1376            // the SHORT, technically-unreachable guess (`generate.py` always adds a bare
1377            // `{crate}::{Type}::{fn}` guess as a possible root-alias, without confirming a `pub use`
1378            // actually exists for it). Both are listed: the short form so this fix closes the exact row
1379            // the ratchet carries, the long form so it fires for what a real consumer's source (proven
1380            // against a compiling fixture) actually contains — dropping the long form would leave the
1381            // real gap open while the bookkeeping said closed.
1382            if crate_name == "rusqlite"
1383                && (path == "rusqlite::Backup::new"
1384                    || path == "rusqlite::backup::Backup::new"
1385                    || path == "rusqlite::Backup::new_with_names"
1386                    || path == "rusqlite::backup::Backup::new_with_names"
1387                    || path == "rusqlite::Backup::step"
1388                    || path == "rusqlite::backup::Backup::step"
1389                    || path == "rusqlite::Backup::run_to_completion"
1390                    || path == "rusqlite::backup::Backup::run_to_completion"
1391                    || path == "rusqlite::Connection::backup"
1392                    || path == "rusqlite::Connection::restore"
1393                    || path == "rusqlite::Blob::read_at"
1394                    || path == "rusqlite::blob::Blob::read_at"
1395                    || path == "rusqlite::Blob::read_at_exact"
1396                    || path == "rusqlite::blob::Blob::read_at_exact"
1397                    || path == "rusqlite::Blob::raw_read_at"
1398                    || path == "rusqlite::blob::Blob::raw_read_at"
1399                    || path == "rusqlite::Blob::raw_read_at_exact"
1400                    || path == "rusqlite::blob::Blob::raw_read_at_exact"
1401                    || path == "rusqlite::Blob::write_at"
1402                    || path == "rusqlite::blob::Blob::write_at"
1403                    || path == "rusqlite::Blob::write_all_at"
1404                    || path == "rusqlite::blob::Blob::write_all_at"
1405                    || path == "rusqlite::Connection::from_handle"
1406                    || path == "rusqlite::Connection::from_handle_owned"
1407                    || path == "rusqlite::Connection::extension_init2"
1408                    || path == "rusqlite::init_auto_extension"
1409                    || path == "rusqlite::auto_extension::init_auto_extension")
1410            {
1411                return Some("Db");
1412            }
1413            return None;
1414        }
1415        // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
1416        // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
1417        // connection establishment. The shared VERBS below only catch the low-level
1418        // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
1419        // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
1420        if crate_name == "redis"
1421            && (path.contains("Commands::")
1422                // THE COVERAGE-GATE SWEEP (2026-08-27), a pre-existing over-report found in passing:
1423                // `path.contains("::get_connection")` is a SUBSTRING match, so it also matched
1424                // `Client::get_connection_info` (client.rs:78) — a pure accessor returning an
1425                // already-stored `&ConnectionInfo`, no round-trip at all. Verified against redis 1.6.0.
1426                || (path.contains("::get_connection") && !path.ends_with("::get_connection_info"))
1427                || path.contains("::get_async_connection")
1428                || path.contains("::get_multiplexed_async_connection")
1429                // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
1430                // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
1431                // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
1432                || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
1433                    && !path.ends_with("::clone"))
1434                || path.ends_with("::query")
1435                || path.ends_with("::query_async")
1436                || path.ends_with("::req_command")
1437                || path.ends_with("::req_packed_command")
1438                || path.ends_with("::req_packed_commands"))
1439        {
1440            return Some("Db");
1441        }
1442        // mongodb: a document-store API with none of the SQL verbs — the user calls
1443        // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
1444        // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
1445        // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
1446        // and option/doc builders don't match these verbs, so they stay pure.
1447        //
1448        // THE FIX: `Client::with_options(options)` IS `with_uri_str`'s own body one call down —
1449        // `with_uri_str` (client.rs:179) is literally `ClientOptions::parse(uri).await?;
1450        // Client::with_options(options)`, and `with_options` (client.rs:188) is where the topology/
1451        // monitoring actually spins up. A caller who already holds a `ClientOptions` (built
1452        // programmatically, or via `ClientOptions::parse` called separately) uses `with_options`
1453        // directly — mongodb's OWN alternate entry point for the identical effect, missing from an
1454        // allowlist keyed only on the URI-string spelling. Same shape as `ignore::Walk::new` and
1455        // diesel's `establish`: a sibling constructor of an already-modelled effect. Verified against
1456        // mongodb 3.8.1 source for both the async (`client.rs`) and sync (`sync/client.rs`) `Client`.
1457        if crate_name == "mongodb" {
1458            const MONGO: [&str; 28] = [
1459                "::with_uri_str", "::with_options", "::connect", "::find", "::find_one", "::insert_one",
1460                "::insert_many", "::update_one", "::update_many", "::delete_one",
1461                "::delete_many", "::replace_one", "::aggregate", "::count_documents",
1462                "::estimated_document_count", "::count", "::distinct", "::run_command",
1463                "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
1464                "::list_collections", "::list_collection_names", "::list_databases",
1465                "::list_database_names", "::create_collection", "::create_index", "::watch",
1466            ];
1467            if MONGO.iter().any(|v| path.ends_with(v)) {
1468                return Some("Db");
1469            }
1470            // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mongodb 3.8.1 — the client-side
1471            // field-level encryption (CSFLE) surface, entirely unrelated to the CRUD verb list above.
1472            //
1473            // `ClientEncryption::decrypt` (client/csfle/client_encryption.rs:195) is an ordinary `pub
1474            // async fn` that bottoms out in `CryptExecutor::run_ctx` (client/csfle/state_machine.rs:106),
1475            // a state-machine loop that — depending on state — runs a real `list_collections` query
1476            // against the key-vault database, executes a real command against `mongocryptd` (respawning
1477            // the child process if the connection drops), and fetches data keys from the key vault:
1478            // genuinely Net (and, via mongocryptd respawn, Exec), reached from a manual decrypt call, not
1479            // a guess. Suffix-matched because the real path runs through a re-export hop the ratchet's
1480            // crate-root-alias guess skips: `ClientEncryption` is defined inside `pub(crate) mod csfle;`
1481            // (client.rs:4) and reachable only via `pub use crate::client::csfle::client_encryption;` at
1482            // the crate root (lib.rs:74) — `mongodb::client_encryption::ClientEncryption`, not bare
1483            // `mongodb::ClientEncryption`. The name is specific enough within this crate that a suffix
1484            // match carries no fabrication risk (crate-gated, and `ClientEncryption` exists nowhere else
1485            // in mongodb's own source).
1486            if path.ends_with("ClientEncryption::decrypt") {
1487                return Some("Net");
1488            }
1489            // NOT modelled (removed from open.tsv, not guessed): `CreateDataKey::execute` /
1490            // `Encrypt::execute` — a DIFFERENT false-guess shape from every other row in this pass.
1491            // `CreateDataKey`/`Encrypt` (action/csfle/create_data_key.rs, action/csfle/encrypt.rs) are
1492            // the crate's real, publicly re-exported Action-builder types (`mongodb::action::csfle::
1493            // {CreateDataKey,Encrypt}`) — but the SAME effect self-scan found (the real `run_ctx` call)
1494            // lives in `impl Action for CreateDataKey<'a> { async fn execute(self) -> .. { .. } }`
1495            // (client/csfle/client_encryption/create_data_key.rs:14), which is the INPUT to the
1496            // `#[action_impl]` proc macro, not a real method: the `Action` trait (action.rs:104) declares
1497            // only `optional`, and its own doc comment says the crate's action types are "executed via
1498            // `await` (or `run` if using the sync client)" — the macro consumes this `execute` fn body
1499            // and re-emits it as `IntoFuture::into_future`, so no compiled `CreateDataKey`/`Encrypt`
1500            // value ever has a method literally named `execute` a consumer could call. Self-scan found a
1501            // real function performing a real effect; it is just never reachable under this spelling
1502            // (nor, by construction, under ANY spelling — a consumer's call site is `.await`, not a
1503            // `.execute()` method call, so candor-scan would need to resolve the `IntoFuture` desugaring
1504            // itself to see this at all, a different, deeper question than a classify() rule can answer).
1505            return None;
1506        }
1507        // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
1508        // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
1509        // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
1510        // API (unit-tested; a real-app repro is the remaining confirmation).
1511        //
1512        // THE FIX: `Conn::new(opts)` is each crate's OWN primary connection constructor — not a
1513        // pool helper like `get_conn`, but the raw handle. `mysql::Conn::new` (conn/mod.rs:342) calls
1514        // `conn.connect_stream()?; conn.connect()?` directly; `mysql_async::Conn::new` (conn/mod.rs:921)
1515        // returns a future that does the same handshake. Real connection establishment, same shape as
1516        // diesel's `establish`/rusqlite's `open*` — but `::new` never appeared in this verb list, so a
1517        // bare `Conn::new(opts)?` (the crate's OWN first doctest example) read pure. Scoped to the
1518        // `Conn::` segment specifically (`::new` alone is far too generic a suffix to key on blindly —
1519        // `Opts`/`Pool`/query-result types in the same two crates have their own pure `new`s).
1520        if matches!(crate_name, "mysql" | "mysql_async") {
1521            if path.ends_with("::Conn::new") {
1522                return Some("Db");
1523            }
1524            const MY: [&str; 16] = [
1525                "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
1526                "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
1527                "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
1528            ];
1529            if MY.iter().any(|v| path.ends_with(v)) {
1530                return Some("Db");
1531            }
1532            // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mysql_async 0.37.0:
1533            // `Conn::from_url` (conn/mod.rs:1059) is `Conn::new(Opts::from_str(url)?)` one hop down —
1534            // the crate's OWN alternate entry point for the identical connect effect `Conn::new` above
1535            // already carries, same shape as `ignore::Walk::new`/diesel's `establish`.
1536            // `WhiteListFsHandler::handle` (local_infile_handler/builtin.rs:58, the `GlobalHandler` impl
1537            // for `LOAD DATA LOCAL INFILE`) opens a real file off the caller-supplied whitelist —
1538            // reachable at the crate root (`pub use self::local_infile_handler::{builtin::
1539            // WhiteListFsHandler, ..}`, lib.rs:500).
1540            if path == "mysql_async::Conn::from_url" {
1541                return Some("Db");
1542            }
1543            if path.ends_with("WhiteListFsHandler::handle") {
1544                return Some("Fs");
1545            }
1546            // NOT modelled (all three removed from open.tsv, not guessed): `mysql::MyTcpBuilder::connect`/
1547            // `Stream::connect_tcp`/`Stream::make_secure` (mysql, not mysql_async — io/tcp.rs, io/mod.rs,
1548            // io/tls/native_tls_io.rs). `mod io;` is PRIVATE in mysql's lib.rs with no re-export of
1549            // `MyTcpBuilder`/`Stream`, so none of the three is a path any external consumer can name —
1550            // the crate's real, externally-reachable connect effect is `Conn::new` above (already
1551            // covered), which calls into this private `io` module internally. Also NOT modelled:
1552            // `mysql_async::PathOrBuf::read` (opts/mod.rs) — `PathOrBuf` is declared in the same private
1553            // `mod opts;` whose OTHER types (`Opts`, `OptsBuilder`, …) ARE individually `pub use`-
1554            // re-exported at the crate root (lib.rs:493) but `PathOrBuf` specifically is not, so it too
1555            // is unreachable — the same private-module shape as tempfile's `imp::` functions above.
1556            return None;
1557        }
1558        // sea_orm: an ORM whose execution is split from building (like sqlx). The query
1559        // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
1560        // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
1561        // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
1562        // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
1563        // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
1564        if crate_name == "sea_orm" {
1565            // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
1566            // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
1567            // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
1568            // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
1569            // exclude the whole re-exported namespace first.
1570            if path.contains("sea_query") {
1571                return None;
1572            }
1573            if path.ends_with("::all")
1574                || path.ends_with("::one")
1575                || path.ends_with("::count")
1576                || path.ends_with("::stream")
1577                || path.ends_with("::exec")
1578                || path.ends_with("::exec_with_returning")
1579                || path.ends_with("::exec_without_returning")
1580                || path.ends_with("::connect")
1581                // `Database::connect_proxy` (database/mod.rs:139, behind the `proxy` feature) is
1582                // `Database::connect`'s sibling for a caller-supplied `ProxyDatabaseTrait` backend —
1583                // same effect (produces a live `DatabaseConnection`), different verb, missing from an
1584                // allowlist keyed on the plain `connect` spelling.
1585                || path.ends_with("::connect_proxy")
1586                || path.ends_with("::execute")
1587                || path.ends_with("::execute_unprepared")
1588                || path.ends_with("::query_one")
1589                || path.ends_with("::query_all")
1590                || path.ends_with("::fetch_page")
1591                || path.ends_with("::num_items")
1592                || path.contains("ActiveModelTrait::")
1593            {
1594                return Some("Db");
1595            }
1596            // THE COVERAGE-GATE SWEEP (2026-08-27): sea_orm's TRANSACTION and PAGINATION families,
1597            // verified against sea-orm 1.1.20 source — each of these was found effectful by self-scan
1598            // under no guessed spelling this allowlist already covered.
1599            //
1600            // `DatabaseConnection::transaction`/`transaction_with_config` (db_connection.rs:302,345, the
1601            // `TransactionTrait` impl) is the crate's documented callback-transaction API
1602            // (`db.transaction(|txn| ...)`) — every sqlx-backed match arm dispatches to the
1603            // `SqlxXxxPoolConnection::transaction` methods FQN-listed below, a real BEGIN. FQN-exact
1604            // (not a bare `::transaction` suffix) because `MockDatabaseConnection`/`ProxyDatabaseConnection`
1605            // (driver/mock.rs, driver/proxy.rs, both feature-gated) have their OWN `begin`/`commit`/
1606            // `rollback`/`ping`/`transaction` methods sharing every one of these verb names — a mock
1607            // backend performs no real I/O by construction, and a caller-supplied proxy is a callback
1608            // boundary, not a provable effect; a bare suffix would fabricate Db on both.
1609            if path == "sea_orm::DatabaseConnection::transaction"
1610                || path == "sea_orm::DatabaseConnection::transaction_with_config"
1611                // BONUS, found while proving the above reachable with a real consumer fixture (not in
1612                // the original coverage-gate ratchet — self-scan's own reachability pass didn't flag
1613                // these, apparently because it doesn't track an enum match arm's payload binding as a
1614                // typed receiver, but they dispatch through the identical match-on-`self` shape as
1615                // `transaction` two lines up): `DatabaseConnection::ping` (db_connection.rs:462) and the
1616                // `TransactionTrait::begin`/`begin_with_config` impl (db_connection.rs:246,268) — a fixture
1617                // proved `ping` silently read pure with no rule at all.
1618                || path == "sea_orm::DatabaseConnection::ping"
1619                || path == "sea_orm::DatabaseConnection::begin"
1620                || path == "sea_orm::DatabaseConnection::begin_with_config"
1621                // `DatabaseTransaction::commit`/`rollback` (transaction.rs:119,159) call
1622                // `<sqlx::X as sqlx::Database>::TransactionManager::commit`/`rollback` directly — real
1623                // COMMIT/ROLLBACK, not sea_orm's OWN `::begin`/`::run` (both `pub(crate)`, unreachable).
1624                || path == "sea_orm::DatabaseTransaction::commit"
1625                || path == "sea_orm::DatabaseTransaction::rollback"
1626                // The three sqlx-backed pool connections' OWN `begin`/`ping`/`transaction` (driver/
1627                // sqlx_{mysql,postgres,sqlite}.rs) — `begin`/`transaction` acquire a pool connection and
1628                // start a real transaction, `ping` round-trips `conn.ping()` to the server.
1629                || path == "sea_orm::SqlxMySqlPoolConnection::begin"
1630                || path == "sea_orm::SqlxMySqlPoolConnection::ping"
1631                || path == "sea_orm::SqlxMySqlPoolConnection::transaction"
1632                || path == "sea_orm::SqlxPostgresPoolConnection::begin"
1633                || path == "sea_orm::SqlxPostgresPoolConnection::ping"
1634                || path == "sea_orm::SqlxPostgresPoolConnection::transaction"
1635                || path == "sea_orm::SqlxSqlitePoolConnection::begin"
1636                || path == "sea_orm::SqlxSqlitePoolConnection::ping"
1637                || path == "sea_orm::SqlxSqlitePoolConnection::transaction"
1638                // `Paginator::fetch`/`fetch_and_next`/`into_stream`/`num_pages`/`num_items_and_pages`
1639                // (executor/paginator.rs) all transitively reach `fetch_page`/`num_items` — already-
1640                // covered verbs — but each is ALSO a documented entry point a real consumer calls
1641                // directly (`cake::Entity::find().paginate(db, 50).into_stream()`, the crate's own
1642                // pagination doc example) and needs its own rule for the same reason
1643                // `Connection::backup` needed one beside `Backup::new` in rusqlite.
1644                || path == "sea_orm::Paginator::fetch"
1645                || path == "sea_orm::Paginator::fetch_and_next"
1646                || path == "sea_orm::Paginator::into_stream"
1647                || path == "sea_orm::Paginator::num_pages"
1648                || path == "sea_orm::Paginator::num_items_and_pages"
1649                // `Insert`/`Inserter`/`TryInsert::exec_with_returning_keys`/`_many` (executor/insert.rs)
1650                // are sibling spellings of the already-covered `::exec_with_returning` (same shape as
1651                // rusqlite's `open_with_flags_and_vfs` beside `open_with_flags`): each calls through to
1652                // the same underlying `exec_with_returning_keys`/`_many` executor, a real INSERT.
1653                || path.ends_with("::exec_with_returning_keys")
1654                || path.ends_with("::exec_with_returning_many")
1655            {
1656                return Some("Db");
1657            }
1658            return None;
1659        }
1660        // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
1661        // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
1662        // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
1663        //
1664        // `establish` is diesel's OWN name for `::connect` — `Connection::establish(url)`
1665        // (connection/mod.rs:243), implemented by opening the real backend handle in
1666        // `SqliteConnection`/`PgConnection`/`MysqlConnection::establish` (sqlite/connection/mod.rs:230,
1667        // pg/connection/mod.rs:176, mysql/connection/mod.rs:158 — each does the real file-open/socket-
1668        // connect). It is diesel's canonical, and by far most common, connection entry point — every
1669        // diesel quickstart opens with it — yet it shares no verb spelling with `::connect`, so it fell
1670        // through this allowlist to `None`, and because `diesel` IS a CALIBRATED_CRATES entry the miss
1671        // produced NO `coverage.uncovered` disclosure either (that ledger is crate-level: a calibrated
1672        // crate's unmatched path reads as reviewed-pure, not as a gap) — the same silent-purity shape as
1673        // `ignore::Walk::new` above, one call away from `establish_test_transaction`/`establish_inner`,
1674        // which are diesel's own private plumbing and stay unmatched (no `::` in the allowed suffix keeps
1675        // this exact-verb, not a substring match).
1676        const VERBS: [&str; 20] = [
1677            "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
1678            "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
1679            "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
1680            "::first", "::get_result", "::get_results", "::establish",
1681        ];
1682        if VERBS.iter().any(|v| path.ends_with(v)) {
1683            return Some("Db");
1684        }
1685        return None;
1686    }
1687    // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
1688    // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
1689    // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
1690    // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
1691    // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
1692    // fd's residual `Path::symlink_metadata` under-report.)
1693    if let Some(m) = path
1694        .strip_prefix("std::path::Path::")
1695        .or_else(|| path.strip_prefix("std::path::PathBuf::"))
1696    {
1697        const STAT: &[&str] = &[
1698            "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
1699            "try_exists", "is_file", "is_dir", "is_symlink",
1700        ];
1701        return STAT.contains(&m).then_some("Fs");
1702    }
1703    // OPTION-BUILDERS, the shape SPEC §1 ⟨0.32⟩ separates from an invocation object: "option-builders for
1704    // other effects (`OpenOptions`, request builders) stay pure because their resource arrives at the
1705    // terminal verb, which is charged at its own call site". An `OpenOptions` holds a handful of bools and
1706    // names no file; `DirBuilder` holds one bool and a mode. Both sit under the coarse `std::fs::` prefix
1707    // below, which charged Fs for `OpenOptions::new()` and for every `o.read(true)` — MEASURED: a
1708    // `let o = OpenOptions::new().read(true);` with no `open` ANYWHERE reported `Fs`.
1709    //
1710    // A DENYLIST, and keyed on the TYPE. Only the provably-pure setters/ctors are subtracted; anything
1711    // else under the type keeps its effect, so the terminal verb (`OpenOptions::open`, `DirBuilder::create`)
1712    // stays Fs and a std addition we have not read about fails in the safe direction. The type key is not
1713    // decoration: `create` is a pure flag SETTER on `OpenOptions` and the mkdir SYSCALL on `DirBuilder` —
1714    // one leaf, opposite answers. (candor-java keys the same carve-out by DESCRIPTOR, because its collision
1715    // is an overload — `command()` reads back, `command(List)` sets. Rust has no overloads, so the type IS
1716    // the discriminator; a bare-name denylist would silence `DirBuilder::create`.)
1717    if let Some(m) = path.strip_prefix("std::fs::OpenOptions::") {
1718        const PURE: &[&str] = &[
1719            // the builder itself + the std flag setters
1720            "new", "read", "write", "append", "truncate", "create", "create_new",
1721            // the platform extension setters (`OpenOptionsExt`, unix + windows)
1722            "mode", "custom_flags", "security_qos_flags", "access_mode", "share_mode", "attributes",
1723            // derives
1724            "clone", "default", "fmt", "eq", "ne", "hash",
1725        ];
1726        return (!PURE.contains(&m)).then_some("Fs");
1727    }
1728    if let Some(m) = path.strip_prefix("std::fs::DirBuilder::") {
1729        const PURE: &[&str] = &["new", "recursive", "mode", "clone", "default", "fmt"];
1730        return (!PURE.contains(&m)).then_some("Fs");
1731    }
1732    // THE PLATFORM `fs` MODULES — `std::os::{unix,windows,wasi}::fs`. Measured 2026-09-02, ground truth
1733    // EXECUTED (a `cargo run` that really created a symlink on disk and then stat'd it back):
1734    // `std::os::unix::fs::symlink`, `chown`, `lchown` and `chroot` all read PURE, so `deny Fs` over a
1735    // crate whose only filesystem write is a symlink exited 0. The `std::fs::` prefix below is the whole
1736    // filesystem rule and these modules are simply not under it — the platform-specific half of std's
1737    // filesystem API had no rule at all. (Found tracing why `tokio::fs::symlink` carries no `Fs` of its
1738    // own; its body is `asyncify(move || std::os::unix::fs::symlink(..))`.)
1739    //
1740    // A DENYLIST keyed on the TRAIT, exactly like `OpenOptions`/`DirBuilder` above and for the same
1741    // reason: subtract only the provably-pure surfaces, so a std addition nobody here has read about
1742    // fails in the SAFE direction. What is subtracted, and why each is pure:
1743    //   * `MetadataExt` / `DirEntryExt` / `FileTypeExt` — accessors over data ALREADY fetched by the
1744    //     `metadata()`/`read_dir()` call that is charged at its own site. `m.uid()` issues no syscall.
1745    //   * `PermissionsExt` — reads/sets bits on an in-memory `Permissions`; the syscall is
1746    //     `set_permissions`, charged under `std::fs::`.
1747    //   * `OpenOptionsExt` / `DirBuilderExt` — SPEC §1 ⟨0.32⟩ option-builders: the resource arrives at
1748    //     the terminal verb (`open`/`create`), which is charged at its own call site.
1749    // `FileExt` is deliberately NOT subtracted — `read_at`/`write_at`/`seek_read`/`seek_write` are the
1750    // positional-I/O syscalls and are exactly what this rule must keep.
1751    if let Some(rest) = path
1752        .strip_prefix("std::os::unix::fs::")
1753        .or_else(|| path.strip_prefix("std::os::windows::fs::"))
1754        .or_else(|| path.strip_prefix("std::os::wasi::fs::"))
1755    {
1756        const PURE_TRAITS: &[&str] = &[
1757            "MetadataExt::", "DirEntryExt::", "DirEntryExt2::", "FileTypeExt::",
1758            "PermissionsExt::", "OpenOptionsExt::", "DirBuilderExt::",
1759        ];
1760        return (!PURE_TRAITS.iter().any(|t| rest.starts_with(t))).then_some("Fs");
1761    }
1762    // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
1763    // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
1764    if path.starts_with("std::fs::")
1765        || path.starts_with("tokio::fs::")
1766        || path.starts_with("async_std::fs::")
1767        || crate_name == "async_fs"
1768        || crate_name == "fs_err"
1769    {
1770        return Some("Fs");
1771    }
1772    // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
1773    // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
1774    // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
1775    // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
1776    // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
1777    // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
1778    // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
1779    // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
1780    if crate_name == "memmap2" {
1781        let m = path.rsplit("::").next().unwrap_or(path);
1782        if m.starts_with("map")
1783            || m == "flush"
1784            || m == "flush_async"
1785            || m == "flush_range"
1786            || m == "flush_async_range"
1787            || m == "remap"
1788            || m.starts_with("make_")
1789            || m == "advise"
1790            || m == "advise_range"
1791            || m == "lock"
1792            || m == "unlock"
1793        {
1794            return Some("Fs");
1795        }
1796        return None;
1797    }
1798    // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
1799    // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
1800    // the file on disk; `close` removes it.
1801    if crate_name == "tempfile"
1802        && (path.ends_with("::tempfile")
1803            || path.ends_with("::tempfile_in")
1804            || path.ends_with("::tempdir")
1805            || path.ends_with("::tempdir_in")
1806            || path.ends_with("NamedTempFile::new")
1807            || path.ends_with("NamedTempFile::new_in")
1808            || path.ends_with("TempDir::new")
1809            || path.ends_with("TempDir::new_in")
1810            || path.ends_with("::persist")
1811            || path.ends_with("::persist_noclobber")
1812            || path.ends_with("::keep"))
1813    {
1814        return Some("Fs");
1815    }
1816    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against tempfile 3.27.0 source — the SIBLING
1817    // constructors/closers the create/persist verb list above never reached:
1818    //
1819    // `Builder::make`/`make_in` (lib.rs:716,739) are the crate's OWN documented escape hatch for a
1820    // caller-supplied factory (`Builder::new().make_in(dir, |path| UnixListener::bind(path))`) — real
1821    // disk creation via `util::create_helper`, same mechanism as `Builder::tempfile`/`tempfile_in`
1822    // two lines up, just a different entry verb.
1823    // `NamedTempFile::with_prefix`/`with_prefix_in`/`with_suffix`/`with_suffix_in` (file/mod.rs:630-677)
1824    // are one-call convenience wrappers documented as equivalent to `Builder::new().prefix(..).tempfile()`
1825    // — real creation, missing because the verb list matched `NamedTempFile::new`/`new_in` but not these.
1826    // `NamedTempFile::reopen` (file/mod.rs:951) calls the platform `imp::reopen`, a real `open`/`fstat`
1827    // pair (found the original file was replaced, or hands back a fresh handle to it).
1828    // `TempPath::close` (file/mod.rs:161) and `TempDir::close` (dir/mod.rs:470) `fs::remove_file`/
1829    // `remove_dir_all` the real path — `NamedTempFile::close` (file/mod.rs:727) is a thin `self.path.close()`
1830    // delegate to the same `TempPath::close`, so matching the `::close` suffix (crate-gated, and the ONLY
1831    // three `pub fn close` in this crate) catches it too without a separate rule.
1832    // `SpooledTempFile::set_len` (spooled.rs:134) calls `File::set_len` on the real disk file once the
1833    // in-memory buffer has spilled past `max_size` — the crate's own roll-over threshold, not a corner case.
1834    //
1835    // NOT included: `tempfile::create`/`create_named`/`reopen` (dir/imp/any.rs, file/imp/windows.rs,
1836    // file/imp/unix.rs) — despite being `pub fn`, `mod dir;`/`mod file;` are PRIVATE at the crate root
1837    // (lib.rs:202,204) with no re-export of the `imp` submodules, so no external consumer can ever name
1838    // these paths; the ratchet's generator guesses a crate-root alias for every candidate regardless of
1839    // whether one is real (the same "second, related shape" gap the previous pass's rusqlite/lettre
1840    // private-module findings already documented — restricted_types()/is_bare_pub check a type's or fn's
1841    // OWN visibility keyword, not its enclosing module chain). Removed from open.tsv rather than guessed.
1842    if crate_name == "tempfile"
1843        && (path == "tempfile::Builder::make"
1844            || path == "tempfile::Builder::make_in"
1845            || path == "tempfile::NamedTempFile::with_prefix"
1846            || path == "tempfile::NamedTempFile::with_prefix_in"
1847            || path == "tempfile::NamedTempFile::with_suffix"
1848            || path == "tempfile::NamedTempFile::with_suffix_in"
1849            || path == "tempfile::NamedTempFile::reopen"
1850            || path == "tempfile::SpooledTempFile::set_len"
1851            || path.ends_with("::close"))
1852    {
1853        return Some("Fs");
1854    }
1855    // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
1856    // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
1857    if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
1858        return Some("Fs");
1859    }
1860    // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
1861    // argon2 landed on exactly the call a security review cares about). In this engine's
1862    // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
1863    // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
1864    // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
1865    // carries the OsRng source itself (otherwise the most common salt mint is invisible).
1866    if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
1867        if path.contains("SaltString::generate") {
1868            return Some("Rand");
1869        }
1870        return None;
1871    }
1872    if crate_name == "bcrypt" {
1873        if path.ends_with("::hash") || path.ends_with("::hash_with_result") {
1874            return Some("Rand");
1875        }
1876        return None;
1877    }
1878    if crate_name == "rand_core" {
1879        if path.contains("OsRng")
1880            || path.ends_with("::next_u32")
1881            || path.ends_with("::next_u64")
1882            || path.ends_with("::fill_bytes")
1883        {
1884            return Some("Rand");
1885        }
1886        return None;
1887    }
1888    // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
1889    // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
1890    // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
1891    // over-reported those as `Rand`; match only the calls that actually consume randomness — the
1892    // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
1893    // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
1894    if crate_name == "getrandom" {
1895        return Some("Rand");
1896    }
1897    // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
1898    // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
1899    // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
1900    // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
1901    // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
1902    // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
1903    // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
1904    // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
1905    // (i.e. a value draw) is Rand.
1906    if crate_name == "fastrand" {
1907        let m = path.rsplit("::").next().unwrap_or(path);
1908        // Provably pure: deterministic seeded ctor + state split/copy.
1909        if m == "with_seed" || m == "fork" || m == "clone" {
1910            return None;
1911        }
1912        // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
1913        // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
1914        // method we don't recognise — keep the effect, the safe direction.)
1915        return Some("Rand");
1916    }
1917    if crate_name == "rand" {
1918        let rng_verb = path.ends_with("::gen")
1919            || path.ends_with("::gen_range")
1920            || path.ends_with("::gen_bool")
1921            || path.ends_with("::gen_ratio")
1922            || path.ends_with("::random")
1923            || path.ends_with("::random_range")
1924            || path.ends_with("::random_bool")
1925            || path.ends_with("::random_ratio")
1926            || path.ends_with("::random_iter") // rand 0.9 iterator generator
1927            || path.ends_with("::gen_iter")
1928            || path.ends_with("::fill")
1929            || path.ends_with("::fill_bytes")
1930            || path.ends_with("::try_fill")
1931            || path.ends_with("::try_fill_bytes")
1932            || path.ends_with("::sample")
1933            || path.ends_with("::sample_iter")
1934            || path.ends_with("::next_u32")
1935            || path.ends_with("::next_u64")
1936            || path.ends_with("::thread_rng")
1937            || path.ends_with("::rng")
1938            || path.ends_with("::from_entropy")
1939            || path.ends_with("::from_os_rng");
1940        // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
1941        // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
1942        // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
1943        // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
1944        // review: OsRng is a unit struct, cloning consumes nothing).
1945        let m = path.rsplit("::").next().unwrap_or(path);
1946        let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
1947        if rng_verb || os_rng {
1948            return Some("Rand");
1949        }
1950        return None;
1951    }
1952    // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
1953    // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
1954    // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
1955    // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
1956    // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
1957    if path.starts_with("std::process::Command")
1958        || path.starts_with("std::process::Child")
1959        || path.starts_with("tokio::process::Command")
1960        || path.starts_with("tokio::process::Child")
1961        || path.starts_with("async_std::process::Command")
1962        || path.starts_with("async_std::process::Child")
1963    {
1964        // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
1965        // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
1966        // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
1967        // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
1968        if path.ends_with("::get_program")
1969            || path.ends_with("::get_args")
1970            || path.ends_with("::get_envs")
1971            || path.ends_with("::get_current_dir")
1972            || path.ends_with("Child::id")
1973        {
1974            return None;
1975        }
1976        return Some("Exec");
1977    }
1978    // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
1979    // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
1980    // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
1981    // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
1982    // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
1983    // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
1984    // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
1985    // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
1986    if crate_name == "async_process" || crate_name == "portable_pty" {
1987        let m = path.rsplit("::").next().unwrap_or(path);
1988        // THE COVERAGE-GATE SWEEP (2026-08-27): `CommandBuilder::get_shell` (cmdbuilder.rs:544, unix)
1989        // LOOKS like a config read-back getter — same `get_` naming as the pure getters below — but its
1990        // unix body calls `nix::unistd::access(shell, X_OK)`, a real filesystem-access syscall, before
1991        // falling back to a password-database lookup if `$SHELL` isn't set or isn't executable. Carved
1992        // out of the `get_` prefix exemption BEFORE it, not folded into it — the one getter this crate
1993        // exposes that is not actually pure. Verified against portable-pty 0.9.0.
1994        if m == "get_shell" {
1995            return Some("Fs");
1996        }
1997        // configuration read-back getters — pure (no spawn).
1998        if m.starts_with("get_") || m == "as_unix_command_line" {
1999            return None;
2000        }
2001        // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
2002        if matches!(
2003            m,
2004            "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
2005                | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
2006                | "cwd" | "current_dir" | "rows" | "cols"
2007                | "clone" | "fmt" | "eq" | "ne" | "hash"
2008        ) {
2009            return None;
2010        }
2011        return Some("Exec");
2012    }
2013    // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
2014    // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
2015    if crate_name == "duct"
2016        && (path.ends_with("::run")
2017            || path.ends_with("::read")
2018            || path.ends_with("::start")
2019            || path.ends_with("::read_chars")
2020            // `Expression::reader` (lib.rs:357) calls the already-covered `.start()` internally to
2021            // launch the child and hands back a streaming `ReaderHandle` — a distinct public entry
2022            // point from `start` itself, so the suffix match above never reached it.
2023            || path.ends_with("Expression::reader"))
2024    {
2025        return Some("Exec");
2026    }
2027    if path.starts_with("std::env::") {
2028        return Some("Env");
2029    }
2030    // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
2031    // environment). Match the load/read entry points; `Error`/builder types stay pure.
2032    if matches!(crate_name, "dotenvy" | "dotenv")
2033        && (path.ends_with("::dotenv")
2034            || path.ends_with("::dotenv_override")
2035            || path.ends_with("::from_path")
2036            || path.ends_with("::from_path_override")
2037            || path.ends_with("::from_filename")
2038            || path.ends_with("::from_filename_override")
2039            || path.ends_with("::from_read")
2040            || path.ends_with("::from_read_override")
2041            || path.ends_with("::load")
2042            || path.ends_with("::var")
2043            || path.ends_with("::vars"))
2044    {
2045        return Some("Env");
2046    }
2047    // THE COVERAGE-GATE SWEEP (2026-08-27), verified against dotenv 0.15.0 and dotenvy 0.15.7
2048    // (byte-identical shape in both crates) — the ITERATOR-returning siblings of the load verbs above,
2049    // which return the parsed pairs instead of mutating the environment but still touch disk to get
2050    // them: `dotenv_iter`/`from_filename_iter` (lib.rs) resolve and open a `.env`-shaped file exactly
2051    // like `dotenv`/`from_filename` two lines up (same `Finder::new().find()` call); `from_path_iter`
2052    // opens the given path directly. Bucketed `Env` for consistency with the rest of this family rather
2053    // than `Fs` (self-scan's raw signal): the crate's OWN purpose is env-var loading, and splitting the
2054    // iterator variants into a different effect bucket from their non-iterator siblings would be an
2055    // arbitrary distinction a reviewer gains nothing from.
2056    //
2057    // NOT included: `Finder::find` / the free fn `find` (find.rs) — both are used only via a PRIVATE
2058    // `use crate::find::Finder;` (no `pub use`) inside a private `mod find;` (lib.rs), so neither
2059    // `dotenv(y)::Finder` nor `dotenv(y)::find` is a path any external consumer can compile a call to;
2060    // the ratchet's generator still guesses a crate-root alias for every candidate (the same private-
2061    // module shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2062    if matches!(crate_name, "dotenvy" | "dotenv")
2063        && (path.ends_with("::dotenv_iter")
2064            || path.ends_with("::from_filename_iter")
2065            || path.ends_with("::from_path_iter"))
2066    {
2067        return Some("Env");
2068    }
2069    // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
2070    // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
2071    // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
2072    if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
2073        return Some("Clock");
2074    }
2075    if crate_name == "time"
2076        && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
2077    {
2078        return Some("Clock");
2079    }
2080    // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
2081    // the emit, not the whole crate. The actual program output is the macro-expanded
2082    // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
2083    // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
2084    // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
2085    // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
2086    // fabricated Log on them. Match the emit verbs; everything else returns None.
2087    if crate_name == "tracing" {
2088        let m = path.rsplit("::").next().unwrap_or(path);
2089        // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
2090        // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
2091        // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
2092        // have other tails and stay None.
2093        if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
2094            || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
2095            || m == "error_span" || m == "span"
2096            || m == "event"
2097            || m == "new_span"
2098            || m == "record"
2099            || m == "record_follows_from"
2100            || m == "enter"
2101            || m == "exit"
2102            || m == "in_scope"
2103            || m == "entered"
2104            || path.contains("::__macro_support")
2105            || path.contains("::__tracing")
2106            || path.contains("Subscriber::event")
2107            || path.contains("Subscriber::new_span")
2108            || path.contains("Subscriber::enter")
2109            || path.contains("Subscriber::exit")
2110        {
2111            return Some("Log");
2112        }
2113        return None;
2114    }
2115    // The `log` facade: its macros route through `log::__private_api`; the crate's types
2116    // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
2117    if crate_name == "log" {
2118        // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
2119        // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
2120        let m = path.rsplit("::").next().unwrap_or(path);
2121        if path.contains("::__private_api")
2122            || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
2123        {
2124            return Some("Log");
2125        }
2126    }
2127    // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
2128    // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
2129    // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
2130    // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
2131    // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
2132    if crate_name == "rustc_lint"
2133        && (path.ends_with("::emit_span_lint")
2134            || path.ends_with("::span_lint")
2135            || path.ends_with("::span_lint_hir"))
2136    {
2137        return Some("Log");
2138    }
2139    if crate_name == "rustc_errors"
2140        && (path.ends_with("::emit")
2141            || path.ends_with("::emit_diagnostic")
2142            || path.ends_with("::emit_now"))
2143    {
2144        return Some("Log");
2145    }
2146    // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
2147    // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
2148    // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
2149    // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
2150    // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
2151    // connection to the clipboard server, so it's an effect too; `get`/`set` return the
2152    // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
2153    //
2154    // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28), verified against arboard 3.6.1: `file_list`
2155    // (lib.rs:205,251, `Get::file_list`/`Set::file_list`) is the SAME builder-then-terminal shape as
2156    // `text`/`image`/`html` two lines up — reading/writing the clipboard's file-path list — but was
2157    // missing, not because it's ambiguous (it's the only `file_list` in the crate) but because
2158    // `eval/coverage-gate/generate.py`'s own generator only triggers on a self-scan `inferred` set
2159    // containing Fs/Net/Db/Exec; Clipboard isn't in that trigger set, so a missing Clipboard verb is
2160    // structurally invisible to the completeness gate regardless of phrasing. `Clear::default`
2161    // (lib.rs:265) is `Clipboard::clear`'s own documented alternate entry point
2162    // (`clear() { self.clear_with().default() }`, lib.rs:156-158) — the SAME "sibling constructor"
2163    // shape as `ignore::Walk::new` beside `WalkBuilder::build`; `clear_with()` itself doesn't share
2164    // `clear`'s leaf, so neither the constructor nor its terminal was covered. `default` is otherwise
2165    // unused in this crate (no derived `Default` impls), so the bare leaf carries no fabrication risk
2166    // once crate-gated.
2167    if crate_name == "arboard" {
2168        let m = path.rsplit("::").next().unwrap_or(path);
2169        if m == "new"
2170            || m == "get"
2171            || m == "set"
2172            || m == "clear"
2173            || m == "get_text"
2174            || m == "set_text"
2175            || m == "set_html"
2176            || m == "get_image"
2177            || m == "set_image"
2178            || m == "text"
2179            || m == "image"
2180            || m == "html"
2181            || m == "file_list"
2182            || m == "default"
2183        {
2184            return Some("Clipboard");
2185        }
2186        return None;
2187    }
2188    // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
2189    //    notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
2190
2191    // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
2192    // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
2193    // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
2194    // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
2195    if crate_name == "etcetera" {
2196        let m = path.rsplit("::").next().unwrap_or(path);
2197        if m == "home_dir"
2198            || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
2199            || m == "config_dir" || m == "data_dir" || m == "cache_dir"
2200            || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
2201        {
2202            return Some("Env");
2203        }
2204        return None;
2205    }
2206    // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
2207    // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
2208    // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
2209    // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
2210    if crate_name == "sqlx_core" {
2211        if path.ends_with("::connect") || path.ends_with("::connect_with") {
2212            return Some("Net");
2213        }
2214        if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
2215            || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
2216            || path.ends_with("::execute") || path.ends_with("::execute_many")
2217            || path.ends_with("::prepare") || path.ends_with("::prepare_with")
2218            || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
2219        {
2220            return Some("Db");
2221        }
2222        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against sqlx-core 0.8.6/0.9.0 (identical
2223        // shape in both) — the free-function surface under `pub mod fs`/`pub mod net::tls`/
2224        // `pub mod migrate`, none of which end in the connect/fetch/execute verbs above:
2225        //
2226        // `fs::{read,read_to_string,create_dir_all,remove_file,remove_dir,remove_dir_all,read_dir}`
2227        // (fs.rs) are direct `std::fs::*` calls off-loaded to `spawn_blocking` — sqlx's OWN async
2228        // filesystem shim, used by the migrator to read `.sql` files and by SQLite to manage on-disk
2229        // databases. `net::tls::handshake` (net/tls/mod.rs:69) is the real TLS handshake dispatch
2230        // reqwest/ureq-style crates already model — sqlx has its OWN copy because it drives the
2231        // handshake over its own `Socket` trait rather than a `TcpStream` directly.
2232        // `migrate::resolve_blocking`/`resolve_blocking_with_config` (migrate/source.rs:151,156) are
2233        // `#[doc(hidden)]` but still `pub use`-exported at `sqlx_core::migrate` (mod.rs:16) — real,
2234        // callable, and reads the migrations directory off disk (`fs::read_dir` + per-file `canonicalize`).
2235        //
2236        // NOT included: `net::tls::tls_rustls::RustlsSocket::poll_{flush,read_ready,write_ready,
2237        // shutdown}` — `RustlsSocket` is declared inside `mod tls_rustls;` (net/tls/mod.rs), which is
2238        // PRIVATE with no re-export, so no external consumer can ever name it (the same private-module
2239        // shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2240        if path == "sqlx_core::fs::read"
2241            || path == "sqlx_core::fs::read_to_string"
2242            || path == "sqlx_core::fs::create_dir_all"
2243            || path == "sqlx_core::fs::remove_file"
2244            || path == "sqlx_core::fs::remove_dir"
2245            || path == "sqlx_core::fs::remove_dir_all"
2246            || path == "sqlx_core::fs::read_dir"
2247            || path.ends_with("::migrate::resolve_blocking")
2248            || path.ends_with("::migrate::resolve_blocking_with_config")
2249        {
2250            return Some("Fs");
2251        }
2252        if path.ends_with("::net::tls::handshake") {
2253            return Some("Net");
2254        }
2255        return None;
2256    }
2257    // `walkdir` — recursive directory traversal. Charged at `WalkDir::new` (construction), the SAME
2258    // point `ignore::WalkBuilder::build`/`glob::glob` are charged, and NOT at `IntoIter::next` (driving
2259    // the iterator) as this rule originally read. The disk read (`read_dir`+`stat`) is technically lazy
2260    // — nothing happens until the value is iterated — but `.next()` is reached ONLY through a receiver
2261    // typed as `walkdir::IntoIter`, and candor-scan's receiver-typing (`ctor_type`/`resolve_recv_type`)
2262    // hard-blocks the `.into_iter()` verb (a guard against fabricating onto a DIFFERENT std type, e.g.
2263    // `Vec::into_iter()` -> `std::vec::IntoIter`) with no per-crate exception for the SAME-crate case.
2264    // `WalkDir::into_iter()` returns `walkdir::IntoIter` (same crate as `WalkDir` itself, exactly the
2265    // shape the blocklist exists to protect, not the shape it should be blocking) — so every idiomatic
2266    // form (`for e in WalkDir::new(p)`, `.into_iter().count()`, `.into_iter().filter_map(..)`, an
2267    // untyped `let it = ...into_iter(); it.next()`) never reaches a typed `IntoIter::next` receiver and
2268    // read silent-pure under the OLD rule, with `deny Fs` exiting 0 over code that walks the filesystem.
2269    // Charging at `WalkDir::new` needs no receiver typing at all — it's a plain `Expr::Call`, robust to
2270    // however the iterator is later consumed. `max_depth`/`follow_links`/`sort_by`/`into_iter` (the
2271    // BUILDER chain, no I/O until pulled) and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
2272    // accessors (`file_type` makes NO syscall) stay PURE — matched by nothing here, so they fall through.
2273    // The `IntoIter::next`/`DirEntry::metadata` rule is KEPT, not dead: it still fires whenever the
2274    // concrete `walkdir::IntoIter` type reaches `.next()` by a route the blocklist doesn't gate — an
2275    // EXPLICIT type annotation (`let it: walkdir::IntoIter = ...`), a struct field declared with that
2276    // type, or a local fn's declared return type — real, if rarer, shapes than the inline-chain idiom.
2277    // (Companion to the already-modeled `ignore`.)
2278    if crate_name == "walkdir" {
2279        if path.ends_with("::WalkDir::new")
2280            || path.ends_with("::IntoIter::next")
2281            || path.ends_with("::DirEntry::metadata")
2282        {
2283            return Some("Fs");
2284        }
2285        return None;
2286    }
2287    // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
2288    // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
2289    // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
2290    // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
2291    if crate_name == "filetime" {
2292        if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
2293            || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
2294            || path.ends_with("::set_file_handle_times")
2295        {
2296            return Some("Fs");
2297        }
2298        if path.ends_with("::FileTime::now") {
2299            return Some("Clock");
2300        }
2301        return None;
2302    }
2303    // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
2304    // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
2305    // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
2306    if crate_name == "execute" {
2307        if path.contains("::execute") {
2308            return Some("Exec");
2309        }
2310        return None;
2311    }
2312    // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
2313    // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
2314    // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
2315    if crate_name == "ctrlc" {
2316        if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
2317            return Some("Ipc");
2318        }
2319        return None;
2320    }
2321    // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
2322    // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
2323    // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
2324    // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
2325    // read argv) so they stay pure too.
2326    //
2327    // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28): this file used to leave `Arg::env` unmodeled, calling
2328    // bare `::env` "too generic to gate safely" — the same words used for libc's genuinely ambiguous fd
2329    // verbs. It isn't the same shape: `Arg::env(name)` calls `env::var_os(&name)` DIRECTLY at builder
2330    // time (verified against clap_builder 4.6.6, builder/arg.rs:2205-2213 — real, immediate, not
2331    // deferred to `get_matches()`), and — unlike a bare fd, which could be Fs/Net/Ipc — `clap_builder`
2332    // has exactly ONE `pub fn` ending `::env` in its entire source (`Arg::env`; the deprecated
2333    // `Arg::env_os` is a one-line delegate to it). This whole match arm is already crate-gated on
2334    // `crate_name == "clap"`, so no ambiguity survives to fabricate on: a fixture proved the untreated
2335    // gap silently vanishes the function ("functions": [] — the exact R59 shape, worse than an
2336    // uncalibrated dependency, since `clap` is fully `CALIBRATED_CRATES` and the miss reads as
2337    // reviewed-pure rather than disclosed). Classified, not carved into
2338    // `CALIBRATED_BUT_PARTIAL_CRATES`, because the effect IS unambiguous once actually checked.
2339    if crate_name == "clap" {
2340        if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
2341            || path.ends_with("::try_get_matches")
2342            || path.ends_with("::parse") || path.ends_with("::try_parse")
2343            || path.ends_with("::env") || path.ends_with("::env_os")
2344        {
2345            return Some("Env");
2346        }
2347        return None;
2348    }
2349    // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
2350    // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
2351    // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
2352    // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
2353    if crate_name == "jiff" {
2354        if path.ends_with("::now") || path.ends_with("::now_with") {
2355            return Some("Clock");
2356        }
2357        if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
2358            || path.ends_with("::TimeZoneDatabase::get")
2359            // `TimeZone::try_system` (tz/timezone.rs:391) is `TimeZone::system`'s fallible sibling —
2360            // same `crate::tz::system::get(crate::tz::db())` call, real localtime/zoneinfo disk read.
2361            || path.ends_with("::TimeZone::try_system")
2362        {
2363            return Some("Fs");
2364        }
2365        return None;
2366    }
2367    // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
2368    // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
2369    // `Builder::new`/`build` and the format/filter/target config setters are PURE.
2370    if crate_name == "env_logger" {
2371        if path.ends_with("::init") || path.ends_with("::try_init")
2372            || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
2373        {
2374            return Some("Log");
2375        }
2376        return None;
2377    }
2378    // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
2379    // console dialogue with the user — Ipc, like the other local-channel effects). The
2380    // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
2381    if crate_name == "dialoguer" {
2382        if path.ends_with("::interact") || path.ends_with("::interact_on")
2383            || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
2384            || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
2385        {
2386            return Some("Ipc");
2387        }
2388        // `Editor::edit` (edit.rs:93) writes the caller's string to a real tempfile, spawns
2389        // `$VISUAL`/`$EDITOR` on it via `process::Command::spawn().wait()`, then reads the result back
2390        // — a real subprocess launch, not a terminal-dialogue read/write like the verbs above.
2391        if path.ends_with("Editor::edit") {
2392            return Some("Exec");
2393        }
2394        // WIDENED-CORE AUDIT (2026-08-28): `Editor::new`/`Editor::default` (edit.rs:54,44) call
2395        // `get_default_editor()` (edit.rs:31), which reads `env::var_os("VISUAL")` then `("EDITOR")`
2396        // IMMEDIATELY at construction — a real, independent Env read a consumer triggers just by
2397        // building an `Editor`, whether or not `.edit()` is ever called (the SAME "builder-time env
2398        // read, separate from the terminal verb" shape as `clap::Arg::env`, R59-class). Verified against
2399        // dialoguer 0.12.0: `get_default_editor` is the ONLY caller of either `env::var_os`, so there is
2400        // no ambiguity gating this to just these two constructors.
2401        if path.ends_with("Editor::new") || path.ends_with("Editor::default") {
2402            return Some("Env");
2403        }
2404        return None;
2405    }
2406    // `tracing_subscriber` — the subscriber that gives `tracing` somewhere to go. TWO effects, and the
2407    // filing said "Log/Fs": VERIFIED against 0.3.23, the Fs half is WRONG.
2408    //
2409    //   Log — `fmt/fmt_layer.rs:749` defaults `make_writer: io::stdout`, so the fmt INIT terminals install
2410    //         a subscriber that writes program output. Same family as `log`/`tracing`/`env_logger`.
2411    //   Env — `fmt/mod.rs:1219` reads `RUST_LOG` on the `init()` path, `fmt_layer.rs` reads `NO_COLOR`,
2412    //         and `filter/env/builder.rs:189,203` read `env::var(self.env_var_name())`.
2413    //
2414    // NOT Fs. The only `std::fs` in the crate is `impl MakeWriter for std::fs::File` — the crate ACCEPTING
2415    // a caller-supplied File, not opening one. The caller's `File::create` is classified on the caller, so
2416    // charging Fs here would double-count, exactly the `serde_json::from_reader` caveat one crate over.
2417    //
2418    // The builders (`fmt()`, `layer()`, `with_writer`, `with_target`, `EnvFilter::new`) are PURE: they
2419    // describe a subscriber. Only the INIT terminals install one, and only the from-env constructors read.
2420    if crate_name == "tracing_subscriber" {
2421        if path.ends_with("::init") || path.ends_with("::try_init") {
2422            return Some("Log");
2423        }
2424        if path.ends_with("::from_default_env") || path.ends_with("::try_from_default_env")
2425            || path.ends_with("::from_env") || path.ends_with("::from_env_lossy")
2426            || path.ends_with("::try_from_env")
2427        {
2428            return Some("Env");
2429        }
2430        return None;
2431    }
2432    // `crossterm` — the terminal driver. The tty is a USER DIALOGUE CHANNEL, so this is Ipc, matching the
2433    // ruling `dialoguer`/`console`/`terminal_colorsaurus` already carry rather than a new one.
2434    //
2435    // VERIFIED against crossterm-0.28.1 rather than assumed: `command.rs` `execute`/`queue` end in
2436    // `self.flush()?` on the writer (real code, not a doc example), `event::read`/`poll` read tty input,
2437    // and `terminal::{enable,disable}_raw_mode` + `size`/`window_size` talk to the device.
2438    //
2439    // `size`/`window_size`/`is_raw_mode_enabled` ARE classified, and that is deliberate: once a crate is
2440    // CALIBRATED every unmatched path becomes a PURITY CLAIM rather than a disclosed blind spot, so a tty
2441    // ioctl left to fall through would be claimed pure. The genuinely pure surface — the Command VALUE
2442    // types (`Print`, `MoveTo`, `SetForegroundColor`), the style/event data types — carries none of these
2443    // tails and stays pure correctly.
2444    if crate_name == "crossterm" {
2445        if path.ends_with("::execute") || path.ends_with("::queue")
2446            || path.ends_with("::event::read") || path.ends_with("::event::poll")
2447            || path.ends_with("::enable_raw_mode") || path.ends_with("::disable_raw_mode")
2448            || path.ends_with("::size") || path.ends_with("::window_size")
2449            || path.ends_with("::is_raw_mode_enabled")
2450        {
2451            return Some("Ipc");
2452        }
2453        // `terminal::supports_keyboard_enhancement` (terminal/sys/unix.rs:188, re-exported `pub use
2454        // sys::supports_keyboard_enhancement` in terminal.rs) drives the SAME channel as
2455        // `event::read`/`enable_raw_mode` above (it toggles raw mode, then reads/polls terminal events
2456        // to detect the kitty keyboard protocol) — bucketed `Ipc` for consistency with the rest of this
2457        // tty-dialogue family rather than the `Fs` self-scan's raw signal reports (that signal traces
2458        // through the crate's internal event source opening `/dev/tty` as a fallback fd, the same
2459        // primitive `tty_fd` below wraps — not a distinct effect from the terminal channel itself).
2460        //
2461        // NOT modelled: `tty_fd` (terminal/sys/file_descriptor.rs:124) — declared inside `pub(crate) mod
2462        // sys;` (terminal.rs:99), so despite being `pub fn` it is unreachable from outside the crate
2463        // (only used internally by the event source). Removed from open.tsv rather than guessed.
2464        if path.ends_with("::supports_keyboard_enhancement") {
2465            return Some("Ipc");
2466        }
2467        return None;
2468    }
2469    // `ratatui` — the TUI renderer, and the single loudest source of disclosed-blind calls measured in the
2470    // 2026-07-14 four-ecosystem sweep (3,345 across three real repos). The backlog filed it as
2471    // "mark reviewed-pure"; VERIFYING against ratatui-0.29.0 REFUTES that for part of the surface:
2472    // `terminal/terminal.rs` `draw`/`flush`/`clear`/`autoresize`/`hide_cursor`/`show_cursor` end in a
2473    // backend flush, and `backend/` writes to the terminal. Marking the whole crate pure would have
2474    // claimed purity over the one API that actually writes.
2475    //
2476    // So the split is where the sweep's noise actually is: the BULK of those 3,345 calls are widget,
2477    // layout, buffer, style and text constructors — genuinely pure, and now covered rather than disclosed.
2478    // The Terminal/backend verbs are Ipc, same channel as crossterm underneath them.
2479    if crate_name == "ratatui" {
2480        // CARVE-OUT FIRST: `widgets::canvas` is an IN-MEMORY grid. `Context::draw(&shape)` sets
2481        // `self.dirty` and paints into a `Painter` — no terminal, no writer, provably pure — but it ends
2482        // in `::draw` and the tails below would have charged it `Ipc`. MEASURED as a live fabrication on a
2483        // fixture (`plot(ctx) -> ['Ipc']`) before this line existed, and it is a HOT path: a TUI drawing
2484        // charts or maps calls it per shape per frame.
2485        //
2486        // A DENYLIST (carve out the proven-pure module) rather than an allowlist of `Terminal::`, per the
2487        // family rule: an allowlist silently under-reports whatever it forgot, and the write surface here
2488        // is Terminal AND the backends (`CrosstermBackend::flush`), so pinning to `Terminal::` would drop
2489        // a direct backend call. Reading the crate, canvas is the only module whose methods collide with
2490        // these tails.
2491        if path.contains("::canvas::") {
2492            return None;
2493        }
2494        if path.ends_with("::draw") || path.ends_with("::try_draw") || path.ends_with("::flush")
2495            || path.ends_with("::autoresize") || path.ends_with("::clear")
2496            || path.ends_with("::hide_cursor") || path.ends_with("::show_cursor")
2497            || path.ends_with("::insert_before")
2498            || path.ends_with("::set_cursor_position") || path.ends_with("::get_cursor_position")
2499        {
2500            return Some("Ipc");
2501        }
2502        return None;
2503    }
2504    // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
2505    // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
2506    // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
2507    // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
2508    //
2509    // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28): the note above ("no `write_str` — `Term` impls
2510    // `io::Write`") RECORDED the trait impl without covering it. `Term::write`/`Term::flush` (console
2511    // 0.15.11, term.rs:622-633) call `self.write_through(buf)` — the SAME primitive `write_line` already
2512    // charges Ipc for — and `Term::read` (term.rs:650-654) calls `io::stdin().read(buf)` directly. These
2513    // are a second, real entry point to the identical tty channel, missed because `read`/`write`/`flush`
2514    // are the names every I/O type shares — but crate-gated on `crate_name == "console"`, term.rs
2515    // defines exactly one of each (verified against the real source; no ambiguity survives the gate,
2516    // same shape as `clap::Arg::env`). Left unclassified, a fn calling only `term.write_all(..)` (no
2517    // `write_line`) read as reviewed-pure, since `console` is fully `CALIBRATED_CRATES`.
2518    if crate_name == "console" {
2519        if path.ends_with("::write_line") || path.ends_with("::read_line")
2520            || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
2521            || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
2522            || path.ends_with("::read_secure_line")
2523            || path.ends_with("Term::write") || path.ends_with("Term::flush")
2524            || path.ends_with("Term::read")
2525        {
2526            return Some("Ipc");
2527        }
2528        if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
2529            || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
2530        {
2531            return Some("Env");
2532        }
2533        return None;
2534    }
2535    // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
2536    // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
2537    if crate_name == "terminal_colorsaurus" {
2538        if path.ends_with("::background_color") || path.ends_with("::foreground_color")
2539            || path.ends_with("::color_palette") || path.ends_with("::theme_mode")
2540        {
2541            return Some("Ipc");
2542        }
2543        return None;
2544    }
2545    // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
2546    // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
2547    // effects are out of scope here — we model only backoff's own Clock effect.)
2548    if crate_name == "backoff" {
2549        if path.ends_with("::retry") || path.ends_with("::retry_notify") {
2550            return Some("Clock");
2551        }
2552        return None;
2553    }
2554    // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
2555    // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
2556    if crate_name == "lscolors" {
2557        if path.ends_with("::from_env") {
2558            return Some("Env");
2559        }
2560        return None;
2561    }
2562    // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
2563    if crate_name == "wild" {
2564        if path.ends_with("::args") || path.ends_with("::args_os") {
2565            return Some("Env");
2566        }
2567        return None;
2568    }
2569    // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
2570    // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
2571    // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
2572    // they read no data; flagging them would be an inconsistent over-report.
2573    if crate_name == "grep_cli" {
2574        if path.ends_with("::build") {
2575            return Some("Exec");
2576        }
2577        // THE COVERAGE-GATE SWEEP (2026-08-27), verified against grep-cli 0.1.12 — the sibling entry
2578        // points to `::build` above, none of which end in that suffix:
2579        //
2580        // `CommandReader::new` (process.rs:195) is `CommandReaderBuilder::new().build(cmd)` — the same
2581        // spawn, reached through the type's own one-call constructor instead of the builder.
2582        // `CommandReader::close` (process.rs:218) drops the child's stdout handle then calls
2583        // `self.child.wait()` — `std::process::Child::wait` is ALREADY classified `Exec` elsewhere in
2584        // this file (reaping/blocking on a spawned child is part of the subprocess lifecycle this
2585        // project tracks), and self-scan resolves `self.child`'s concrete `process::Child` type to
2586        // confirm it, not a guess.
2587        // `DecompressionMatcher::command` (decompress.rs:179) builds a REAL `Command::new(&decomp_cmd.bin)`
2588        // naming an actual decompressor binary (gzip/xz/bzip2/…) and returns it un-spawned — the same
2589        // "constructs a real argv, caller spawns it" shape `Cred::credential_helper` was classified for
2590        // in git2 (see the FQN-exact rule near this file's top).
2591        // `DecompressionReader::new` (decompress.rs:352) is `DecompressionReaderBuilder::new().build(path)`,
2592        // which calls the already-covered `CommandReaderBuilder::build` two hops down.
2593        // `patterns_from_path` (pattern.rs:82) opens and reads the given file directly (`std::fs::File::
2594        // open`) — unrelated to the Exec family above, genuinely Fs.
2595        if path == "grep_cli::CommandReader::new"
2596            || path == "grep_cli::CommandReader::close"
2597            || path == "grep_cli::DecompressionMatcher::command"
2598            || path == "grep_cli::DecompressionReader::new"
2599        {
2600            return Some("Exec");
2601        }
2602        if path == "grep_cli::patterns_from_path" {
2603            return Some("Fs");
2604        }
2605        return None;
2606    }
2607    // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
2608    // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
2609    // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
2610    // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
2611    if crate_name == "clircle" {
2612        if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
2613            return Some("Fs");
2614        }
2615        return None;
2616    }
2617    None
2618}
2619
2620pub fn cap_from_name(name: &str) -> Option<&'static str> {
2621    EFFECTS.iter().copied().find(|e| *e == name)
2622}
2623
2624/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
2625/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
2626/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
2627/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
2628/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
2629/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
2630/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
2631/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
2632/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
2633/// the one boundary every engine hits — refines identically (the §4-consistency argument).
2634/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its path implies.
2635/// `["read"]`, `["write"]`, `["read","write"]`, or `[]` when the verb does not say.
2636///
2637/// THE EMPTY CASE IS THE DISCIPLINE. §2: *"when `Fs` is reached but its kind is unknown … the field MUST
2638/// be omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
2639/// never writes'), which is the §4 trust contract's forbidden direction."* So an unrecognised verb
2640/// contributes nothing and the field stays absent; absence means "kind undetermined", never "read-only".
2641///
2642/// A syntactic refinement of an effect already proved, NOT a soundness claim. Deliberately the same
2643/// vocabulary as candor-java's `fsKind`, candor-swift's and candor-ts's — the surface is spec'd four-way,
2644/// and four engines inventing four verb tables for one field is how a shared field stops meaning one thing.
2645pub fn fs_kind(path: &str) -> &'static [&'static str] {
2646    // the terminal segment is the verb (`std::fs::write`, `File::create`, `f.read_to_string`)
2647    let leaf = path.rsplit("::").next().unwrap_or(path);
2648    // `OpenOptions::open` is the one verb that does NOT carry its own direction — the direction was set
2649    // by the BUILDER chain (`.read(true)`/`.write(true)`), which this function cannot see. The READ list
2650    // below holds a bare `open` for `File::open`, unambiguously a read; letting an `OpenOptions` receiver
2651    // reach it publishes `fs: ["read"]` for a builder configured `write(true)`, and §2 reads that as the
2652    // positive claim "reads but never writes" — the forbidden direction. The paragraph under the READ
2653    // list has always SAID this; nothing implemented it, and it only became REACHABLE when
2654    // `OpenOptions::open` started classifying on its own — before, the `Fs` came from the
2655    // `OpenOptions::new` constructor, whose leaf claims nothing, so the field was simply absent.
2656    // MEASURED on this commit's own fix before this guard: `OpenOptions::new().read(true).open(p)` went
2657    // from `fs` ABSENT to `fs: ["read"]`, i.e. the fix minted a claim the engine cannot support. Keyed on
2658    // the TYPE segment, so it holds for the `fs_err`/`tokio::fs`/`async_std::fs` spellings too.
2659    if path.rsplit("::").nth(1) == Some("OpenOptions") {
2660        return &[];
2661    }
2662    // Reads the source AND writes the destination in one call.
2663    if matches!(leaf, "copy" | "rename" | "hard_link" | "soft_link" | "symlink") {
2664        return &["read", "write"];
2665    }
2666    const WRITE: &[&str] = &[
2667        "write", "write_all", "write_fmt", "write_vectored", "create", "create_new", "create_dir",
2668        "create_dir_all", "remove_file", "remove_dir", "remove_dir_all", "set_permissions",
2669        "set_len", "set_modified", "set_times", "append", "flush", "sync_all", "sync_data",
2670        "write_at", "truncate",
2671    ];
2672    const READ: &[&str] = &[
2673        "read", "read_to_string", "read_to_end", "read_exact", "read_dir", "read_link",
2674        "metadata", "symlink_metadata", "exists", "try_exists", "canonicalize", "open",
2675        "read_at", "file_type", "permissions", "modified", "accessed", "created", "len",
2676    ];
2677    if WRITE.contains(&leaf) { return &["write"]; }
2678    if READ.contains(&leaf) { return &["read"]; }
2679    // `OpenOptions` carries the direction in its BUILDER chain, not its terminal verb, so `.open()` on one
2680    // says nothing here and is deliberately left to the READ arm above only when it is `File::open`
2681    // (unambiguously a read). Anything else: no claim.
2682    if leaf.starts_with("write") || leaf.starts_with("append") { return &["write"]; }
2683    if leaf.starts_with("read") { return &["read"]; }
2684    &[]
2685}
2686
2687pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
2688    // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
2689    // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
2690    // the under-report rule forbids it, so such heads keep the bare cliff.
2691    match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
2692        "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
2693        "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
2694        // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
2695        "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
2696        | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
2697        _ => &[],
2698    }
2699}
2700
2701/// Known machine-learning MODEL-provider hosts — the SPEC §1 ⟨0.13⟩ `Llm` host-literal refinement:
2702/// a statically-known `Net` request to one of these classifies `Llm` IN ADDITION to `Net` (Net is
2703/// never dropped — a model call IS network I/O, exactly as an `Exec`-refined subprocess keeps `Exec`),
2704/// just as a jdbc URL classifies `Db`. Matched by host, case-insensitive; a SUBDOMAIN of a listed host
2705/// counts. The reference engines share this table VERBATIM with candor-java's `Literals.MODEL_HOSTS`
2706/// (the analog of `classify_command_head`) so the `Net` boundary refines to `Llm` identically. An
2707/// UNKNOWN host stays bare `Net` — never guessed. Curated STARTER set; the §7 coverage ledger
2708/// discloses an uncovered provider like any other.
2709pub const MODEL_HOSTS: &[&str] = &[
2710    "api.openai.com",
2711    "api.anthropic.com",
2712    "generativelanguage.googleapis.com",
2713    "api.mistral.ai",
2714    "api.cohere.ai",
2715    "api.cohere.com",
2716    "api.groq.com",
2717    "api.together.xyz",
2718    "api.perplexity.ai",
2719    "openrouter.ai",
2720];
2721
2722/// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
2723/// `MODEL_HOSTS` entry counts). Strips a `:port` suffix first. Two special forms carry their own rule,
2724/// matching candor-java's `Literals.isModelHost` exactly: any host whose port is `11434` is a local
2725/// Ollama endpoint (a LOOPBACK host — `localhost`/`127.0.0.1`/`::1` — on port 11434); and an AWS Bedrock
2726/// runtime host (the model-inference service label `bedrock-runtime`/`bedrock-agent-runtime`).
2727pub fn is_model_host(host_literal: &str) -> bool {
2728    // Strip any `:port` (via the shared host_part) and lowercase for the name comparisons.
2729    let host = policy::host_part(host_literal).to_ascii_lowercase();
2730    // Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any
2731    // host on :11434" fabricated Llm on unrelated internal services on that port).
2732    if let Some((_, port)) = host_literal.rsplit_once(':') {
2733        if port == "11434" {
2734            return matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
2735        }
2736    }
2737    if MODEL_HOSTS.contains(&host.as_str()) {
2738        return true;
2739    }
2740    // A subdomain of a known model host counts (`eu.api.openai.com` → api.openai.com).
2741    if MODEL_HOSTS.iter().any(|m| host.ends_with(&format!(".{m}"))) {
2742        return true;
2743    }
2744    // AWS Bedrock runtime: the FIRST label is the model-inference service (`bedrock-runtime.<region>.
2745    // amazonaws.com`), NOT the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an
2746    // S3 bucket) and NOT the control-plane `bedrock.<region>.amazonaws.com`.
2747    host.ends_with(".amazonaws.com")
2748        && matches!(host.split('.').next(), Some("bedrock-runtime") | Some("bedrock-agent-runtime"))
2749}
2750
2751/// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
2752/// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with candor-java's `Literals.TELEMETRY_HOSTS` (like
2753/// `MODEL_HOSTS`). A benign observability endpoint. Matched by host, case-insensitive; a SUBDOMAIN of a
2754/// listed host counts. Tight, high-precision STARTER set — mis-including an exfil-capable host would
2755/// under-gate `deny Net[unknown-host]`.
2756pub const TELEMETRY_HOSTS: &[&str] = &[
2757    "sentry.io",
2758    "bugsnag.com",
2759    "rollbar.com",
2760    "segment.io",
2761    "segment.com",
2762    "mixpanel.com",
2763    "amplitude.com",
2764    "google-analytics.com",
2765    "analytics.google.com",
2766    "datadoghq.com",
2767    "datadoghq.eu",
2768    "newrelic.com",
2769    "nr-data.net",
2770    "honeycomb.io",
2771    "logtail.com",
2772    // ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
2773    // providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
2774    "posthog.com",
2775    "plausible.io",
2776    "usefathom.com",
2777    "heapanalytics.com",
2778    "fullstory.com",
2779    "hotjar.com",
2780    "logrocket.com",
2781    "cloudflareinsights.com",
2782];
2783
2784/// Whether an endpoint HOST literal is in `set` (case-insensitive; a subdomain of a listed host counts).
2785/// Strips a `:port` suffix first via `host_part`. The shared membership test for `TELEMETRY_HOSTS` and the
2786/// config-declared partner set (mirrors candor-java's `Literals.hostInSet`).
2787pub fn host_in_set(host_literal: &str, set: &[&str]) -> bool {
2788    let host = policy::host_part(host_literal).to_ascii_lowercase();
2789    set.contains(&host.as_str()) || set.iter().any(|e| host.ends_with(&format!(".{e}")))
2790}
2791
2792/// Whether an endpoint HOST literal is a known telemetry/analytics/APM host (`TELEMETRY_HOSTS`).
2793pub fn is_telemetry_host(host_literal: &str) -> bool {
2794    host_in_set(host_literal, TELEMETRY_HOSTS)
2795}
2796
2797/// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
2798/// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
2799/// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). A partner set
2800/// is per-project (config-declared). Never fabricated onto a safe class: an unresolved host is unknown-host.
2801/// Mirrors candor-java's `Literals.netDestClass`.
2802pub fn net_dest_class(host_literal: &str, partners: &std::collections::BTreeSet<String>) -> &'static str {
2803    if is_telemetry_host(host_literal) {
2804        return "known-telemetry";
2805    }
2806    if partner_for(host_literal, partners).is_some() || is_model_host(host_literal) {
2807        return "known-partner";
2808    }
2809    "unknown-host"
2810}
2811
2812/// ⟨0.31⟩ WHICH declared partner a host matched, or `None` — the SAME match `net_dest_class` decides on,
2813/// extracted so the DISCLOSURE and the DECISION cannot use different rules.
2814///
2815/// That is not a stylistic preference. The reverted first attempt at the `net-partner` disclosure
2816/// re-implemented this match against a normaliser that KEEPS the port, so an observed
2817/// `partner.example:443` never equalled a declared `partner.example` and the disclosure came back
2818/// SILENTLY EMPTY on every real run — while the verdicts it was reporting on had flipped. A disclosure
2819/// normalised differently from the decision it reports can only be wrong, and one function with two
2820/// callers is what makes writing one impossible.
2821pub fn partner_for(
2822    host_literal: &str,
2823    partners: &std::collections::BTreeSet<String>,
2824) -> Option<String> {
2825    if partners.is_empty() {
2826        return None;
2827    }
2828    let host = policy::host_part(host_literal).to_ascii_lowercase();
2829    if partners.contains(&host) {
2830        return Some(host);
2831    }
2832    partners
2833        .iter()
2834        .find(|p| host.ends_with(&format!(".{p}")))
2835        .cloned()
2836}
2837
2838/// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
2839pub const NET_DEST_CLASSES: &[&str] = &["known-telemetry", "known-partner", "unknown-host"];
2840
2841/// Curated Rust model-provider SDK crates — the SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface, the Rust analog
2842/// of candor-java's `Rules.MODEL_SDK_PACKAGES`. A resolved call into one of these crates classifies
2843/// `Llm` + `Net` (the caller adds both — a model dispatch IS network I/O). NO method-name gating: these
2844/// are single-purpose provider clients, so ANY call into the crate is a model dispatch (matches the java
2845/// reference's judgment call). Curated STARTER list; the §7 coverage ledger discloses the rest.
2846pub const MODEL_SDK_CRATES: &[&str] = &[
2847    "async_openai",           // async-openai — the de-facto OpenAI client
2848    "anthropic_sdk",          // anthropic-sdk
2849    "anthropic",              // anthropic (community client crate)
2850    "aws_sdk_bedrockruntime", // AWS Bedrock runtime (invoke/converse) — the model surface of the aws-sdk family
2851    "ollama_rs",              // ollama-rs — local Ollama client
2852    "langchain_rust",         // langchain-rust — the LangChain invoke surfaces
2853    "mistralai",              // mistralai (Mistral client)
2854    "genai",                  // genai — a multi-provider model client
2855];
2856
2857/// Whether a resolved call's CRATE is a curated model-provider SDK (`MODEL_SDK_CRATES`) → the SPEC §1
2858/// ⟨0.13⟩ `Llm` model-SDK classification (the caller adds both `Llm` and `Net`). Crate-level, no
2859/// method gating — a single-purpose client, matching candor-java's `isModelSdkOwner`.
2860pub fn is_model_sdk_crate(crate_name: &str) -> bool {
2861    MODEL_SDK_CRATES.contains(&crate_name)
2862}
2863
2864/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
2865/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
2866/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
2867/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
2868/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
2869/// method is the call path's last segment.
2870pub fn is_cmd_builder_method(method: &str) -> bool {
2871    matches!(
2872        method,
2873        "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
2874            | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
2875            | "groups" | "process_group"
2876    )
2877}
2878
2879/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
2880/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
2881/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
2882/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
2883/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
2884/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
2885/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
2886/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
2887/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
2888pub fn is_cmd_naming_method(method: &str) -> bool {
2889    matches!(method, "new" | "cmd")
2890}
2891
2892/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
2893/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
2894/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
2895/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
2896/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
2897/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
2898/// host was fixed at `connect`) never false-positives. Under-catching an unusual establishing verb is a
2899/// missed mask (sound-with-disclosure), never a broken gate. The arg is the method (path's last segment).
2900pub fn is_net_establishing(method: &str) -> bool {
2901    matches!(
2902        method,
2903        "connect"
2904            | "connect_timeout"
2905            | "get"
2906            | "post"
2907            | "put"
2908            | "patch"
2909            | "delete"
2910            | "head"
2911            | "request"
2912            | "send_to"
2913            | "lookup_host"
2914            | "to_socket_addrs"
2915    )
2916}
2917
2918/// ⟨0.29⟩ A LOCAL BIND/LISTEN VERB — the address it names is where the process LISTENS, never a
2919/// destination it reaches.
2920///
2921/// **MEASURED, and it is a false all-clear rather than a naming quibble.** `UdpSocket::bind("0.0.0.0:0")`
2922/// put `0.0.0.0:0` into `hosts`, the DESTINATION surface `allow Net` gates on (§2), and — because a
2923/// literal had been captured — nothing marked the surface incomplete. So:
2924///
2925/// ```text
2926/// let s = UdpSocket::bind("0.0.0.0:0")?;   // local
2927/// s.send_to(b"secrets", dst);              // destination is a RUNTIME value
2928/// allow Net 0.0.0.0   ->   policy ✓, exit 0
2929/// ```
2930///
2931/// A local listen address certified a send to an endpoint nobody can see. That is the masking evasion
2932/// AS-EFF-008 exists to close, reached through a verb whose literal is not a destination at all.
2933///
2934/// **A BIND CANNOT BE CERTIFIED, EVER**, which is why this marks the surface incomplete rather than
2935/// merely withholding the literal: a server that binds and accepts talks to whoever connects, so its
2936/// destination set is not statically knowable even in principle. candor-java already behaves this way —
2937/// it publishes the bind address AND hedges — and matching the reference engine keeps the informative
2938/// half (an operator can still see what the service listens on) while making it non-certifying.
2939/// ⟨0.29⟩ The Net verbs whose LOCATOR is at argument **1**, not 0 — the rust analogue of candor-ts's
2940/// `NET_URL_ARG1_MEMBERS`.
2941///
2942/// **A REGRESSION FOUND IN REVIEW, and the direction matters.** The positional-literal rung replaced
2943/// `first_str_lit` ("the first string literal ANYWHERE in the call") with `positional_str_lit(args, 0)`
2944/// as the UNIVERSAL default. That is right for `Fs`/`Db`/`Exec`, whose locator is always argument 0 —
2945/// but `is_net_establishing` already listed two verbs whose locator is not: `reqwest::Client::request`
2946/// takes `(Method, url)` and `UdpSocket::send_to` takes `(buf, addr)`. MEASURED after the swap:
2947/// `c.request(Method::GET, "https://api.example.com/v1")` published NO `hosts` at all and could not be
2948/// certified, while `c.get("https://api.example.com/v1")` certified normally.
2949///
2950/// ⟨0.29⟩ MEASURED FOR `request` ONLY. `send_to` is a METHOD on a receiver the STABLE syntactic backend
2951/// does not type, so `s.send_to(buf, "203.0.113.9:53")` is not classified at all there and its arg-1
2952/// literal is still uncaptured on the floor; the position is right and the deep engine can use it. Said
2953/// plainly because the first version of this fix's changelog listed both verbs as measured.
2954///
2955/// The direction is SAFE — an uncaptured locator fails closed, it never certifies something invisible —
2956/// which is exactly why it needed a review to find: the gate stayed sound and quietly stopped being
2957/// USABLE for a common shape. candor-ts avoided this by making its resolver verb-aware in the same rung;
2958/// this is that discipline arriving one engine late.
2959pub fn is_net_host_arg1(method: &str) -> bool {
2960    matches!(method, "request" | "send_to")
2961}
2962
2963pub fn is_net_binding(method: &str) -> bool {
2964    matches!(method, "bind" | "listen" | "bind_to_device" | "incoming" | "accept")
2965}
2966
2967/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
2968/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
2969/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
2970/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
2971/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
2972/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
2973/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
2974/// is not here, so a missing literal there never false-positives. Under-catching an unusual
2975/// path-naming fn is a missed mask (sound-with-disclosure), never a broken gate. The arg is the
2976/// method/fn leaf (the path's last segment).
2977pub fn is_fs_path_arg(leaf: &str) -> bool {
2978    matches!(
2979        leaf,
2980        // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
2981        "write"
2982            | "read"
2983            | "read_to_string"
2984            | "read_dir"
2985            | "read_link"
2986            | "copy"
2987            | "rename"
2988            | "remove_file"
2989            | "remove_dir"
2990            | "remove_dir_all"
2991            | "create_dir"
2992            | "create_dir_all"
2993            | "hard_link"
2994            | "soft_link"
2995            | "symlink"
2996            | "symlink_file"
2997            | "symlink_dir"
2998            | "symlink_metadata"
2999            | "canonicalize"
3000            | "metadata"
3001            | "set_permissions"
3002            | "exists"
3003            | "try_exists"
3004            // File / OpenOptions constructors taking a path argument
3005            | "open"
3006            | "create"
3007            | "create_new"
3008    )
3009}
3010
3011/// ⟨0.29⟩ HOW MANY LEADING ARGUMENTS OF AN `Fs` PATH-TAKING CALL ARE PATHS.
3012///
3013/// **THE DEFECT THIS EXISTS FOR.** The literal harvester took the first string literal found ANYWHERE in
3014/// the argument list, so `fs::write(user_path, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the BYTES
3015/// BEING WRITTEN — as the destination surface, and `allow Fs /tmp/lit` certified a write to an
3016/// attacker-controlled path at exit 0. Measured on candor-scan and candor-ts; candor-java and
3017/// candor-swift read the path POSITION and fail closed correctly. Removing the sibling literal made the
3018/// same call fail closed, which is what identified the mechanism: any literal in the call, in any
3019/// position, defeated the runtime-path incompleteness marker.
3020///
3021/// So the surface is read from the PATH POSITIONS and nowhere else, and every one of them must be a
3022/// literal for the surface to be complete. Two-path operations are the reason this is an arity and not a
3023/// boolean: `fs::copy("/safe", user_path)` has a literal at position 0 and still writes somewhere
3024/// nobody can see, so requiring only position 0 would leave the identical hole one argument along.
3025pub fn fs_path_arity(leaf: &str) -> usize {
3026    match leaf {
3027        "copy" | "rename" | "hard_link" | "soft_link" | "symlink" | "symlink_file" | "symlink_dir" => 2,
3028        _ => 1,
3029    }
3030}
3031
3032/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
3033/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
3034/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
3035/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
3036/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
3037/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
3038/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
3039/// structurally (never a maskable string literal) so a missing literal must not false-positive.
3040/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), never a broken gate.
3041/// The arg is the method leaf (the path's last segment).
3042pub fn is_db_query_arg(leaf: &str) -> bool {
3043    matches!(
3044        leaf,
3045        "execute"
3046            | "execute_batch"
3047            | "execute_unprepared"
3048            | "batch_execute"
3049            | "simple_query"
3050            | "query"
3051            | "query_one"
3052            | "query_opt"
3053            | "query_raw"
3054            | "query_row"
3055            | "query_map"
3056            | "query_and_then"
3057            | "query_typed"
3058            | "query_all"
3059            | "prepare"
3060            | "prepare_typed"
3061            | "prepare_cached"
3062            | "exec"
3063            | "exec_first"
3064            | "exec_iter"
3065            | "exec_map"
3066            | "exec_fold"
3067            | "exec_drop"
3068            | "exec_batch"
3069            | "prep"
3070            | "run_command"
3071    )
3072}
3073
3074/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
3075/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
3076/// treats it as a declared capability, exactly like its own `&Fs` token.
3077pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
3078    if !crate_name.starts_with("cap_") {
3079        return None;
3080    }
3081    Some(match type_name {
3082        "Dir" => "Fs",
3083        "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
3084        "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
3085        "SystemClock" | "MonotonicClock" => "Clock",
3086        _ => return None,
3087    })
3088}
3089
3090/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
3091/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
3092/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
3093/// open with a SQL statement keyword, and only identifiers in table position are taken —
3094/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
3095/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
3096/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
3097/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
3098/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
3099/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
3100/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
3101pub fn tables_in_sql(sql: &str) -> Vec<String> {
3102    const STMT: &[&str] =
3103        &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
3104    // Tokens that can FOLLOW a table-introducing keyword without being a table.
3105    const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
3106    // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
3107    const STOP: &[&str] = &[
3108        "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
3109        "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
3110        "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
3111        "from", "join", "into", "update", "delete", "insert",
3112    ];
3113    // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
3114    // list without fabricating from other comma-ridden positions (column lists, ON clauses).
3115    let cleaned: String = sql
3116        .to_lowercase()
3117        .chars()
3118        .flat_map(|c| match c {
3119            '(' | ')' | ';' => vec![' '],
3120            ',' => vec![' ', ',', ' '],
3121            _ => vec![c],
3122        })
3123        .collect();
3124    let toks: Vec<&str> = cleaned.split_whitespace().collect();
3125    let Some(first) = toks.first() else { return Vec::new() };
3126    if !STMT.contains(first) {
3127        return Vec::new(); // not SQL — nothing to certify, nothing fabricated
3128    }
3129    let ident = |t: &str| -> Option<String> {
3130        let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
3131        let mut chars = t.chars();
3132        let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
3133        let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
3134        (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
3135    };
3136    let mut out: Vec<String> = Vec::new();
3137    let mut push = |t: Option<String>| {
3138        if let Some(t) = t {
3139            if !out.contains(&t) {
3140                out.push(t);
3141            }
3142        }
3143    };
3144    for (i, tok) in toks.iter().enumerate() {
3145        let table_pos = match *tok {
3146            "from" | "join" | "into" | "table" => true,
3147            // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
3148            "update" | "truncate" => i == 0,
3149            _ => false,
3150        };
3151        if !table_pos {
3152            continue;
3153        }
3154        let mut j = i + 1;
3155        while j < toks.len() && SKIP.contains(&toks[j]) {
3156            j += 1;
3157        }
3158        let Some(next) = toks.get(j) else { continue };
3159        let Some(first) = ident(next) else { continue };
3160        push(Some(first));
3161        // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
3162        // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
3163        // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
3164        // list, whose parens are spaces by the time we tokenize).
3165        while j + 2 < toks.len() && toks[j + 1] == "," {
3166            let Some(more) = ident(toks[j + 2]) else { break };
3167            push(Some(more));
3168            j += 2;
3169        }
3170    }
3171    out
3172}
3173
3174#[cfg(test)]
3175mod tests {
3176    #[test]
3177    fn model_host_recognizes_known_providers_and_special_forms() {
3178        use super::is_model_host as m;
3179        // exact known hosts (case-insensitive), with/without a port
3180        assert!(m("api.openai.com"));
3181        assert!(m("API.OpenAI.com"));
3182        assert!(m("api.anthropic.com:443"));
3183        assert!(m("generativelanguage.googleapis.com"));
3184        assert!(m("api.mistral.ai"));
3185        assert!(m("api.cohere.ai"));
3186        assert!(m("api.cohere.com")); // BOTH cohere hosts
3187        assert!(m("api.groq.com"));
3188        assert!(m("api.together.xyz"));
3189        assert!(m("api.perplexity.ai"));
3190        assert!(m("openrouter.ai"));
3191        // a subdomain of a known host counts
3192        assert!(m("eu.api.openai.com"));
3193        // Ollama: :11434 on a LOOPBACK host only (max-review r3 — a remote host on 11434 is not Ollama)
3194        assert!(m("localhost:11434"));
3195        assert!(m("127.0.0.1:11434"));
3196        assert!(!m("ollama.internal:11434")); // a remote/internal service on 11434 is NOT a model host
3197        // Bedrock: the FIRST label is the model-inference service, not the substring "bedrock"
3198        assert!(m("bedrock-runtime.us-east-1.amazonaws.com"));
3199        assert!(m("bedrock-runtime.eu-west-1.amazonaws.com"));
3200        assert!(m("bedrock-agent-runtime.us-east-1.amazonaws.com"));
3201        // NOT model hosts (never guessed)
3202        assert!(!m("example.com"));
3203        assert!(!m("api.stripe.com"));
3204        assert!(!m("localhost:8080")); // a non-Ollama local port
3205        assert!(!m("s3.us-east-1.amazonaws.com")); // amazonaws but not bedrock
3206        assert!(!m("bedrock-backups.s3.amazonaws.com")); // an S3 bucket merely NAMED bedrock — not the runtime
3207        assert!(!m("bedrock.us-east-1.amazonaws.com")); // the Bedrock CONTROL plane — not model inference
3208        assert!(!m("openai.com.evil.com")); // suffix trick — not a subdomain of a known host
3209    }
3210
3211    #[test]
3212    fn model_sdk_crate_is_crate_level_no_method_gating() {
3213        use super::is_model_sdk_crate as s;
3214        assert!(s("async_openai"));
3215        assert!(s("aws_sdk_bedrockruntime"));
3216        assert!(s("ollama_rs"));
3217        assert!(s("langchain_rust"));
3218        assert!(!s("reqwest"));
3219        assert!(!s("aws_sdk_s3"));
3220    }
3221
3222    #[test]
3223    fn sql_table_extraction_is_conservative() {
3224        use super::tables_in_sql as t;
3225        assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
3226        assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
3227                   vec!["ledger.entries", "customers"]);
3228        assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
3229        assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
3230        assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
3231        assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
3232        assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
3233        // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
3234        assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
3235        // a subquery in FROM position yields nothing for that position
3236        assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
3237        // not SQL -> nothing (never fabricate)
3238        assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
3239        assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
3240        // comma-ADJACENT continuation: a FROM list takes every table in the chain…
3241        assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
3242        // …but an alias breaks it (under-report, never a guess)…
3243        assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
3244        // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
3245        assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
3246        // a subquery after the comma stops the chain too
3247        assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
3248    }
3249
3250    use super::*;
3251
3252    /// The routed-handle list's MEMBERSHIP RULE, enforced rather than merely documented.
3253    ///
3254    /// (a) Every routed type must have a whole-type rule that answers an ARBITRARY method on it.
3255    /// If it did not, routing it would hand the scanner a path the classifier drops — motion with no
3256    /// effect, and the silent under-report would survive the fix that was supposed to close it. The
3257    /// probe leaf is deliberately a name no carve-out lists, so it measures the BASE rule.
3258    ///
3259    /// (b) The named exclusions stay excluded. `ReadDir` cannot have a verb-precise rule written for it
3260    /// (its `next` is a syscall and is spelled like every other iterator's), and `Metadata`/`DirEntry`/
3261    /// `Permissions`/`FileType` are pure DATA under the coarse `std::fs::` prefix — routing any of them
3262    /// charges an effect for reading a field. `OpenOptions`/`DirBuilder` LEFT this list by the route the
3263    /// list's own comment prescribed: their type-keyed setter carve-outs were written first, so the
3264    /// whole-type rule under them now answers only for the terminal verb.
3265    #[test]
3266    fn std_effect_handles_have_a_whole_type_rule_and_exclude_the_pure_surfaces() {
3267        for ty in STD_EFFECT_HANDLES {
3268            let probe = format!("{ty}::__candor_probe_verb");
3269            assert!(
3270                classify("std", &probe).is_some(),
3271                "`{ty}` is routed into the classifier by receiver inference, but an arbitrary method \
3272                 on it classifies to NOTHING — the routing cannot close any under-report"
3273            );
3274            assert!(is_std_effect_handle(ty), "the predicate must agree with the list");
3275        }
3276        for ty in [
3277            "std::fs::ReadDir",
3278            "std::fs::Metadata", "std::fs::DirEntry", "std::fs::Permissions", "std::fs::FileType",
3279            "std::path::Path", "std::path::PathBuf", "std::vec::Vec",
3280        ] {
3281            assert!(!is_std_effect_handle(ty),
3282                    "`{ty}` has a pure surface the coarse prefix rules would charge — it must not be \
3283                     routed as a handle (Path/PathBuf route through their own verb-precise carve-out)");
3284        }
3285    }
3286
3287    /// The pure read-backs on the ROUTED types, which is what makes (a) above safe: a whole-type rule
3288    /// answering every method is only acceptable because the genuinely-pure methods were carved out
3289    /// first. Receiver routing is a second door into these rules, so it must not re-fabricate.
3290    #[test]
3291    fn routed_std_handles_keep_their_pure_accessor_carve_outs() {
3292        for p in [
3293            "std::process::Command::get_program", "std::process::Command::get_args",
3294            "std::process::Command::get_envs", "std::process::Command::get_current_dir",
3295            "std::process::Child::id",
3296            "std::net::TcpStream::local_addr", "std::net::TcpStream::peer_addr",
3297            "std::net::TcpStream::nodelay", "std::net::TcpStream::ttl",
3298            "std::net::UdpSocket::take_error",
3299            "std::fs::File::as_raw_fd", "std::fs::File::into_raw_fd",
3300            "std::os::unix::net::UnixStream::as_raw_fd",
3301            // The OPTION-BUILDER setters (SPEC §1 ⟨0.32⟩): flags in a struct, no file named, nothing
3302            // opened. `create` is here as `OpenOptions`' setter and is a VERB on `DirBuilder` below —
3303            // one leaf, two answers, which is why these carve-outs are keyed on the TYPE.
3304            "std::fs::OpenOptions::new", "std::fs::OpenOptions::read", "std::fs::OpenOptions::write",
3305            "std::fs::OpenOptions::append", "std::fs::OpenOptions::truncate",
3306            "std::fs::OpenOptions::create", "std::fs::OpenOptions::create_new",
3307            "std::fs::OpenOptions::mode", "std::fs::OpenOptions::custom_flags",
3308            "std::fs::DirBuilder::new", "std::fs::DirBuilder::recursive", "std::fs::DirBuilder::mode",
3309        ] {
3310            assert_eq!(classify("std", p), None, "`{p}` is a pure read-back");
3311        }
3312        // …and the verbs on the same types still answer, or the carve-outs would have eaten the rule.
3313        for (p, want) in [
3314            ("std::process::Command::spawn", "Exec"),
3315            ("std::process::Child::wait", "Exec"),
3316            ("std::fs::File::write_all", "Fs"),
3317            ("std::net::TcpStream::write_all", "Net"),
3318            ("std::os::unix::net::UnixStream::send", "Ipc"),
3319            // the TERMINAL VERBS of the two option-builders — the whole point of routing them.
3320            ("std::fs::OpenOptions::open", "Fs"),
3321            ("std::fs::DirBuilder::create", "Fs"),
3322        ] {
3323            assert_eq!(classify("std", p), Some(want), "`{p}` must stay {want}");
3324        }
3325    }
3326
3327    #[test]
3328    fn db_crates_are_calibrated() {
3329        // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
3330        // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
3331        for c in DB_CRATES {
3332            assert!(
3333                CALIBRATED_CRATES.contains(&c),
3334                "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
3335            );
3336        }
3337    }
3338
3339    /// The two coverage lists mean OPPOSITE things and must stay disjoint.
3340    ///
3341    /// `CALIBRATED_CRATES` = "classify has effect rules here". `REVIEWED_PURE_CRATES` = "read it, it
3342    /// performs nothing". A crate in both would be asserting both at once, and the ledger consults them
3343    /// with an OR — so the contradiction would resolve silently to "covered" and nobody would look again.
3344    #[test]
3345    fn reviewed_pure_and_calibrated_are_disjoint() {
3346        for c in REVIEWED_PURE_CRATES {
3347            assert!(!CALIBRATED_CRATES.contains(&c),
3348                    "`{c}` is in BOTH lists — it cannot be rule-covered AND effect-free");
3349            assert!(!PATH_CALIBRATED_CRATES.contains(&c), "`{c}` is in BOTH lists (path-calibrated)");
3350            assert!(!CALIBRATED_PREFIXES.iter().any(|p| c.starts_with(p)),
3351                    "`{c}` is covered by a calibrated PREFIX as well as the pure list");
3352        }
3353    }
3354
3355    /// A reviewed-pure crate must actually classify as pure — the mirror of `calibrated_crates_are_live`.
3356    ///
3357    /// The list makes candor BELIEVE these crates rather than disclose them, so if someone later adds a
3358    /// rule for one, the claim "performs no effect of its own" is dead and the entry has to be re-read,
3359    /// not silently outvoted by the rule. Probed with the same tails the liveness test uses, which is a
3360    /// broad sweep of the effectful verb shapes candor knows.
3361    #[test]
3362    fn reviewed_pure_crates_classify_as_pure() {
3363        for c in REVIEWED_PURE_CRATES {
3364            for t in CALIBRATION_PROBE_TAILS {
3365                assert!(classify(c, &format!("{c}{t}")).is_none(),
3366                        "`{c}` is listed REVIEWED-PURE but classify() gives it an effect on `{c}{t}` — \
3367                         one of the two is wrong, and the list is the claim");
3368            }
3369        }
3370    }
3371
3372    #[test]
3373    fn calibrated_crates_are_live() {
3374        // Conversely, every crate advertised as calibrated must actually be matched by classify() for
3375        // some representative path — a dead entry would silently suppress a real coverage warning.
3376        for c in CALIBRATED_CRATES {
3377            assert!(
3378                CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
3379                "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
3380            );
3381        }
3382    }
3383
3384    #[test]
3385    fn async_http_stack_classifies() {
3386        // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
3387        // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
3388        assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
3389        assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
3390        assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
3391        assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
3392        assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
3393        assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
3394        assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
3395        assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
3396        assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
3397        assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
3398        assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
3399        assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
3400        // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
3401        assert_eq!(classify("num_cpus", "num_cpus::get"), None);
3402        assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
3403        // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
3404        assert_eq!(classify("hyper", "hyper::Request::builder"), None);
3405        assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
3406        assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
3407        assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
3408    }
3409
3410    #[test]
3411    fn coverage_differential_crates_classify() {
3412        // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
3413        // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
3414        // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
3415
3416        // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
3417        assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
3418        assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
3419        assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
3420        assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
3421        assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
3422
3423        // native-tls variants — handshake is Net; builder is pure.
3424        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
3425        assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
3426        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
3427
3428        // etcetera — dir resolution reads env; the args data type is pure.
3429        assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
3430        assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
3431        assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
3432        assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
3433
3434        // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
3435        assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
3436        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
3437        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
3438        assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
3439        assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
3440
3441        // walkdir — charged at construction (`WalkDir::new`, mirroring `ignore`/`glob`), NOT solely at
3442        // the lazy `next()`/`metadata()` read: candor-scan's receiver-typing blocklist hard-blocks
3443        // `.into_iter()`, so a typed `IntoIter::next` receiver is unreachable from the idiomatic
3444        // `WalkDir::new(p).into_iter()...` chain — `WalkDir::new` is the only point every real usage
3445        // (for-loop, `.count()`, untyped `.next()`) actually reaches. `IntoIter::next`/`DirEntry::metadata`
3446        // stay Some("Fs") as a secondary rule for the narrower explicit-type-annotation case. Builder
3447        // setters (`into_iter` itself, `max_depth`, …) and cached accessors stay pure.
3448        assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), Some("Fs"));
3449        assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
3450        assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
3451        assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
3452        assert_eq!(classify("walkdir", "walkdir::WalkDir::max_depth"), None); // builder setter
3453        assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
3454
3455        // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
3456        assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
3457        assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
3458        assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
3459        assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
3460        assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
3461
3462        // execute — the execute* verbs spawn (Exec); command/shell builders pure.
3463        assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
3464        assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
3465        assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
3466        assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
3467        assert_eq!(classify("execute", "execute::shell"), None);
3468
3469        // ctrlc — install signal handler (Ipc).
3470        assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
3471        assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
3472
3473        // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
3474        assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
3475        assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
3476        assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
3477        assert_eq!(classify("clap", "clap::Command::new"), None); // builder
3478        assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
3479        assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
3480        // R59-class audit: `Arg::env` calls `env::var_os` DIRECTLY at builder time (verified against
3481        // clap_builder 4.6.6) — unambiguous (the crate's only `::env` method), so classify it rather
3482        // than leave a real effect to fall silently into the CALIBRATED_CRATES purity exemption.
3483        assert_eq!(classify("clap", "clap::Arg::env"), Some("Env"));
3484        assert_eq!(classify("clap", "clap::Arg::env_os"), Some("Env"));
3485
3486        // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
3487        assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
3488        assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
3489        assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
3490        assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
3491        assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
3492
3493        // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
3494        // TUI — the tty is a user dialogue channel (Ipc), the ruling dialoguer/console already carry.
3495        // Each verb below was read off the crate source (crossterm-0.28.1, ratatui-0.29.0), not guessed.
3496        assert_eq!(classify("crossterm", "crossterm::ExecutableCommand::execute"), Some("Ipc"));
3497        assert_eq!(classify("crossterm", "crossterm::QueueableCommand::queue"), Some("Ipc"));
3498        assert_eq!(classify("crossterm", "crossterm::event::read"), Some("Ipc"));
3499        assert_eq!(classify("crossterm", "crossterm::event::poll"), Some("Ipc"));
3500        assert_eq!(classify("crossterm", "crossterm::terminal::enable_raw_mode"), Some("Ipc"));
3501        // a tty IOCTL must not fall through: in a CALIBRATED crate an unmatched path is a purity CLAIM
3502        assert_eq!(classify("crossterm", "crossterm::terminal::size"), Some("Ipc"));
3503        // the Command VALUE types are pure — they describe an action, they do not perform one
3504        assert_eq!(classify("crossterm", "crossterm::style::Print"), None);
3505        assert_eq!(classify("crossterm", "crossterm::cursor::MoveTo"), None);
3506
3507        // ratatui: the backlog said "mark reviewed-pure"; the SOURCE says `Terminal::draw` ends in a
3508        // backend flush, so the write surface is Ipc and only the render surface is pure.
3509        assert_eq!(classify("ratatui", "ratatui::Terminal::draw"), Some("Ipc"));
3510        assert_eq!(classify("ratatui", "ratatui::Terminal::flush"), Some("Ipc"));
3511        assert_eq!(classify("ratatui", "ratatui::Terminal::clear"), Some("Ipc"));
3512        assert_eq!(classify("ratatui", "ratatui::Terminal::hide_cursor"), Some("Ipc"));
3513        // REGRESSION: `widgets::canvas` is an in-memory grid. `Context::draw` ends in `::draw` and was
3514        // FABRICATING Ipc — caught in review, measured on a fixture, and a hot path (per shape, per frame).
3515        assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::draw"), None);
3516        assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::layer"), None);
3517        // …while the real write surface still classifies, including a DIRECT backend call (which is why
3518        // the carve-out is a denylist on canvas rather than an allowlist on `Terminal::`).
3519        assert_eq!(classify("ratatui", "ratatui::backend::CrosstermBackend::flush"), Some("Ipc"));
3520        // the BULK of the 3,345 disclosed calls — widgets, layout, style — are genuinely pure
3521        assert_eq!(classify("ratatui", "ratatui::widgets::Paragraph::new"), None);
3522        assert_eq!(classify("ratatui", "ratatui::layout::Layout::split"), None);
3523        assert_eq!(classify("ratatui", "ratatui::style::Style::fg"), None);
3524        assert_eq!(classify("ratatui", "ratatui::buffer::Buffer::set_string"), None);
3525
3526        // tracing_subscriber — two effects, both read off 0.3.23. The filing said "Log/Fs"; the Fs half
3527        // is wrong (the crate ACCEPTS a File as a writer, it never opens one).
3528        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::init"), Some("Log"));
3529        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::try_init"), Some("Log"));
3530        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::init"), Some("Log"));
3531        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_default_env"), Some("Env"));
3532        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_env"), Some("Env"));
3533        // builders DESCRIBE a subscriber; they do not install one
3534        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::layer"), None);
3535        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::with_target"), None);
3536        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::new"), None);
3537
3538        assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
3539        assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
3540        assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
3541        assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
3542        assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
3543
3544        // dialoguer — interact* is tty I/O (Ipc); builders pure.
3545        assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
3546        assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
3547        assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
3548        assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
3549        // widened-CORE audit: `Editor::new`/`::default` read VISUAL/EDITOR at construction, independent
3550        // of `Editor::edit`'s already-covered Exec (spawning that editor is a separate, later step).
3551        assert_eq!(classify("dialoguer", "dialoguer::Editor::new"), Some("Env"));
3552        assert_eq!(classify("dialoguer", "dialoguer::Editor::default"), Some("Env"));
3553        assert_eq!(classify("dialoguer", "dialoguer::Editor::edit"), Some("Exec"));
3554        assert_eq!(classify("dialoguer", "dialoguer::Editor::extension"), None); // pure setter
3555
3556        // console — Term I/O is Ipc, detection is Env, Style is pure.
3557        assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
3558        assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
3559        assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
3560        assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
3561        assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
3562        // R59-class audit: `Term` also implements raw `io::{Read,Write}` (console 0.15.11) — the same
3563        // tty channel as `write_line`/`read_key`, reached through the generic trait methods instead of
3564        // the crate's own named convenience methods. Crate-gated and `Term::`-scoped, so it cannot spread.
3565        assert_eq!(classify("console", "console::Term::write"), Some("Ipc"));
3566        assert_eq!(classify("console", "console::Term::flush"), Some("Ipc"));
3567        assert_eq!(classify("console", "console::Term::read"), Some("Ipc"));
3568
3569        // terminal_colorsaurus — tty colour query (Ipc).
3570        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
3571        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
3572
3573        // backoff — retry sleeps + reads the clock (Clock); config pure.
3574        assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
3575        assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
3576        assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
3577
3578        // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
3579        assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
3580        assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
3581        assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
3582
3583        // wild — argv readers (Env).
3584        assert_eq!(classify("wild", "wild::args"), Some("Env"));
3585        assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
3586
3587        // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
3588        assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
3589        assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
3590        assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
3591
3592        // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
3593        assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
3594        assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
3595    }
3596
3597    #[test]
3598    fn log_tracing_emit_macros_classify_pre_expansion() {
3599        // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
3600        // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
3601        assert_eq!(classify("log", "log::info"), Some("Log"));
3602        assert_eq!(classify("log", "log::error"), Some("Log"));
3603        assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
3604        assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
3605        // pure data-type surface stays None (no fabricated Log)
3606        assert_eq!(classify("log", "log::Level::as_str"), None);
3607        assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
3608    }
3609
3610    #[test]
3611    fn classify_core_effects() {
3612        // A representative smoke test of the classifier's main families, so the published crate is not
3613        // shipped untested (these used to live only in the nightly-only src/lib.rs).
3614        assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
3615        // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
3616        // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
3617        assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
3618        assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
3619        assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
3620        assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
3621        assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
3622        assert_eq!(classify("std", "std::path::Path::parent"), None);
3623        assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
3624        assert_eq!(classify("std", "std::env::var"), Some("Env"));
3625        assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
3626        // one-shot convenience fns send immediately → Net.
3627        assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
3628        assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
3629        // the URL-BEARING builder methods classify Net too — the DOMINANT idiom is the builder chain
3630        // `Client::new().post(url).send()`, whose URL literal rides the `.post(url)` step (NOT `.send()`),
3631        // so the endpoint (and the Llm host refinement) only get captured if the URL-naming step is Net.
3632        assert_eq!(classify("reqwest", "reqwest::Client::get"), Some("Net"));
3633        assert_eq!(classify("reqwest", "reqwest::Client::post"), Some("Net"));
3634        assert_eq!(classify("reqwest", "reqwest::Client::put"), Some("Net"));
3635        assert_eq!(classify("reqwest", "reqwest::Client::delete"), Some("Net"));
3636        assert_eq!(classify("reqwest", "reqwest::Client::request"), Some("Net"));
3637        // the PURE builder surface stays None (no URL, no dispatch).
3638        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
3639        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::json"), None);
3640        assert_eq!(classify("reqwest", "reqwest::ClientBuilder::build"), None);
3641        // RAW POSIX SOCKETS — the lowest network tier, pinned as a regression guard (four-way close:
3642        // swift got a raw-socket regression this week from a bare-identifier collision; rust never had
3643        // the gap because it classifies path-QUALIFIED via the syscall-leaf table, but pin it so the
3644        // `socket`/`connect` Net rows can't silently drop). `libc::connect`/`libc::socket` are the direct
3645        // FFI syscalls; `nix::sys::socket::connect` is the safe wrapper; both bottom out in the NET table.
3646        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
3647        assert_eq!(classify("libc", "libc::socket"), Some("Net"));
3648        assert_eq!(classify("libc", "libc::bind"), Some("Net"));
3649        assert_eq!(classify("libc", "libc::accept"), Some("Net"));
3650        // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
3651        assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
3652        assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
3653        assert_eq!(classify("nix", "nix::sys::socket::socket"), Some("Net"));
3654        assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
3655        assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
3656        assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
3657        // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
3658        assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
3659        assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
3660        assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
3661        assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
3662        // pnet raw packet capture: channel openers are Net, packet construction stays pure.
3663        assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
3664        assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
3665        assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
3666        assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
3667        assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
3668        // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
3669        assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
3670        assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
3671        assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
3672        assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
3673        assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
3674        assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
3675        assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
3676        // THE FIX: `Walk::new`/`Walk::from_iter` are `WalkBuilder::new(path).build()` /
3677        // `WalkBuilder::from_iter(paths).build()` in ignore's own source — the crate's documented
3678        // convenience constructors, and its own top-level doc example (`for entry in Walk::new(path)`).
3679        // A corpus round caught `deny Fs` exiting 0 over exactly that idiom.
3680        assert_eq!(classify("ignore", "ignore::Walk::new"), Some("Fs"));
3681        assert_eq!(classify("ignore", "ignore::Walk::from_iter"), Some("Fs"));
3682        // NO FABRICATION: a same-named `Walk::new` from an unrelated crate must not gain Fs — crate-gated.
3683        assert_eq!(classify("walkdir", "walkdir::Walk::new"), None);
3684        assert_eq!(classify("some_local_crate", "some_local_crate::Walk::new"), None);
3685        // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
3686        assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
3687        assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
3688        assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
3689        assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
3690        assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
3691        assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
3692        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
3693        // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
3694        assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
3695        assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
3696        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
3697        assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
3698        assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
3699        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
3700        // THE FIX: three more real `Connection::open*` constructors an exact-suffix list missed
3701        // (`open_in_memory_with_flags` doesn't END in `open_with_flags`), plus the documented blob-I/O
3702        // API (`blob_open`/`Blob::reopen`), each calling the real sqlite3 FFI directly.
3703        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags"), Some("Db"));
3704        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_with_flags_and_vfs"), Some("Db"));
3705        assert_eq!(
3706            classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags_and_vfs"),
3707            Some("Db")
3708        );
3709        assert_eq!(classify("rusqlite", "rusqlite::Connection::blob_open"), Some("Db"));
3710        assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::reopen"), Some("Db"));
3711        // NO FABRICATION: a same-crate, DIFFERENT type's `open`-prefixed method (rusqlite's own private
3712        // `pragma::Sql::open_brace`, which pushes one char to a string buffer) must not gain Db — the
3713        // fix keys on the `Connection::` segment, not a bare leaf prefix.
3714        assert_eq!(classify("rusqlite", "rusqlite::pragma::Sql::open_brace"), None);
3715        // THE COVERAGE-GATE SWEEP FIX: the online-backup API and incremental-BLOB positional I/O,
3716        // verified against rusqlite 0.32.1 (each calls an `ffi::sqlite3_*` leaf already in this file's
3717        // own FFI-leaf DB table, but the `crate_name == "rusqlite"` block above returned before ever
3718        // reaching it — same shape as the git2 fix elsewhere in this file).
3719        assert_eq!(classify("rusqlite", "rusqlite::Backup::new"), Some("Db"));
3720        assert_eq!(classify("rusqlite", "rusqlite::Backup::new_with_names"), Some("Db"));
3721        assert_eq!(classify("rusqlite", "rusqlite::Backup::step"), Some("Db"));
3722        assert_eq!(classify("rusqlite", "rusqlite::Backup::run_to_completion"), Some("Db"));
3723        assert_eq!(classify("rusqlite", "rusqlite::Connection::backup"), Some("Db"));
3724        assert_eq!(classify("rusqlite", "rusqlite::Connection::restore"), Some("Db"));
3725        assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at"), Some("Db"));
3726        assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at_exact"), Some("Db"));
3727        assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at"), Some("Db"));
3728        assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at_exact"), Some("Db"));
3729        assert_eq!(classify("rusqlite", "rusqlite::Blob::write_at"), Some("Db"));
3730        assert_eq!(classify("rusqlite", "rusqlite::Blob::write_all_at"), Some("Db"));
3731        assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle"), Some("Db"));
3732        assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle_owned"), Some("Db"));
3733        assert_eq!(classify("rusqlite", "rusqlite::Connection::extension_init2"), Some("Db"));
3734        assert_eq!(classify("rusqlite", "rusqlite::init_auto_extension"), Some("Db"));
3735        // …and the REAL module-qualified spellings (`Backup`/`Blob`/`init_auto_extension` are not
3736        // re-exported at rusqlite's crate root, unlike `Connection` — a real consumer's source, proven
3737        // against a compiling fixture, must use these, not the short forms above).
3738        assert_eq!(classify("rusqlite", "rusqlite::backup::Backup::new"), Some("Db"));
3739        assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::read_at"), Some("Db"));
3740        assert_eq!(classify("rusqlite", "rusqlite::auto_extension::init_auto_extension"), Some("Db"));
3741        // NO FABRICATION: `InnerConnection`/`RawStatement` live in PRIVATE modules (lib.rs `mod
3742        // inner_connection;`/`mod raw_statement;`, no `pub`) — no external consumer can ever name either
3743        // type, so no rule was added despite self-scan flagging them too.
3744        assert_eq!(classify("rusqlite", "rusqlite::InnerConnection::close"), None);
3745        assert_eq!(classify("rusqlite", "rusqlite::RawStatement::step"), None);
3746        // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
3747        assert_eq!(classify("postgres", "postgres::Client::open"), None);
3748        assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
3749        // THE FIX: `Config::connect_raw` does the same protocol handshake as `Config::connect`, over a
3750        // caller-supplied stream instead of one it dials itself.
3751        assert_eq!(classify("tokio_postgres", "tokio_postgres::Config::connect_raw"), Some("Db"));
3752        // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
3753        assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
3754        assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
3755        // THE FIX: `Connection::establish` is diesel's own name for `::connect` — the crate's canonical,
3756        // most-used connection entry point (`SqliteConnection::establish(url)` in every diesel
3757        // quickstart), implemented by really opening the backend handle — but shared no verb spelling
3758        // with `::connect`, so it read pure with zero `coverage.uncovered` disclosure (diesel is
3759        // calibrated, so the miss reads as reviewed-pure, not as a gap).
3760        assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish"), Some("Db"));
3761        assert_eq!(classify("diesel", "diesel::pg::PgConnection::establish"), Some("Db"));
3762        assert_eq!(classify("diesel", "diesel::mysql::MysqlConnection::establish"), Some("Db"));
3763        assert_eq!(classify("diesel", "diesel::Connection::establish"), Some("Db"));
3764        // NO FABRICATION: diesel's own private plumbing shares the substring but not the exact verb.
3765        assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish_inner"), None);
3766        assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
3767        // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
3768        assert_eq!(classify("sqlx", "sqlx::query"), None);
3769        // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
3770        // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
3771        // pure, same principle as the `log` facade).
3772        assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
3773        assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
3774        assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
3775        assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
3776        assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
3777        assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
3778        assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
3779        assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
3780        assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
3781        // git2 (high-level Rust API, not the `raw::git_*` FFI tier tested above): remote verbs are Net.
3782        assert_eq!(classify("git2", "git2::Remote::fetch"), Some("Net"));
3783        assert_eq!(classify("git2", "git2::Remote::push"), Some("Net"));
3784        assert_eq!(classify("git2", "git2::Remote::download"), Some("Net"));
3785        assert_eq!(classify("git2", "git2::Remote::connect"), Some("Net"));
3786        assert_eq!(classify("git2", "git2::Remote::connect_auth"), Some("Net"));
3787        assert_eq!(classify("git2", "git2::Remote::ls"), Some("Net"));
3788        assert_eq!(classify("git2", "git2::Remote::upload"), Some("Net"));
3789        // THE COVERAGE-GATE SWEEP FIX: `Repository::open`/`init` (and every other local .git-directory
3790        // operation below) used to read PURE here — the comment this replaced called that "honest, no
3791        // network", but candor-scan self-scanning git2's OWN source (eval/coverage-gate) proved these
3792        // reach a REAL Fs effect (`raw::git_repository_open`/`_init_ext`, already in this file's FFI-tier
3793        // table) that a real consumer's call to the safe wrapper could never surface, because THIS
3794        // block's early `return None` sat in front of that table for every `crate_name == "git2"` call.
3795        // "local, no network" was true and beside the point: Fs was the effect being missed. See MEMORY /
3796        // the commit that added this block for the full sweep (Config/Index/Odb/PackBuilder/Reference/
3797        // Repository/TreeBuilder — every FQN below calls a listed FS FFI leaf; verified against git2
3798        // 0.20.4 source).
3799        assert_eq!(classify("git2", "git2::Repository::open"), Some("Fs"));
3800        assert_eq!(classify("git2", "git2::Repository::init"), Some("Fs"));
3801        assert_eq!(classify("git2", "git2::Repository::open_bare"), Some("Fs"));
3802        assert_eq!(classify("git2", "git2::Repository::open_ext"), Some("Fs"));
3803        assert_eq!(classify("git2", "git2::Repository::open_from_env"), Some("Fs"));
3804        assert_eq!(classify("git2", "git2::Repository::discover"), Some("Fs"));
3805        assert_eq!(classify("git2", "git2::Repository::discover_path"), Some("Fs"));
3806        assert_eq!(classify("git2", "git2::Repository::init_bare"), Some("Fs"));
3807        assert_eq!(classify("git2", "git2::Repository::init_opts"), Some("Fs"));
3808        assert_eq!(classify("git2", "git2::Repository::blob_path"), Some("Fs"));
3809        assert_eq!(classify("git2", "git2::Repository::checkout_head"), Some("Fs"));
3810        assert_eq!(classify("git2", "git2::Repository::checkout_index"), Some("Fs"));
3811        assert_eq!(classify("git2", "git2::Repository::checkout_tree"), Some("Fs"));
3812        assert_eq!(classify("git2", "git2::Repository::commit"), Some("Fs"));
3813        assert_eq!(classify("git2", "git2::Repository::reference"), Some("Fs"));
3814        assert_eq!(classify("git2", "git2::Repository::tag"), Some("Fs"));
3815        assert_eq!(classify("git2", "git2::Config::add_file"), Some("Fs"));
3816        assert_eq!(classify("git2", "git2::Config::open"), Some("Fs"));
3817        assert_eq!(classify("git2", "git2::Config::open_default"), Some("Fs"));
3818        assert_eq!(classify("git2", "git2::Index::add_all"), Some("Fs"));
3819        assert_eq!(classify("git2", "git2::Index::add_path"), Some("Fs"));
3820        assert_eq!(classify("git2", "git2::Index::read"), Some("Fs"));
3821        assert_eq!(classify("git2", "git2::Index::write"), Some("Fs"));
3822        assert_eq!(classify("git2", "git2::Index::write_tree"), Some("Fs"));
3823        assert_eq!(classify("git2", "git2::Index::write_tree_to"), Some("Fs"));
3824        assert_eq!(classify("git2", "git2::Odb::read"), Some("Fs"));
3825        assert_eq!(classify("git2", "git2::Odb::reader"), Some("Fs"));
3826        assert_eq!(classify("git2", "git2::Odb::write"), Some("Fs"));
3827        assert_eq!(classify("git2", "git2::Odb::writer"), Some("Fs"));
3828        assert_eq!(classify("git2", "git2::PackBuilder::write"), Some("Fs"));
3829        assert_eq!(classify("git2", "git2::Reference::delete"), Some("Fs"));
3830        assert_eq!(classify("git2", "git2::Reference::set_target"), Some("Fs"));
3831        assert_eq!(classify("git2", "git2::TreeBuilder::write"), Some("Fs"));
3832        // `Remote::list`/`RemoteConnection::list` call `raw::git_remote_ls` directly — the same
3833        // reference-advertisement Net effect `Remote::ls` was meant to catch, under the name git2 0.20
3834        // actually uses (there is no `ls` method left in the crate at all — that suffix rule is dead
3835        // weight, kept rather than removed since deleting it is a separate, unrelated cleanup).
3836        assert_eq!(classify("git2", "git2::Remote::list"), Some("Net"));
3837        assert_eq!(classify("git2", "git2::RemoteConnection::list"), Some("Net"));
3838        // `Cred::credential_helper` spawns a real `sh -c "<helper> get"` subprocess to resolve
3839        // `credential.helper` config (cred.rs:370-390) — a real consumer's most common way to honor it.
3840        assert_eq!(classify("git2", "git2::Cred::credential_helper"), Some("Exec"));
3841        assert_eq!(classify("git2", "git2::CredentialHelper::execute"), Some("Exec"));
3842        // NO FABRICATION controls: a `write`/`read`/`open` on some OTHER git2 type (not FQN-listed above)
3843        // must stay pure — this fix is FQN-exact, not a suffix re-widen.
3844        assert_eq!(classify("git2", "git2::Repository::state"), None);
3845        assert_eq!(classify("git2", "git2::Blob::content"), None);
3846        // tonic's SERVER half (the coverage-gate sweep found only the client verbs were covered):
3847        // `Router::serve`/`::serve_with_shutdown` bind a real listening socket via `TcpIncoming::new`
3848        // (`StdTcpListener::bind`), verified against tonic 0.12.3 source.
3849        assert_eq!(classify("tonic", "tonic::transport::server::Router::serve"), Some("Net"));
3850        assert_eq!(
3851            classify("tonic", "tonic::transport::server::Router::serve_with_shutdown"),
3852            Some("Net")
3853        );
3854        assert_eq!(classify("tonic", "tonic::TcpIncoming::new"), Some("Net"));
3855        // NO FABRICATION control: `Server<L>::serve_with_shutdown` (mod.rs:530) is a DIFFERENT,
3856        // `pub(crate)` method on a different type — not reachable by any consumer, and this fix does not
3857        // fabricate an effect for it by widening past the `Router::` FQN.
3858        assert_eq!(classify("tonic", "tonic::transport::server::Server::serve_with_shutdown"), None);
3859        // THE FIX: `Repository::clone`/`clone_recurse` and `RepoBuilder::clone` ARE libgit2's real
3860        // network clone — a corpus round found `git2::Repository::clone(url, path)` reporting ZERO
3861        // effects and passing `deny Net` at exit 0, because the bare `::clone` denylist meant to keep
3862        // out `Remote`'s derived `Clone` dup (below) also excluded the one thing the old comment named
3863        // as NOT meant to be excluded.
3864        assert_eq!(classify("git2", "git2::Repository::clone"), Some("Net"));
3865        assert_eq!(classify("git2", "git2::Repository::clone_recurse"), Some("Net"));
3866        assert_eq!(classify("git2", "git2::build::RepoBuilder::clone"), Some("Net"));
3867        // THE CONTROL: `Remote::clone` is the DERIVED `Clone` trait impl (an Arc/handle duplication, no
3868        // libgit2 call) — it must stay pure. Re-fabricating Net here would undo the very over-charge the
3869        // original exclusion existed to prevent. FQN-exact matching (not a bare `::clone` re-widen) keeps
3870        // this pure while catching `Repository::clone` above.
3871        assert_eq!(classify("git2", "git2::Remote::clone"), None);
3872        // THE SWEEP FIX: a fourth+fifth true positive in the SAME gap — `Submodule::clone`/
3873        // `Submodule::update` call `raw::git_submodule_clone`/`git_submodule_update` directly (the exact
3874        // leaves already in this file's FFI-tier NET table), but git2's documented submodule-init idiom
3875        // calls the safe Rust wrapper, which carried no rule of its own.
3876        assert_eq!(classify("git2", "git2::Submodule::clone"), Some("Net"));
3877        assert_eq!(classify("git2", "git2::Submodule::update"), Some("Net"));
3878        // NO FABRICATION: git2's many pure `update_*` setters (CheckoutBuilder/DiffOptions/StatusOptions)
3879        // must not gain Net — FQN-exact, not a bare `::update` substring.
3880        assert_eq!(classify("git2", "git2::build::CheckoutBuilder::update_only"), None);
3881        assert_eq!(classify("git2", "git2::DiffOptions::update_index"), None);
3882        assert_eq!(classify("git2", "git2::Submodule::update_strategy"), None);
3883        // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
3884        // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
3885        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
3886        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
3887        assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
3888        assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
3889        assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
3890        assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
3891        assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
3892        assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
3893        // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
3894        // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
3895        assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
3896        assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
3897        assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
3898        assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
3899        assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
3900        assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
3901        assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
3902        // R59-class audit: `file_list` is `Get`/`Set`'s sibling terminal beside `text`/`image`/`html`
3903        // (verified against arboard 3.6.1 — the only `file_list` in the crate, so no ambiguity survives
3904        // the crate gate); `Clear::default` is `clear()`'s own alternate entry point.
3905        assert_eq!(classify("arboard", "arboard::Get::file_list"), Some("Clipboard"));
3906        assert_eq!(classify("arboard", "arboard::Set::file_list"), Some("Clipboard"));
3907        assert_eq!(classify("arboard", "arboard::Clear::default"), Some("Clipboard"));
3908        // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
3909        // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
3910        assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
3911        assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
3912        assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
3913        assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
3914        assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
3915        assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
3916        assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
3917        // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
3918        // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
3919        assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
3920        assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
3921        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
3922        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
3923        assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
3924        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
3925        assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
3926        assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
3927        assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
3928        assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
3929        // FFI tiers (matched by distinctive leaf, alias-independent)
3930        assert_eq!(classify("libc", "libc::open"), Some("Fs"));
3931        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
3932        assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
3933        assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
3934        // SOUNDNESS R166 — the PROCESS-GLOBAL extension registry. `rusqlite::register_auto_extension`
3935        // calls `ffi::sqlite3_auto_extension` directly and read `[]` on the 0.34.0 binary: an explicit
3936        // empty effect set over a call that changes what every LATER `sqlite3_open` executes.
3937        assert_eq!(classify("ffi", "ffi::sqlite3_auto_extension"), Some("Db"));
3938        assert_eq!(classify("ffi", "ffi::sqlite3_cancel_auto_extension"), Some("Db"));
3939        assert_eq!(classify("ffi", "ffi::sqlite3_reset_auto_extension"), Some("Db"));
3940        assert_eq!(classify("ffi", "ffi::sqlite3_enable_load_extension"), Some("Db"));
3941        // …and the database-CONTENT I/O the same sweep found unlisted beside it.
3942        assert_eq!(classify("ffi", "ffi::sqlite3_deserialize"), Some("Db"));
3943        assert_eq!(classify("ffi", "ffi::sqlite3_serialize"), Some("Db"));
3944        assert_eq!(classify("ffi", "ffi::sqlite3_db_cacheflush"), Some("Db"));
3945        assert_eq!(classify("ffi", "ffi::sqlite3_file_control"), Some("Db"));
3946        assert_eq!(classify("ffi", "ffi::sqlite3_wal_autocheckpoint"), Some("Db"));
3947        // CONTROLS for the boundary this list deliberately stops at — an in-memory accessor and a
3948        // per-connection callback INSTALLER stay unclassified, so the additions above are not read as
3949        // "every `sqlite3_*` is Db". Both pass before and after R166; recorded as boundary, not coverage.
3950        assert_eq!(classify("ffi", "ffi::sqlite3_column_int64"), None);
3951        assert_eq!(classify("ffi", "ffi::sqlite3_create_function_v2"), None);
3952        assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
3953        // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
3954        // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
3955        // `src/build.rs` module was being dropped as if it were the Cargo build script).
3956        assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
3957        assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
3958        assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
3959        assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
3960        // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
3961        // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
3962        // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
3963        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
3964        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
3965        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
3966        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
3967        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
3968        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
3969        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
3970        // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
3971        assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
3972        assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
3973        assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
3974        assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
3975        assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
3976        assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
3977        // pure crates stay pure
3978        assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
3979        assert_eq!(classify("std", "std::vec::Vec::push"), None);
3980
3981        // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
3982        // [24] std::net socket accessors are pure; the I/O verbs stay Net.
3983        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
3984        assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
3985        assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
3986        assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
3987        assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
3988        // [37] std DNS resolution is Net (was floored).
3989        assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
3990        assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
3991        // [23] std::process getters are pure; spawn/new stay Exec.
3992        assert_eq!(classify("std", "std::process::Command::get_program"), None);
3993        assert_eq!(classify("std", "std::process::Command::get_args"), None);
3994        assert_eq!(classify("std", "std::process::Child::id"), None);
3995        assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
3996        // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
3997        assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
3998        assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
3999        // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
4000        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
4001        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
4002        assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
4003        // THE FIX: `Database::connect_proxy` (the `proxy`-feature sibling of `Database::connect`) opens
4004        // a live `DatabaseConnection` through a caller-supplied `ProxyDatabaseTrait` — same effect,
4005        // missing from an allowlist keyed on the plain `connect` spelling.
4006        assert_eq!(classify("sea_orm", "sea_orm::Database::connect_proxy"), Some("Db"));
4007        // THE COVERAGE-GATE SWEEP: the transaction and pagination families, verified against sea-orm
4008        // 1.1.20 source.
4009        assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::transaction"), Some("Db"));
4010        assert_eq!(
4011            classify("sea_orm", "sea_orm::DatabaseConnection::transaction_with_config"),
4012            Some("Db")
4013        );
4014        // BONUS (found proving the above reachable with a real fixture, not in the original ratchet):
4015        assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::ping"), Some("Db"));
4016        assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::begin"), Some("Db"));
4017        assert_eq!(
4018            classify("sea_orm", "sea_orm::DatabaseConnection::begin_with_config"),
4019            Some("Db")
4020        );
4021        assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::commit"), Some("Db"));
4022        assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::rollback"), Some("Db"));
4023        assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::begin"), Some("Db"));
4024        assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::ping"), Some("Db"));
4025        assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::transaction"), Some("Db"));
4026        assert_eq!(classify("sea_orm", "sea_orm::SqlxPostgresPoolConnection::begin"), Some("Db"));
4027        assert_eq!(classify("sea_orm", "sea_orm::SqlxSqlitePoolConnection::ping"), Some("Db"));
4028        assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch"), Some("Db"));
4029        assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch_and_next"), Some("Db"));
4030        assert_eq!(classify("sea_orm", "sea_orm::Paginator::into_stream"), Some("Db"));
4031        assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_pages"), Some("Db"));
4032        assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_items_and_pages"), Some("Db"));
4033        assert_eq!(classify("sea_orm", "sea_orm::Insert::exec_with_returning_keys"), Some("Db"));
4034        assert_eq!(classify("sea_orm", "sea_orm::Inserter::exec_with_returning_many"), Some("Db"));
4035        assert_eq!(classify("sea_orm", "sea_orm::TryInsert::exec_with_returning_keys"), Some("Db"));
4036        // NO FABRICATION: the mock/proxy backends share every one of these verb names but perform no
4037        // real I/O (mock) or dispatch through a caller-supplied trait (proxy) — FQN-exact, not a bare
4038        // `::ping`/`::begin`/`::transaction`/`::commit`/`::rollback` suffix, keeps them pure/unknown.
4039        assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::ping"), None);
4040        assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::begin"), None);
4041        assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::commit"), None);
4042        assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::ping"), None);
4043    }
4044
4045    #[test]
4046    fn lettre_tls_setup_and_connection_family_is_covered() {
4047        // THE COVERAGE-GATE SWEEP, verified against lettre 0.11.23 source. `build_rustls` calls
4048        // `rustls_native_certs::load_native_certs()` (a real OS-trust-store read) + `tracing::debug!`.
4049        assert_eq!(
4050            classify("lettre", "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"),
4051            Some("Fs")
4052        );
4053        assert_eq!(classify("lettre", "lettre::TlsParametersBuilder::build"), Some("Fs"));
4054        assert_eq!(classify("lettre", "lettre::TlsParameters::new"), Some("Fs"));
4055        assert_eq!(classify("lettre", "lettre::TlsParameters::new_rustls"), Some("Fs"));
4056        assert_eq!(classify("lettre", "lettre::SmtpTransport::from_url"), Some("Fs"));
4057        assert_eq!(classify("lettre", "lettre::SmtpTransport::relay"), Some("Fs"));
4058        assert_eq!(classify("lettre", "lettre::SmtpTransport::starttls_relay"), Some("Fs"));
4059        assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::from_url"), Some("Fs"));
4060        assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::relay"), Some("Fs"));
4061        assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::starttls_relay"), Some("Fs"));
4062        assert_eq!(classify("lettre", "lettre::FileTransport::read"), Some("Fs"));
4063        // The sealed `Executor` trait impl — `#[doc(hidden)]`, a narrower surface, but genuinely
4064        // reachable (both the trait and `AsyncStd1Executor` are `pub`, re-exported at the crate root).
4065        assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::connect"), Some("Net"));
4066        assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_read"), Some("Fs"));
4067        assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_write"), Some("Fs"));
4068        // Only reachable via the module-qualified path — neither type is re-exported at the crate root.
4069        assert_eq!(
4070            classify(
4071                "lettre",
4072                "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
4073            ),
4074            Some("Net")
4075        );
4076        assert_eq!(
4077            classify(
4078                "lettre",
4079                "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
4080            ),
4081            Some("Net")
4082        );
4083        // NO FABRICATION: `NetworkStream` lives in a PRIVATE module (`mod net;`, never re-exported) — no
4084        // external consumer can ever name it, so no rule was added despite self-scan flagging its
4085        // `shutdown`/`set_read_timeout`/`set_write_timeout` (propagated from the coarse
4086        // `std::net::TcpStream` whole-handle rule).
4087        assert_eq!(classify("lettre", "lettre::NetworkStream::shutdown"), None);
4088        assert_eq!(classify("lettre", "lettre::NetworkStream::set_read_timeout"), None);
4089    }
4090
4091    #[test]
4092    fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
4093        // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
4094        // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
4095        assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
4096        assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
4097        assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
4098        assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
4099        assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
4100        assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
4101    }
4102
4103    #[test]
4104    fn redis_connection_manager_config_builder_is_pure() {
4105        // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
4106        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
4107        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
4108        // the LIVE manager still round-trips (Db).
4109        assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
4110        assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
4111    }
4112
4113    #[test]
4114    fn redis_get_connection_info_is_a_pure_accessor_not_swept_by_the_substring() {
4115        // coverage-gate sweep (2026-08-27): `contains("::get_connection")` also matched
4116        // `Client::get_connection_info` (a pure accessor over an already-stored field) because it is a
4117        // literal substring of that name — the real connection-getters stay caught.
4118        assert_eq!(classify("redis", "redis::Client::get_connection_info"), None);
4119        assert_eq!(classify("redis", "redis::Client::get_connection"), Some("Db"));
4120        assert_eq!(classify("redis", "redis::Client::get_async_connection"), Some("Db"));
4121        assert_eq!(
4122            classify("redis", "redis::Client::get_multiplexed_async_connection"),
4123            Some("Db")
4124        );
4125    }
4126
4127    #[test]
4128    fn aws_config_load_from_env_is_loads_own_convenience_wrapper() {
4129        // THE FIX: `load_from_env()` is the crate's OWN "convenience wrapper" (its doc comment's words)
4130        // around `from_env().load().await` — the exact already-modelled effect one call down — but
4131        // "load_from_env".ends_with("::load") is false.
4132        assert_eq!(classify("aws_config", "aws_config::load"), Some("Net"));
4133        assert_eq!(classify("aws_config", "aws_config::load_defaults"), Some("Net"));
4134        assert_eq!(classify("aws_config", "aws_config::load_from_env"), Some("Net"));
4135        assert_eq!(classify("aws_config", "aws_config::from_env"), None); // pure builder
4136    }
4137
4138    #[test]
4139    fn tungstenite_stream_first_handshake_entry_points_are_net() {
4140        // THE FIX: `client`/`client_with_config`/`client_tls`/`client_tls_with_config` (client.rs:176,
4141        // 159; tls.rs:163,179) and `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config`
4142        // (server.rs:23-63) each run `{Client,Server}Handshake::start(stream, ..).handshake()` — the real
4143        // WS upgrade I/O over an already-open stream. tungstenite's OWN documented way to run over a
4144        // caller-managed TCP/TLS/mio stream, missing from a verb list keyed only on `connect`.
4145        assert_eq!(classify("tungstenite", "tungstenite::client::client"), Some("Net"));
4146        assert_eq!(classify("tungstenite", "tungstenite::client::client_with_config"), Some("Net"));
4147        assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls"), Some("Net"));
4148        assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls_with_config"), Some("Net"));
4149        assert_eq!(classify("tungstenite", "tungstenite::server::accept"), Some("Net"));
4150        assert_eq!(classify("tungstenite", "tungstenite::server::accept_with_config"), Some("Net"));
4151        assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr"), Some("Net"));
4152        assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr_with_config"), Some("Net"));
4153        assert_eq!(classify("tungstenite", "tungstenite::client::connect_with_config"), Some("Net"));
4154    }
4155
4156    #[test]
4157    fn mysql_conn_new_is_the_real_connect() {
4158        // THE FIX: `mysql::Conn::new` (conn/mod.rs:342) calls `connect_stream()?; connect()?` directly;
4159        // `mysql_async::Conn::new` (conn/mod.rs:921) is the async equivalent. Each crate's own primary
4160        // connection constructor, the crate's own first doctest example — but `::new` never appeared in
4161        // the verb list.
4162        assert_eq!(classify("mysql", "mysql::Conn::new"), Some("Db"));
4163        assert_eq!(classify("mysql_async", "mysql_async::Conn::new"), Some("Db"));
4164        // NO FABRICATION: the SAME two crates' unrelated pure `new`s (Opts/PoolConstraints/TxOpts/…)
4165        // must not gain Db — scoped to the `Conn::` segment, not a bare `::new` suffix.
4166        assert_eq!(classify("mysql", "mysql::Opts::new"), None);
4167        assert_eq!(classify("mysql", "mysql::PoolConstraints::new"), None);
4168        assert_eq!(classify("mysql_async", "mysql_async::TxOpts::new"), None);
4169    }
4170
4171    #[test]
4172    fn mongodb_with_options_is_the_same_effect_as_with_uri_str() {
4173        // THE FIX: `with_uri_str` (client.rs:179) is `ClientOptions::parse(uri).await?;
4174        // Client::with_options(options)` one call down — `with_options` (client.rs:188) is where the
4175        // topology/monitoring actually spins up, and mongodb's own doc calls it the entry point for a
4176        // caller who already holds parsed `ClientOptions`. Verified against mongodb 3.8.1 (both the
4177        // async `client.rs` and sync `sync/client.rs` `Client`).
4178        assert_eq!(classify("mongodb", "mongodb::Client::with_uri_str"), Some("Db"));
4179        assert_eq!(classify("mongodb", "mongodb::Client::with_options"), Some("Db"));
4180        assert_eq!(classify("mongodb", "mongodb::sync::Client::with_options"), Some("Db"));
4181        assert_eq!(classify("mongodb", "mongodb::Collection::find_one"), Some("Db"));
4182        // NO FABRICATION: an unrelated crate's `with_options` builder must not gain Db — crate-gated.
4183        assert_eq!(classify("some_other_crate", "some_other_crate::Widget::with_options"), None);
4184    }
4185
4186    #[test]
4187    fn pure_fd_transfer_is_not_an_effect() {
4188        // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
4189        // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
4190        // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
4191        // `as_raw_fd` all fabricated effects.)
4192        assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
4193        assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
4194        assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
4195        assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
4196        assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
4197        assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
4198        assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
4199        assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
4200        assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
4201        // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
4202        assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
4203        assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
4204        assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
4205        assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
4206        // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
4207        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
4208        assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
4209        assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
4210        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
4211        assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
4212    }
4213
4214    /// THE PLATFORM `fs` MODULES. `std::fs::` was the whole filesystem rule, so every function in
4215    /// `std::os::{unix,windows,wasi}::fs` read PURE — measured with EXECUTED ground truth (a `cargo run`
4216    /// that created a real symlink and stat'd it back), `deny Fs` over a crate whose only filesystem
4217    /// write was `std::os::unix::fs::symlink` exited 0.
4218    ///
4219    /// The controls are the point: the `*Ext` DATA/BUILDER traits must stay pure, or the fix trades a
4220    /// silent under-report for a fabrication on every `m.uid()` — and `FileExt` must NOT, because
4221    /// `read_at`/`write_at` are the positional-I/O syscalls this rule exists to catch.
4222    #[test]
4223    fn the_platform_fs_modules_are_filesystem_io_and_their_data_traits_are_not() {
4224        for p in [
4225            "std::os::unix::fs::symlink", "std::os::unix::fs::chown", "std::os::unix::fs::lchown",
4226            "std::os::unix::fs::fchown", "std::os::unix::fs::chroot",
4227            "std::os::windows::fs::symlink_dir", "std::os::windows::fs::symlink_file",
4228            "std::os::windows::fs::junction_point",
4229            "std::os::unix::fs::FileExt::read_at", "std::os::unix::fs::FileExt::write_at",
4230            "std::os::windows::fs::FileExt::seek_read", "std::os::windows::fs::FileExt::seek_write",
4231        ] {
4232            assert_eq!(classify("std", p), Some("Fs"), "{p} performs filesystem I/O");
4233        }
4234        for p in [
4235            "std::os::unix::fs::MetadataExt::uid", "std::os::unix::fs::MetadataExt::mode",
4236            "std::os::unix::fs::PermissionsExt::mode", "std::os::unix::fs::PermissionsExt::set_mode",
4237            "std::os::unix::fs::OpenOptionsExt::custom_flags",
4238            "std::os::unix::fs::DirBuilderExt::mode",
4239            "std::os::unix::fs::DirEntryExt::ino", "std::os::unix::fs::FileTypeExt::is_fifo",
4240            "std::os::windows::fs::MetadataExt::file_attributes",
4241        ] {
4242            assert_eq!(classify("std", p), None,
4243                       "{p} reads or configures data already in hand — charging it would FABRICATE Fs");
4244        }
4245        // `std::os::unix::net` is Ipc and must be untouched by a rule keyed one segment along.
4246        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
4247    }
4248
4249    #[test]
4250    fn command_head_refines_the_exec_cliff() {
4251        use super::classify_command_head as h;
4252        // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
4253        assert_eq!(h("curl"), &["Net"]);
4254        assert_eq!(h("telnet"), &["Net"]);
4255        assert_eq!(h("sftp"), &["Net"]);
4256        assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
4257        assert_eq!(h("mongo"), &["Db"]);
4258        assert_eq!(h("cqlsh"), &["Db"]);
4259        // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
4260        assert_eq!(h("candor-scan"), &["Env", "Fs"]);
4261        assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
4262        // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
4263        // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
4264        // cliff rather than fabricate an effect for the common case.
4265        assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
4266        assert_eq!(h("make"), &[] as &[&str]);
4267        assert_eq!(h("npm"), &[] as &[&str]);
4268        assert_eq!(h("git"), &[] as &[&str]);
4269        assert_eq!(h("rsync"), &[] as &[&str]);
4270        // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
4271        // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
4272        assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
4273        assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
4274        assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
4275        // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
4276        // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
4277        // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
4278        // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
4279        // name a program, so only they may refine.
4280        assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
4281        assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
4282        assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
4283    }
4284
4285    #[test]
4286    fn net_establishing_allowlist() {
4287        // sweep [3]/[7]: the masking guard's establishing-verb allowlist — host-bearing connect/request
4288        // verbs establish (a runtime host there is invisible); USE-verbs on a connected socket do NOT.
4289        assert!(is_net_establishing("connect") && is_net_establishing("connect_timeout"));
4290        assert!(is_net_establishing("get") && is_net_establishing("post") && is_net_establishing("request"));
4291        assert!(is_net_establishing("send_to") && is_net_establishing("to_socket_addrs"));
4292        // use-verbs (host fixed at connect) must NOT be establishing — else `connect("h").write()` flags.
4293        assert!(!is_net_establishing("write") && !is_net_establishing("read") && !is_net_establishing("send"));
4294        assert!(!is_net_establishing("flush") && !is_net_establishing("recv") && !is_net_establishing("peek"));
4295    }
4296
4297    #[test]
4298    fn fs_path_arg_allowlist() {
4299        // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
4300        // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
4301        // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
4302        // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
4303        assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
4304        assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
4305        assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
4306        assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
4307        // handle ops / pure builders take NO path arg — never path-naming.
4308        assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
4309        assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
4310    }
4311
4312    #[test]
4313    fn db_query_arg_allowlist() {
4314        // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
4315        // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
4316        // ops carry no SQL string and must NOT flag.
4317        assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
4318        assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
4319        assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
4320        // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
4321        assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
4322        assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
4323        // connection / lifecycle ops take no SQL — must NOT flag.
4324        assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
4325        assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
4326    }
4327}
4328
4329#[cfg(test)]
4330mod fs_kind_tests {
4331    use super::fs_kind;
4332
4333    /// SPEC §2 `fs`. Most of what these assert is what the classifier REFUSES to say: §2 requires the
4334    /// field be "omitted rather than guessed", because an empty or partial `fs` reads as a positive claim
4335    /// ("reads but never writes") — the §4 trust contract's forbidden direction.
4336    #[test]
4337    fn write_verbs() {
4338        for p in ["std::fs::write", "std::fs::create_dir_all", "std::fs::remove_file",
4339                  "File::create", "std::fs::set_permissions", "f::write_all"] {
4340            assert_eq!(fs_kind(p), &["write"], "{p} mutates the disk");
4341        }
4342    }
4343
4344    #[test]
4345    fn read_verbs() {
4346        for p in ["std::fs::read_to_string", "std::fs::read_dir", "std::fs::metadata",
4347                  "File::open", "std::fs::canonicalize", "f::read_to_end"] {
4348            assert_eq!(fs_kind(p), &["read"], "{p} observes without mutating");
4349        }
4350    }
4351
4352    /// A copy/rename reads the source AND writes the destination — one call, both kinds.
4353    #[test]
4354    fn two_locator_verbs_are_both() {
4355        for p in ["std::fs::copy", "std::fs::rename", "std::fs::hard_link"] {
4356            assert_eq!(fs_kind(p), &["read", "write"], "{p}");
4357        }
4358    }
4359
4360    /// THE LOAD-BEARING CASE. A verb that does not reveal direction must contribute NOTHING — not a
4361    /// default, not a guess. Anything here returning a kind would let a function claim "reads but never
4362    /// writes" on the strength of a verb that said neither.
4363    #[test]
4364    fn unrevealing_verbs_make_no_claim() {
4365        for p in ["std::fs::OpenOptions", "some_crate::do_thing", "std::fs::File", "f::seek"] {
4366            assert!(fs_kind(p).is_empty(), "{p} must not claim a direction it did not reveal");
4367        }
4368    }
4369
4370    /// `OpenOptions::open` is `File::open`'s leaf and NOT its verb: the direction lives in the builder
4371    /// chain (`.read(true)`/`.write(true)`), which `fs_kind` cannot see. A `write(true)` builder claiming
4372    /// `["read"]` is the forbidden direction, so the type wins over the leaf here. `File::open` — where
4373    /// `open` DOES mean read — is unaffected, which is the whole reason this is keyed on the type.
4374    #[test]
4375    fn openoptions_open_claims_no_direction_but_file_open_still_reads() {
4376        for p in ["std::fs::OpenOptions::open", "fs_err::OpenOptions::open",
4377                  "tokio::fs::OpenOptions::open"] {
4378            assert!(fs_kind(p).is_empty(),
4379                    "{p}'s direction was set by the builder chain, which this function cannot see");
4380        }
4381        assert_eq!(fs_kind("std::fs::File::open"), &["read"], "`File::open` is unambiguously a read");
4382    }
4383}