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
10/// The SURPRISE heuristic (the cold-repo hook) — SHARED so candor-scan's scan-time note and
11/// candor-query's `tour` verb can't drift. Generic over the effect element type.
12pub mod surface;
13
14/// Project-supplied rules, consulted only when the built-in `classify` returns None.
15pub fn classify_extra(
16    crate_name: &str,
17    path: &str,
18    extra: &[(&'static str, bool, String)],
19) -> Option<&'static str> {
20    for (eff, is_crate, prefix) in extra {
21        let hit = if *is_crate { crate_name.starts_with(prefix.as_str()) } else { path.starts_with(prefix.as_str()) };
22        if hit {
23            return Some(eff);
24        }
25    }
26    None
27}
28
29/// The exact third-party crates `classify` has effect rules for, and the crate-name
30/// PREFIXES it recognizes. This is the single source of truth for "what candor knows":
31/// it is emitted beside the JSON report (`<prefix>.calibrated.json`) so the Claude Code
32/// receipt's coverage check reads candor's real coverage instead of a hand-copied list.
33/// Keep in lockstep with `classify` below — the `db_crates_are_calibrated` and
34/// `calibrated_crates_are_live` tests (in this crate's `tests` module) enforce both directions.
35pub const CALIBRATED_CRATES: [&str; 79] = [
36    // network (aws_config resolves credentials over the network on `.load()`;
37    // git2 remote ops — fetch/push/connect — contact the network; async_net is smol's net layer;
38    // pnet is raw L2/L3 packet capture)
39    "reqwest", "isahc", "ureq", "curl", "aws_config", "git2", "tokio_tcp", "tokio_udp", "async_net",
40    "async_nats", "lapin", "lettre", "tungstenite", "elasticsearch", "tonic", "rdkafka", "pnet",
41    // directory traversal (ignore = gitignore-aware walker, powers ripgrep/fd; its walk executors are Fs)
42    // + filesystem watching (notify = inotify/FSEvents/kqueue wrapper; powers watchexec/cargo-watch)
43    "ignore", "notify",
44    // database (see DB_CRATES in classify)
45    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
46    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
47    // filesystem (async_fs = smol; fs_err = std::fs wrapper; tempfile; glob) / entropy /
48    // subprocess (async_process = smol; duct) / env (dotenvy/dotenv) / clock (time) / log / clipboard
49    "memmap2", "fs_err", "async_fs", "tempfile", "glob",
50    "rand", "getrandom", "fastrand",
51    // entropy: the password-hashing tier (salt mints + bcrypt's internal salt) + the OsRng source
52    "argon2", "bcrypt", "scrypt", "pbkdf2", "password_hash", "rand_core",
53    "portable_pty", "async_process", "duct",
54    "dotenvy", "dotenv",
55    "chrono", "time", "tracing", "log", "arboard",
56    // compiler diagnostic emission (a dylint lint's output) — see the Log rules in classify
57    "rustc_lint", "rustc_errors",
58    // raw syscalls via FFI — the syscall-name table that lights up the FFI-thin tier (nix is routed
59    // through the same table by leaf name, so a consumer of nix is covered without nix's own source)
60    "libc", "nix", "rustix",
61    // coverage-differential additions (verb-keyed; see the per-crate rules near the end of classify):
62    // sync TLS core + native-tls variants (Net); env/dir resolution + argv + LS_COLORS (Env);
63    // sqlx-core execution terminals (Net/Db); directory walk + timestamp mutation + same-file (Fs);
64    // process-spawn helpers (Exec); signal handler + interactive-tty prompts (Ipc); env_logger (Log);
65    // jiff/backoff clock reads (Clock).
66    "rustls", "native_tls_crate", "tokio_native_tls",
67    "etcetera", "wild", "lscolors",
68    "sqlx_core", "walkdir", "filetime", "clircle",
69    "execute", "ctrlc", "clap", "jiff", "env_logger",
70    "dialoguer", "console", "terminal_colorsaurus", "backoff", "grep_cli",
71];
72
73pub const CALIBRATED_PREFIXES: [&str; 3] = ["aws_sdk_", "aws_smithy", "cap_"];
74
75/// Crates `classify` matches by PATH prefix rather than crate-name equality (their effectful modules
76/// are recognised, e.g. `tokio::net::`/`async_std::fs::`/`mio::net::`), so they're absent from
77/// `CALIBRATED_CRATES` (which the liveness test probes by crate name). The coverage check must still
78/// treat them as *covered* — otherwise it would mislabel the most common async crates as blind spots.
79pub const PATH_CALIBRATED_CRATES: [&str; 3] = ["tokio", "async_std", "mio"];
80
81/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
82/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
83/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
84/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
85/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
86/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
87pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
88    "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
89    "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
90    "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
91    "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
92    "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
93    // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
94    // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
95    "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
96    // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
97    // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
98    "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
99    "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
100    "::write_line", "::background_color", "::retry", "::build",
101];
102
103/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
104/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
105pub const DB_CRATES: [&str; 11] = [
106    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
107    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
108];
109
110/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
111/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
112/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
113/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
114/// 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
115/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
116/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
117/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
118/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
119/// fabricated Net/Fs/Ipc.)
120const PURE_FD_TRANSFER: &[&str] = &[
121    "from_raw_fd", "from_raw_socket", "from_raw_handle",
122    "into_raw_fd", "into_raw_socket", "into_raw_handle",
123    "as_raw_fd", "as_raw_socket", "as_raw_handle",
124    "into_std",
125    // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
126    // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
127    // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
128    "from_pathname",
129];
130
131/// Classify a resolved callee by the crate it belongs to and its full path.
132pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
133    // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
134    // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
135    if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
136        return None;
137    }
138    if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
139        // Only request dispatch is network I/O; builder setters/accessors are pure.
140        if path.ends_with("::send") || path.ends_with("::send_with") {
141            return Some("Net");
142        }
143        return None;
144    }
145    // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
146    // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
147    // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
148    // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
149    if crate_name == "aws_config" {
150        if path.ends_with("::load") || path.ends_with("::load_defaults") {
151            return Some("Net");
152        }
153        return None;
154    }
155    // git2 (libgit2 FFI): remote operations contact the network; everything else is local
156    // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
157    // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
158    // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
159    // client reporting it makes no network calls.)
160    if crate_name == "git2" {
161        if path.ends_with("::fetch")
162            || path.ends_with("::push")
163            || path.ends_with("::download")
164            || path.ends_with("::connect")
165            || path.ends_with("::connect_auth")
166            || path.ends_with("::ls")
167            || path.ends_with("::upload")
168        {
169            return Some("Net");
170        }
171        return None;
172    }
173    // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
174    // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
175    // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
176    // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
177    // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
178    // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
179    // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
180    // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
181    // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
182    // gmtime) are also skipped.
183    //
184    // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
185    // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
186    // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
187    // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
188    // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
189    // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
190    // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
191    // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
192    // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
193    // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
194    // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
195    // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
196    // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
197    if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
198        let f = path.rsplit("::").next().unwrap_or(path);
199        // path / directory / metadata syscalls (incl. *64 and *at variants)
200        const FS: &[&str] = &[
201            "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
202            "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
203            "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
204            "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
205            "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
206            "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
207            "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
208            "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
209            "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
210            "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
211            "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
212            "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
213            "realpath",
214        ];
215        // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
216        // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
217        const NET: &[&str] = &[
218            "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
219            "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
220            "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
221        ];
222        // process creation / replacement / reaping
223        const EXEC: &[&str] = &[
224            "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
225            "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
226            "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
227        ];
228        // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
229        const IPC: &[&str] = &[
230            "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
231            "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
232            "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
233        ];
234        const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
235        const CLOCK: &[&str] = &[
236            "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
237            // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
238            // which sets it via `clock_settime`).
239            "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
240        ];
241        const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
242        if FS.contains(&f) {
243            return Some("Fs");
244        }
245        if NET.contains(&f) {
246            return Some("Net");
247        }
248        if EXEC.contains(&f) {
249            return Some("Exec");
250        }
251        if IPC.contains(&f) {
252            return Some("Ipc");
253        }
254        if ENV.contains(&f) {
255            return Some("Env");
256        }
257        if CLOCK.contains(&f) {
258            return Some("Clock");
259        }
260        if RAND.contains(&f) {
261            return Some("Rand");
262        }
263        return None;
264    }
265    // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
266    // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
267    // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
268    // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
269    // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
270    // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
271    // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
272    // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
273    // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
274    {
275        let leaf = path.rsplit("::").next().unwrap_or(path);
276        if let Some(rest) = leaf.strip_prefix("sqlite3_") {
277            let _ = rest;
278            // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
279            const DB: &[&str] = &[
280                "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
281                "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
282                "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
283                "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
284                "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
285                "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
286            ];
287            return DB.contains(&leaf).then_some("Db");
288        }
289        if leaf.starts_with("git_") {
290            // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
291            // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
292            // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
293            const NET: &[&str] = &[
294                "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
295                "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
296                "git_submodule_clone", "git_submodule_update",
297            ];
298            // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
299            const FS: &[&str] = &[
300                "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
301                "git_repository_init", "git_repository_init_ext", "git_repository_discover",
302                "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
303                "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
304                "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
305                "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
306                "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
307                "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
308                "git_commit_create_v", "git_reference_create", "git_reference_set_target",
309                "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
310                "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
311                "git_packbuilder_write",
312            ];
313            if NET.contains(&leaf) {
314                return Some("Net");
315            }
316            if FS.contains(&leaf) {
317                return Some("Fs");
318            }
319            return None;
320        }
321        if leaf.starts_with("curl_") {
322            // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
323            // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
324            // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
325            // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
326            // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
327            // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
328            // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
329            const NET: &[&str] = &[
330                "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
331                "curl_multi_perform", "curl_multi_socket_action",
332            ];
333            return NET.contains(&leaf).then_some("Net");
334        }
335        if let Some(op) = leaf.strip_prefix("SSL_") {
336            // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
337            // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
338            // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
339            // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
340            // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
341            const SSL_NET: &[&str] = &[
342                "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
343                "peek_ex", "shutdown",
344            ];
345            return SSL_NET.contains(&op).then_some("Net");
346        }
347    }
348    // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
349    // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
350    // were silently classified network-free because reqwest wasn't recognized.)
351    if crate_name == "reqwest" || crate_name == "isahc" {
352        // The builder chain is pure; the dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot
353        // CONVENIENCE functions `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send
354        // immediately — they're not the `Client::get` builder (a different path, `reqwest::Client::get`),
355        // so an exact match avoids false-positiving the builder. (Found running on `xh`: a one-shot
356        // `reqwest::get(url)` was classified network-free.)
357        if path.ends_with("::send")
358            || path.ends_with("::execute")
359            || path == "reqwest::get"
360            || path == "reqwest::blocking::get"
361            || path == "isahc::get"
362        {
363            return Some("Net");
364        }
365        return None;
366    }
367    if crate_name == "ureq" && path.ends_with("::call") {
368        return Some("Net");
369    }
370    // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
371    // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
372    // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
373    // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
374    // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
375    // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
376    if crate_name == "curl"
377        && (path.ends_with("::perform")
378            || path.ends_with("::send")
379            || path.ends_with("::recv")
380            || path.ends_with("::upkeep")
381            || path.ends_with("::action"))
382    {
383        return Some("Net");
384    }
385    // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
386    // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
387    // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
388    // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
389    // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
390    // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
391    match crate_name {
392        // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
393        "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
394        // hyper-util's pooled legacy Client + its TCP connectors.
395        "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
396        // hickory (trust-dns) resolver — issues DNS queries over the network.
397        "hickory_resolver"
398            if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
399                || path.ends_with("::resolve") => return Some("Net"),
400        // HTTP/3 over QUIC.
401        "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
402            || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
403        // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
404        // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
405        // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
406        // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
407        "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
408            || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
409            || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
410            || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
411            || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
412            || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
413            || path.ends_with("::finish") => return Some("Net"),
414        // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
415        "tokio_rustls" | "native_tls"
416            if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
417            return Some("Net"),
418        // AF_VSOCK host<->guest sockets — inter-process / VM comms.
419        "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
420            return Some("Ipc"),
421        // Loads the OS trust store from disk (cert files / keychain).
422        "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
423        // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
424        // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
425        // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
426        // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
427        // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
428        // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
429        "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
430            || path.ends_with("::increase_nofile_limit") => return Some("Env"),
431        // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
432        // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
433        // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
434        // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
435        // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
436        // read_tls), so flagging it would over-report Net on the pure decrypt step.
437        "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
438            || path.ends_with("::complete_io") => return Some("Net"),
439        // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
440        // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
441        "native_tls_crate" | "tokio_native_tls"
442            if path.ends_with("::connect") || path.ends_with("::accept")
443                || path.ends_with("::handshake") => return Some("Net"),
444        _ => {}
445    }
446    // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
447    // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
448    // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
449    // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
450    // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
451    // classified pure — a message-queue client reporting no I/O.)
452    if crate_name == "async_nats" {
453        if path.ends_with("::connect")
454            || path.contains("::publish")
455            || path.ends_with("::subscribe")
456            || path.ends_with("::queue_subscribe")
457            || path.contains("::request")
458            || path.ends_with("::flush")
459        {
460            return Some("Net");
461        }
462        return None;
463    }
464    if crate_name == "lapin" {
465        if path.ends_with("::connect")
466            || path.ends_with("::create_channel")
467            || path.contains("::basic_")
468            || path.contains("::queue_")
469            || path.contains("::exchange_")
470            || path.contains("::tx_")
471            || path.ends_with("::confirm_select")
472            || path.ends_with("::close")
473        {
474            return Some("Net");
475        }
476        return None;
477    }
478    // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
479    // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
480    if crate_name == "lettre" {
481        if path.ends_with("::send") || path.ends_with("::send_raw") {
482            return Some("Net");
483        }
484        return None;
485    }
486    // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
487    // and the socket read/write/send are network; Message constructors are pure. (Found on a
488    // tungstenite consumer: connect + send + read classified pure.)
489    if crate_name == "tungstenite" {
490        if path.ends_with("::connect")
491            || path.ends_with("::read")
492            || path.ends_with("::write")
493            || path.ends_with("::send")
494            || path.ends_with("::close")
495            || path.ends_with("::flush")
496            || path.ends_with("::read_message")
497            || path.ends_with("::write_message")
498        {
499            return Some("Net");
500        }
501        return None;
502    }
503    // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
504    // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
505    if crate_name == "elasticsearch" && path.ends_with("::send") {
506        return Some("Net");
507    }
508    // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
509    // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
510    // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
511    if crate_name == "tonic" {
512        if path.ends_with("::connect")
513            || path.ends_with("::unary")
514            || path.ends_with("::server_streaming")
515            || path.ends_with("::client_streaming")
516            || path.ends_with("::streaming")
517        {
518            return Some("Net");
519        }
520        return None;
521    }
522    // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
523    // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
524    // repro needs librdkafka/cmake, deferred.)
525    if crate_name == "rdkafka" {
526        if path.ends_with("::send")
527            || path.ends_with("::send_result")
528            || path.ends_with("::recv")
529            || path.ends_with("::poll")
530            || path.ends_with("::subscribe")
531            || path.ends_with("::commit")
532            || path.ends_with("::commit_message")
533            || path.ends_with("::commit_consumer_state")
534            || path.ends_with("::store_offset")
535            || path.ends_with("::seek")
536            || path.ends_with("::fetch_metadata")
537            || path.ends_with("::fetch_watermarks")
538            || path.ends_with("::flush")
539        {
540            return Some("Net");
541        }
542        return None;
543    }
544    // cap-std: capability-oriented std. I/O goes *through* a held capability handle
545    // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
546    // cap-std project's real I/O is detected and matches the capability it declared
547    // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
548    if crate_name.starts_with("cap_") {
549        if path.contains("::net::Unix") || path.contains("::os::") {
550            return Some("Ipc");
551        }
552        if path.contains("::net") {
553            return Some("Net");
554        }
555        if path.contains("::time") {
556            return Some("Clock");
557        }
558        if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
559            return Some("Fs");
560        }
561        return None;
562    }
563    // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
564    // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
565    // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
566    // `unix`.
567    if path.starts_with("tokio::net::Unix")
568        || path.starts_with("std::os::unix::net")
569        || path.starts_with("async_std::os::unix::net")
570        || path.starts_with("async_net::unix")
571    {
572        return Some("Ipc");
573    }
574    // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
575    // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
576    // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
577    // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
578    // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
579    // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
580    // scanning bandwhich — a packet sniffer — which reported Net 0.)
581    if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
582        if path.ends_with("::channel") || path.ends_with("::transport_channel") {
583            return Some("Net");
584        }
585        return None;
586    }
587    // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
588    // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
589    // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
590    // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
591    // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
592    // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
593    // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
594    if crate_name == "ignore" {
595        if path == "ignore::WalkBuilder::build"
596            || path == "ignore::WalkBuilder::build_parallel"
597            || path.ends_with("::WalkParallel::run")
598            // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
599            // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
600            // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
601            // surface, so it was silently pure under the covered-crate floor.
602            || path == "ignore::WalkBuilder::add_ignore"
603        {
604            return Some("Fs");
605        }
606        return None;
607    }
608    // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
609    // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
610    // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
611    // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
612    // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
613    // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
614    if crate_name == "notify" {
615        if path.ends_with("Watcher::new")
616            || path.ends_with("::recommended_watcher")
617            || path.ends_with("::watch")
618            || path.ends_with("::unwatch")
619        {
620            return Some("Fs");
621        }
622        return None;
623    }
624    // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
625    // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
626    // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
627    if path.ends_with("::to_socket_addrs")
628        || path == "std::net::lookup_host"
629        || path.ends_with("ToSocketAddrs::to_socket_addrs")
630    {
631        return Some("Net");
632    }
633    // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
634    // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
635    if path.starts_with("std::net::TcpStream")
636        || path.starts_with("std::net::TcpListener")
637        || path.starts_with("std::net::UdpSocket")
638        || path.starts_with("tokio::net::")
639    {
640        // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
641        // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
642        // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
643        // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
644        if path.ends_with("::local_addr")
645            || path.ends_with("::peer_addr")
646            || path.ends_with("::nodelay")
647            || path.ends_with("::ttl")
648            || path.ends_with("::take_error")
649        {
650            return None;
651        }
652        return Some("Net");
653    }
654    // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
655    // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
656    // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
657    // network-free — a network tool confidently reporting 0 Net.)
658    if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
659        return Some("Net");
660    }
661    // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
662    // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
663    // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
664    // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
665    // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
666    // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
667    if path.starts_with("async_std::net::")
668        || path.starts_with("mio::net::")
669        || crate_name == "async_net"
670    {
671        return Some("Net");
672    }
673    // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
674    // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
675    // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
676    // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
677    if DB_CRATES.contains(&crate_name) {
678        // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
679        // execution (round-trips to the server). sqlx is the outlier where bare `query()`
680        // only BUILDS — it keeps the narrow set below. (Found by running on a real
681        // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
682        if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
683            const PG: [&str; 19] = [
684                "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
685                "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
686                "::copy_in", "::copy_out", "::transaction", "::connect",
687                // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
688                // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
689                // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
690                // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
691                // tokio_postgres 0.7.10+.
692                "::query_row", "::query_map", "::query_and_then", "::execute_batch",
693                "::prepare_cached", "::query_typed",
694            ];
695            if PG.iter().any(|v| path.ends_with(v)) {
696                return Some("Db");
697            }
698            // rusqlite only: opening the database IS the connection establishment (`Connection::
699            // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
700            if crate_name == "rusqlite"
701                && (path.ends_with("::open")
702                    || path.ends_with("::open_in_memory")
703                    || path.ends_with("::open_with_flags"))
704            {
705                return Some("Db");
706            }
707            return None;
708        }
709        // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
710        // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
711        // connection establishment. The shared VERBS below only catch the low-level
712        // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
713        // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
714        if crate_name == "redis"
715            && (path.contains("Commands::")
716                || path.contains("::get_connection")
717                || path.contains("::get_async_connection")
718                || path.contains("::get_multiplexed_async_connection")
719                // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
720                // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
721                // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
722                || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
723                    && !path.ends_with("::clone"))
724                || path.ends_with("::query")
725                || path.ends_with("::query_async")
726                || path.ends_with("::req_command")
727                || path.ends_with("::req_packed_command")
728                || path.ends_with("::req_packed_commands"))
729        {
730            return Some("Db");
731        }
732        // mongodb: a document-store API with none of the SQL verbs — the user calls
733        // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
734        // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
735        // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
736        // and option/doc builders don't match these verbs, so they stay pure.
737        if crate_name == "mongodb" {
738            const MONGO: [&str; 27] = [
739                "::with_uri_str", "::connect", "::find", "::find_one", "::insert_one",
740                "::insert_many", "::update_one", "::update_many", "::delete_one",
741                "::delete_many", "::replace_one", "::aggregate", "::count_documents",
742                "::estimated_document_count", "::count", "::distinct", "::run_command",
743                "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
744                "::list_collections", "::list_collection_names", "::list_databases",
745                "::list_database_names", "::create_collection", "::create_index", "::watch",
746            ];
747            if MONGO.iter().any(|v| path.ends_with(v)) {
748                return Some("Db");
749            }
750            return None;
751        }
752        // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
753        // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
754        // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
755        // API (unit-tested; a real-app repro is the remaining confirmation).
756        if matches!(crate_name, "mysql" | "mysql_async") {
757            const MY: [&str; 16] = [
758                "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
759                "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
760                "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
761            ];
762            if MY.iter().any(|v| path.ends_with(v)) {
763                return Some("Db");
764            }
765            return None;
766        }
767        // sea_orm: an ORM whose execution is split from building (like sqlx). The query
768        // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
769        // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
770        // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
771        // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
772        // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
773        if crate_name == "sea_orm" {
774            // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
775            // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
776            // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
777            // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
778            // exclude the whole re-exported namespace first.
779            if path.contains("sea_query") {
780                return None;
781            }
782            if path.ends_with("::all")
783                || path.ends_with("::one")
784                || path.ends_with("::count")
785                || path.ends_with("::stream")
786                || path.ends_with("::exec")
787                || path.ends_with("::exec_with_returning")
788                || path.ends_with("::exec_without_returning")
789                || path.ends_with("::connect")
790                || path.ends_with("::execute")
791                || path.ends_with("::execute_unprepared")
792                || path.ends_with("::query_one")
793                || path.ends_with("::query_all")
794                || path.ends_with("::fetch_page")
795                || path.ends_with("::num_items")
796                || path.contains("ActiveModelTrait::")
797            {
798                return Some("Db");
799            }
800            return None;
801        }
802        // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
803        // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
804        // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
805        const VERBS: [&str; 19] = [
806            "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
807            "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
808            "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
809            "::first", "::get_result", "::get_results",
810        ];
811        if VERBS.iter().any(|v| path.ends_with(v)) {
812            return Some("Db");
813        }
814        return None;
815    }
816    // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
817    // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
818    // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
819    // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
820    // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
821    // fd's residual `Path::symlink_metadata` under-report.)
822    if let Some(m) = path
823        .strip_prefix("std::path::Path::")
824        .or_else(|| path.strip_prefix("std::path::PathBuf::"))
825    {
826        const STAT: &[&str] = &[
827            "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
828            "try_exists", "is_file", "is_dir", "is_symlink",
829        ];
830        return STAT.contains(&m).then_some("Fs");
831    }
832    // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
833    // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
834    if path.starts_with("std::fs::")
835        || path.starts_with("tokio::fs::")
836        || path.starts_with("async_std::fs::")
837        || crate_name == "async_fs"
838        || crate_name == "fs_err"
839    {
840        return Some("Fs");
841    }
842    // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
843    // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
844    // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
845    // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
846    // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
847    // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
848    // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
849    // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
850    if crate_name == "memmap2" {
851        let m = path.rsplit("::").next().unwrap_or(path);
852        if m.starts_with("map")
853            || m == "flush"
854            || m == "flush_async"
855            || m == "flush_range"
856            || m == "flush_async_range"
857            || m == "remap"
858            || m.starts_with("make_")
859            || m == "advise"
860            || m == "advise_range"
861            || m == "lock"
862            || m == "unlock"
863        {
864            return Some("Fs");
865        }
866        return None;
867    }
868    // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
869    // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
870    // the file on disk; `close` removes it.
871    if crate_name == "tempfile"
872        && (path.ends_with("::tempfile")
873            || path.ends_with("::tempfile_in")
874            || path.ends_with("::tempdir")
875            || path.ends_with("::tempdir_in")
876            || path.ends_with("NamedTempFile::new")
877            || path.ends_with("NamedTempFile::new_in")
878            || path.ends_with("TempDir::new")
879            || path.ends_with("TempDir::new_in")
880            || path.ends_with("::persist")
881            || path.ends_with("::persist_noclobber")
882            || path.ends_with("::keep"))
883    {
884        return Some("Fs");
885    }
886    // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
887    // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
888    if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
889        return Some("Fs");
890    }
891    // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
892    // argon2 landed on exactly the call a security review cares about). In this engine's
893    // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
894    // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
895    // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
896    // carries the OsRng source itself (otherwise the most common salt mint is invisible).
897    if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
898        if path.contains("SaltString::generate") {
899            return Some("Rand");
900        }
901        return None;
902    }
903    if crate_name == "bcrypt" {
904        if path.ends_with("::hash") || path.ends_with("::hash_with_result") {
905            return Some("Rand");
906        }
907        return None;
908    }
909    if crate_name == "rand_core" {
910        if path.contains("OsRng")
911            || path.ends_with("::next_u32")
912            || path.ends_with("::next_u64")
913            || path.ends_with("::fill_bytes")
914        {
915            return Some("Rand");
916        }
917        return None;
918    }
919    // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
920    // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
921    // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
922    // over-reported those as `Rand`; match only the calls that actually consume randomness — the
923    // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
924    // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
925    if crate_name == "getrandom" {
926        return Some("Rand");
927    }
928    // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
929    // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
930    // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
931    // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
932    // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
933    // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
934    // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
935    // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
936    // (i.e. a value draw) is Rand.
937    if crate_name == "fastrand" {
938        let m = path.rsplit("::").next().unwrap_or(path);
939        // Provably pure: deterministic seeded ctor + state split/copy.
940        if m == "with_seed" || m == "fork" || m == "clone" {
941            return None;
942        }
943        // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
944        // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
945        // method we don't recognise — keep the effect, the safe direction.)
946        return Some("Rand");
947    }
948    if crate_name == "rand" {
949        let rng_verb = path.ends_with("::gen")
950            || path.ends_with("::gen_range")
951            || path.ends_with("::gen_bool")
952            || path.ends_with("::gen_ratio")
953            || path.ends_with("::random")
954            || path.ends_with("::random_range")
955            || path.ends_with("::random_bool")
956            || path.ends_with("::random_ratio")
957            || path.ends_with("::random_iter") // rand 0.9 iterator generator
958            || path.ends_with("::gen_iter")
959            || path.ends_with("::fill")
960            || path.ends_with("::fill_bytes")
961            || path.ends_with("::try_fill")
962            || path.ends_with("::try_fill_bytes")
963            || path.ends_with("::sample")
964            || path.ends_with("::sample_iter")
965            || path.ends_with("::next_u32")
966            || path.ends_with("::next_u64")
967            || path.ends_with("::thread_rng")
968            || path.ends_with("::rng")
969            || path.ends_with("::from_entropy")
970            || path.ends_with("::from_os_rng");
971        // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
972        // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
973        // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
974        // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
975        // review: OsRng is a unit struct, cloning consumes nothing).
976        let m = path.rsplit("::").next().unwrap_or(path);
977        let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
978        if rng_verb || os_rng {
979            return Some("Rand");
980        }
981        return None;
982    }
983    // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
984    // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
985    // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
986    // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
987    // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
988    if path.starts_with("std::process::Command")
989        || path.starts_with("std::process::Child")
990        || path.starts_with("tokio::process::Command")
991        || path.starts_with("tokio::process::Child")
992        || path.starts_with("async_std::process::Command")
993        || path.starts_with("async_std::process::Child")
994    {
995        // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
996        // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
997        // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
998        // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
999        if path.ends_with("::get_program")
1000            || path.ends_with("::get_args")
1001            || path.ends_with("::get_envs")
1002            || path.ends_with("::get_current_dir")
1003            || path.ends_with("Child::id")
1004        {
1005            return None;
1006        }
1007        return Some("Exec");
1008    }
1009    // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
1010    // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
1011    // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
1012    // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
1013    // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
1014    // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
1015    // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
1016    // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
1017    if crate_name == "async_process" || crate_name == "portable_pty" {
1018        let m = path.rsplit("::").next().unwrap_or(path);
1019        // configuration read-back getters — pure (no spawn).
1020        if m.starts_with("get_") || m == "as_unix_command_line" {
1021            return None;
1022        }
1023        // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
1024        if matches!(
1025            m,
1026            "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
1027                | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
1028                | "cwd" | "current_dir" | "rows" | "cols"
1029                | "clone" | "fmt" | "eq" | "ne" | "hash"
1030        ) {
1031            return None;
1032        }
1033        return Some("Exec");
1034    }
1035    // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
1036    // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
1037    if crate_name == "duct"
1038        && (path.ends_with("::run")
1039            || path.ends_with("::read")
1040            || path.ends_with("::start")
1041            || path.ends_with("::read_chars"))
1042    {
1043        return Some("Exec");
1044    }
1045    if path.starts_with("std::env::") {
1046        return Some("Env");
1047    }
1048    // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
1049    // environment). Match the load/read entry points; `Error`/builder types stay pure.
1050    if matches!(crate_name, "dotenvy" | "dotenv")
1051        && (path.ends_with("::dotenv")
1052            || path.ends_with("::dotenv_override")
1053            || path.ends_with("::from_path")
1054            || path.ends_with("::from_path_override")
1055            || path.ends_with("::from_filename")
1056            || path.ends_with("::from_filename_override")
1057            || path.ends_with("::from_read")
1058            || path.ends_with("::from_read_override")
1059            || path.ends_with("::load")
1060            || path.ends_with("::var")
1061            || path.ends_with("::vars"))
1062    {
1063        return Some("Env");
1064    }
1065    // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
1066    // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
1067    // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
1068    if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
1069        return Some("Clock");
1070    }
1071    if crate_name == "time"
1072        && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
1073    {
1074        return Some("Clock");
1075    }
1076    // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
1077    // the emit, not the whole crate. The actual program output is the macro-expanded
1078    // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
1079    // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
1080    // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
1081    // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
1082    // fabricated Log on them. Match the emit verbs; everything else returns None.
1083    if crate_name == "tracing" {
1084        let m = path.rsplit("::").next().unwrap_or(path);
1085        // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
1086        // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
1087        // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
1088        // have other tails and stay None.
1089        if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
1090            || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
1091            || m == "error_span" || m == "span"
1092            || m == "event"
1093            || m == "new_span"
1094            || m == "record"
1095            || m == "record_follows_from"
1096            || m == "enter"
1097            || m == "exit"
1098            || m == "in_scope"
1099            || m == "entered"
1100            || path.contains("::__macro_support")
1101            || path.contains("::__tracing")
1102            || path.contains("Subscriber::event")
1103            || path.contains("Subscriber::new_span")
1104            || path.contains("Subscriber::enter")
1105            || path.contains("Subscriber::exit")
1106        {
1107            return Some("Log");
1108        }
1109        return None;
1110    }
1111    // The `log` facade: its macros route through `log::__private_api`; the crate's types
1112    // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
1113    if crate_name == "log" {
1114        // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
1115        // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
1116        let m = path.rsplit("::").next().unwrap_or(path);
1117        if path.contains("::__private_api")
1118            || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
1119        {
1120            return Some("Log");
1121        }
1122    }
1123    // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
1124    // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
1125    // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
1126    // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
1127    // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
1128    if crate_name == "rustc_lint"
1129        && (path.ends_with("::emit_span_lint")
1130            || path.ends_with("::span_lint")
1131            || path.ends_with("::span_lint_hir"))
1132    {
1133        return Some("Log");
1134    }
1135    if crate_name == "rustc_errors"
1136        && (path.ends_with("::emit")
1137            || path.ends_with("::emit_diagnostic")
1138            || path.ends_with("::emit_now"))
1139    {
1140        return Some("Log");
1141    }
1142    // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
1143    // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
1144    // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
1145    // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
1146    // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
1147    // connection to the clipboard server, so it's an effect too; `get`/`set` return the
1148    // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
1149    if crate_name == "arboard" {
1150        let m = path.rsplit("::").next().unwrap_or(path);
1151        if m == "new"
1152            || m == "get"
1153            || m == "set"
1154            || m == "clear"
1155            || m == "get_text"
1156            || m == "set_text"
1157            || m == "set_html"
1158            || m == "get_image"
1159            || m == "set_image"
1160            || m == "text"
1161            || m == "image"
1162            || m == "html"
1163        {
1164            return Some("Clipboard");
1165        }
1166        return None;
1167    }
1168    // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
1169    //    notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
1170
1171    // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
1172    // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
1173    // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
1174    // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
1175    if crate_name == "etcetera" {
1176        let m = path.rsplit("::").next().unwrap_or(path);
1177        if m == "home_dir"
1178            || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
1179            || m == "config_dir" || m == "data_dir" || m == "cache_dir"
1180            || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
1181        {
1182            return Some("Env");
1183        }
1184        return None;
1185    }
1186    // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
1187    // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
1188    // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
1189    // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
1190    if crate_name == "sqlx_core" {
1191        if path.ends_with("::connect") || path.ends_with("::connect_with") {
1192            return Some("Net");
1193        }
1194        if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
1195            || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
1196            || path.ends_with("::execute") || path.ends_with("::execute_many")
1197            || path.ends_with("::prepare") || path.ends_with("::prepare_with")
1198            || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
1199        {
1200            return Some("Db");
1201        }
1202        return None;
1203    }
1204    // `walkdir` — recursive directory traversal. The disk read (`read_dir` + `stat`) happens lazily in
1205    // `IntoIter::next` (driving the iterator), and `DirEntry::metadata` issues a `stat`. The
1206    // `WalkDir::new`/`max_depth`/`follow_links`/`sort_by` BUILDERS, `WalkDir::into_iter` (constructs the
1207    // iterator, no I/O until pulled), and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
1208    // accessors (`file_type` makes NO syscall) are PURE. (Companion to the already-modeled `ignore`.)
1209    if crate_name == "walkdir" {
1210        if path.ends_with("::IntoIter::next") || path.ends_with("::DirEntry::metadata") {
1211            return Some("Fs");
1212        }
1213        return None;
1214    }
1215    // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
1216    // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
1217    // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
1218    // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
1219    if crate_name == "filetime" {
1220        if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
1221            || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
1222            || path.ends_with("::set_file_handle_times")
1223        {
1224            return Some("Fs");
1225        }
1226        if path.ends_with("::FileTime::now") {
1227            return Some("Clock");
1228        }
1229        return None;
1230    }
1231    // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
1232    // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
1233    // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
1234    if crate_name == "execute" {
1235        if path.contains("::execute") {
1236            return Some("Exec");
1237        }
1238        return None;
1239    }
1240    // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
1241    // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
1242    // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
1243    if crate_name == "ctrlc" {
1244        if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
1245            return Some("Ipc");
1246        }
1247        return None;
1248    }
1249    // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
1250    // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
1251    // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
1252    // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
1253    // read argv) so they stay pure too. (`Arg::env` reads an env var at builder time but bare `::env` is
1254    // too generic to gate safely, so it's left unmodeled — under-report over fabrication.)
1255    if crate_name == "clap" {
1256        if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
1257            || path.ends_with("::try_get_matches")
1258            || path.ends_with("::parse") || path.ends_with("::try_parse")
1259        {
1260            return Some("Env");
1261        }
1262        return None;
1263    }
1264    // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
1265    // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
1266    // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
1267    // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
1268    if crate_name == "jiff" {
1269        if path.ends_with("::now") || path.ends_with("::now_with") {
1270            return Some("Clock");
1271        }
1272        if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
1273            || path.ends_with("::TimeZoneDatabase::get")
1274        {
1275            return Some("Fs");
1276        }
1277        return None;
1278    }
1279    // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
1280    // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
1281    // `Builder::new`/`build` and the format/filter/target config setters are PURE.
1282    if crate_name == "env_logger" {
1283        if path.ends_with("::init") || path.ends_with("::try_init")
1284            || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
1285        {
1286            return Some("Log");
1287        }
1288        return None;
1289    }
1290    // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
1291    // console dialogue with the user — Ipc, like the other local-channel effects). The
1292    // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
1293    if crate_name == "dialoguer" {
1294        if path.ends_with("::interact") || path.ends_with("::interact_on")
1295            || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
1296            || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
1297        {
1298            return Some("Ipc");
1299        }
1300        return None;
1301    }
1302    // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
1303    // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
1304    // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
1305    // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
1306    if crate_name == "console" {
1307        if path.ends_with("::write_line") || path.ends_with("::read_line")
1308            || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
1309            || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
1310            || path.ends_with("::read_secure_line")
1311        {
1312            return Some("Ipc");
1313        }
1314        if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
1315            || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
1316        {
1317            return Some("Env");
1318        }
1319        return None;
1320    }
1321    // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
1322    // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
1323    if crate_name == "terminal_colorsaurus" {
1324        if path.ends_with("::background_color") || path.ends_with("::foreground_color")
1325            || path.ends_with("::color_palette") || path.ends_with("::theme_mode")
1326        {
1327            return Some("Ipc");
1328        }
1329        return None;
1330    }
1331    // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
1332    // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
1333    // effects are out of scope here — we model only backoff's own Clock effect.)
1334    if crate_name == "backoff" {
1335        if path.ends_with("::retry") || path.ends_with("::retry_notify") {
1336            return Some("Clock");
1337        }
1338        return None;
1339    }
1340    // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
1341    // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
1342    if crate_name == "lscolors" {
1343        if path.ends_with("::from_env") {
1344            return Some("Env");
1345        }
1346        return None;
1347    }
1348    // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
1349    if crate_name == "wild" {
1350        if path.ends_with("::args") || path.ends_with("::args_os") {
1351            return Some("Env");
1352        }
1353        return None;
1354    }
1355    // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
1356    // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
1357    // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
1358    // they read no data; flagging them would be an inconsistent over-report.
1359    if crate_name == "grep_cli" {
1360        if path.ends_with("::build") {
1361            return Some("Exec");
1362        }
1363        return None;
1364    }
1365    // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
1366    // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
1367    // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
1368    // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
1369    if crate_name == "clircle" {
1370        if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
1371            return Some("Fs");
1372        }
1373        return None;
1374    }
1375    None
1376}
1377
1378pub fn cap_from_name(name: &str) -> Option<&'static str> {
1379    EFFECTS.iter().copied().find(|e| *e == name)
1380}
1381
1382/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
1383/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
1384/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
1385/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
1386/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
1387/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
1388/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
1389/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
1390/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
1391/// the one boundary every engine hits — refines identically (the §4-consistency argument).
1392pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
1393    // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
1394    // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
1395    // the under-report rule forbids it, so such heads keep the bare cliff.
1396    match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
1397        "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
1398        "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
1399        // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
1400        "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
1401        | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
1402        _ => &[],
1403    }
1404}
1405
1406/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
1407/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
1408/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
1409/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
1410/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
1411/// method is the call path's last segment.
1412pub fn is_cmd_builder_method(method: &str) -> bool {
1413    matches!(
1414        method,
1415        "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
1416            | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
1417            | "groups" | "process_group"
1418    )
1419}
1420
1421/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
1422/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
1423/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
1424/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
1425/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
1426/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
1427/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
1428/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
1429/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
1430pub fn is_cmd_naming_method(method: &str) -> bool {
1431    matches!(method, "new" | "cmd")
1432}
1433
1434/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
1435/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
1436/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
1437/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
1438/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
1439/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
1440/// host was fixed at `connect`) never false-positives. Under-catching an unusual establishing verb is a
1441/// missed mask (sound-with-disclosure), never a broken gate. The arg is the method (path's last segment).
1442pub fn is_net_establishing(method: &str) -> bool {
1443    matches!(
1444        method,
1445        "connect"
1446            | "connect_timeout"
1447            | "get"
1448            | "post"
1449            | "put"
1450            | "patch"
1451            | "delete"
1452            | "head"
1453            | "request"
1454            | "send_to"
1455            | "lookup_host"
1456            | "to_socket_addrs"
1457    )
1458}
1459
1460/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
1461/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
1462/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
1463/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
1464/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
1465/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
1466/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
1467/// is not here, so a missing literal there never false-positives. Under-catching an unusual
1468/// path-naming fn is a missed mask (sound-with-disclosure), never a broken gate. The arg is the
1469/// method/fn leaf (the path's last segment).
1470pub fn is_fs_path_arg(leaf: &str) -> bool {
1471    matches!(
1472        leaf,
1473        // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
1474        "write"
1475            | "read"
1476            | "read_to_string"
1477            | "read_dir"
1478            | "read_link"
1479            | "copy"
1480            | "rename"
1481            | "remove_file"
1482            | "remove_dir"
1483            | "remove_dir_all"
1484            | "create_dir"
1485            | "create_dir_all"
1486            | "hard_link"
1487            | "soft_link"
1488            | "symlink"
1489            | "symlink_file"
1490            | "symlink_dir"
1491            | "symlink_metadata"
1492            | "canonicalize"
1493            | "metadata"
1494            | "set_permissions"
1495            | "exists"
1496            | "try_exists"
1497            // File / OpenOptions constructors taking a path argument
1498            | "open"
1499            | "create"
1500            | "create_new"
1501    )
1502}
1503
1504/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
1505/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
1506/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
1507/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
1508/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
1509/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
1510/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
1511/// structurally (never a maskable string literal) so a missing literal must not false-positive.
1512/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), never a broken gate.
1513/// The arg is the method leaf (the path's last segment).
1514pub fn is_db_query_arg(leaf: &str) -> bool {
1515    matches!(
1516        leaf,
1517        "execute"
1518            | "execute_batch"
1519            | "execute_unprepared"
1520            | "batch_execute"
1521            | "simple_query"
1522            | "query"
1523            | "query_one"
1524            | "query_opt"
1525            | "query_raw"
1526            | "query_row"
1527            | "query_map"
1528            | "query_and_then"
1529            | "query_typed"
1530            | "query_all"
1531            | "prepare"
1532            | "prepare_typed"
1533            | "prepare_cached"
1534            | "exec"
1535            | "exec_first"
1536            | "exec_iter"
1537            | "exec_map"
1538            | "exec_fold"
1539            | "exec_drop"
1540            | "exec_batch"
1541            | "prep"
1542            | "run_command"
1543    )
1544}
1545
1546/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
1547/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
1548/// treats it as a declared capability, exactly like its own `&Fs` token.
1549pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
1550    if !crate_name.starts_with("cap_") {
1551        return None;
1552    }
1553    Some(match type_name {
1554        "Dir" => "Fs",
1555        "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
1556        "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
1557        "SystemClock" | "MonotonicClock" => "Clock",
1558        _ => return None,
1559    })
1560}
1561
1562/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
1563/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
1564/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
1565/// open with a SQL statement keyword, and only identifiers in table position are taken —
1566/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
1567/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
1568/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
1569/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
1570/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
1571/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
1572/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
1573pub fn tables_in_sql(sql: &str) -> Vec<String> {
1574    const STMT: &[&str] =
1575        &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
1576    // Tokens that can FOLLOW a table-introducing keyword without being a table.
1577    const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
1578    // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
1579    const STOP: &[&str] = &[
1580        "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
1581        "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
1582        "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
1583        "from", "join", "into", "update", "delete", "insert",
1584    ];
1585    // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
1586    // list without fabricating from other comma-ridden positions (column lists, ON clauses).
1587    let cleaned: String = sql
1588        .to_lowercase()
1589        .chars()
1590        .flat_map(|c| match c {
1591            '(' | ')' | ';' => vec![' '],
1592            ',' => vec![' ', ',', ' '],
1593            _ => vec![c],
1594        })
1595        .collect();
1596    let toks: Vec<&str> = cleaned.split_whitespace().collect();
1597    let Some(first) = toks.first() else { return Vec::new() };
1598    if !STMT.contains(first) {
1599        return Vec::new(); // not SQL — nothing to certify, nothing fabricated
1600    }
1601    let ident = |t: &str| -> Option<String> {
1602        let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
1603        let mut chars = t.chars();
1604        let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
1605        let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
1606        (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
1607    };
1608    let mut out: Vec<String> = Vec::new();
1609    let mut push = |t: Option<String>| {
1610        if let Some(t) = t {
1611            if !out.contains(&t) {
1612                out.push(t);
1613            }
1614        }
1615    };
1616    for (i, tok) in toks.iter().enumerate() {
1617        let table_pos = match *tok {
1618            "from" | "join" | "into" | "table" => true,
1619            // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
1620            "update" | "truncate" => i == 0,
1621            _ => false,
1622        };
1623        if !table_pos {
1624            continue;
1625        }
1626        let mut j = i + 1;
1627        while j < toks.len() && SKIP.contains(&toks[j]) {
1628            j += 1;
1629        }
1630        let Some(next) = toks.get(j) else { continue };
1631        let Some(first) = ident(next) else { continue };
1632        push(Some(first));
1633        // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
1634        // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
1635        // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
1636        // list, whose parens are spaces by the time we tokenize).
1637        while j + 2 < toks.len() && toks[j + 1] == "," {
1638            let Some(more) = ident(toks[j + 2]) else { break };
1639            push(Some(more));
1640            j += 2;
1641        }
1642    }
1643    out
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648    #[test]
1649    fn sql_table_extraction_is_conservative() {
1650        use super::tables_in_sql as t;
1651        assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
1652        assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
1653                   vec!["ledger.entries", "customers"]);
1654        assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
1655        assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
1656        assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
1657        assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
1658        assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
1659        // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
1660        assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
1661        // a subquery in FROM position yields nothing for that position
1662        assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
1663        // not SQL -> nothing (never fabricate)
1664        assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
1665        assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
1666        // comma-ADJACENT continuation: a FROM list takes every table in the chain…
1667        assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
1668        // …but an alias breaks it (under-report, never a guess)…
1669        assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
1670        // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
1671        assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
1672        // a subquery after the comma stops the chain too
1673        assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
1674    }
1675
1676    use super::*;
1677
1678    #[test]
1679    fn db_crates_are_calibrated() {
1680        // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
1681        // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
1682        for c in DB_CRATES {
1683            assert!(
1684                CALIBRATED_CRATES.contains(&c),
1685                "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
1686            );
1687        }
1688    }
1689
1690    #[test]
1691    fn calibrated_crates_are_live() {
1692        // Conversely, every crate advertised as calibrated must actually be matched by classify() for
1693        // some representative path — a dead entry would silently suppress a real coverage warning.
1694        for c in CALIBRATED_CRATES {
1695            assert!(
1696                CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
1697                "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
1698            );
1699        }
1700    }
1701
1702    #[test]
1703    fn async_http_stack_classifies() {
1704        // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
1705        // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
1706        assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
1707        assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
1708        assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
1709        assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
1710        assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
1711        assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
1712        assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
1713        assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
1714        assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
1715        assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
1716        assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
1717        assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
1718        // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
1719        assert_eq!(classify("num_cpus", "num_cpus::get"), None);
1720        assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
1721        // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
1722        assert_eq!(classify("hyper", "hyper::Request::builder"), None);
1723        assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
1724        assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
1725        assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
1726    }
1727
1728    #[test]
1729    fn coverage_differential_crates_classify() {
1730        // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
1731        // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
1732        // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
1733
1734        // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
1735        assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
1736        assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
1737        assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
1738        assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
1739        assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
1740
1741        // native-tls variants — handshake is Net; builder is pure.
1742        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
1743        assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
1744        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
1745
1746        // etcetera — dir resolution reads env; the args data type is pure.
1747        assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
1748        assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
1749        assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
1750        assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
1751
1752        // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
1753        assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
1754        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
1755        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
1756        assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
1757        assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
1758
1759        // walkdir — the lazy read happens in next()/metadata(); builders + cached accessors pure.
1760        assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
1761        assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
1762        assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), None); // builder
1763        assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
1764        assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
1765
1766        // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
1767        assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
1768        assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
1769        assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
1770        assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
1771        assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
1772
1773        // execute — the execute* verbs spawn (Exec); command/shell builders pure.
1774        assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
1775        assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
1776        assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
1777        assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
1778        assert_eq!(classify("execute", "execute::shell"), None);
1779
1780        // ctrlc — install signal handler (Ipc).
1781        assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
1782        assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
1783
1784        // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
1785        assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
1786        assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
1787        assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
1788        assert_eq!(classify("clap", "clap::Command::new"), None); // builder
1789        assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
1790        assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
1791
1792        // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
1793        assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
1794        assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
1795        assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
1796        assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
1797        assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
1798
1799        // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
1800        assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
1801        assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
1802        assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
1803        assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
1804        assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
1805
1806        // dialoguer — interact* is tty I/O (Ipc); builders pure.
1807        assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
1808        assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
1809        assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
1810        assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
1811
1812        // console — Term I/O is Ipc, detection is Env, Style is pure.
1813        assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
1814        assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
1815        assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
1816        assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
1817        assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
1818
1819        // terminal_colorsaurus — tty colour query (Ipc).
1820        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
1821        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
1822
1823        // backoff — retry sleeps + reads the clock (Clock); config pure.
1824        assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
1825        assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
1826        assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
1827
1828        // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
1829        assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
1830        assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
1831        assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
1832
1833        // wild — argv readers (Env).
1834        assert_eq!(classify("wild", "wild::args"), Some("Env"));
1835        assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
1836
1837        // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
1838        assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
1839        assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
1840        assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
1841
1842        // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
1843        assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
1844        assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
1845    }
1846
1847    #[test]
1848    fn log_tracing_emit_macros_classify_pre_expansion() {
1849        // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
1850        // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
1851        assert_eq!(classify("log", "log::info"), Some("Log"));
1852        assert_eq!(classify("log", "log::error"), Some("Log"));
1853        assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
1854        assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
1855        // pure data-type surface stays None (no fabricated Log)
1856        assert_eq!(classify("log", "log::Level::as_str"), None);
1857        assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
1858    }
1859
1860    #[test]
1861    fn classify_core_effects() {
1862        // A representative smoke test of the classifier's main families, so the published crate is not
1863        // shipped untested (these used to live only in the nightly-only src/lib.rs).
1864        assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
1865        // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
1866        // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
1867        assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
1868        assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
1869        assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
1870        assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
1871        assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
1872        assert_eq!(classify("std", "std::path::Path::parent"), None);
1873        assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
1874        assert_eq!(classify("std", "std::env::var"), Some("Env"));
1875        assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
1876        // one-shot convenience fns send immediately → Net; the `Client::get` builder stays pure.
1877        assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
1878        assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
1879        assert_eq!(classify("reqwest", "reqwest::Client::get"), None);
1880        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
1881        // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
1882        assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
1883        assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
1884        assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
1885        assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
1886        assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
1887        // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
1888        assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
1889        assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
1890        assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
1891        assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
1892        // pnet raw packet capture: channel openers are Net, packet construction stays pure.
1893        assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
1894        assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
1895        assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
1896        assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
1897        assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
1898        // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
1899        assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
1900        assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
1901        assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
1902        assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
1903        assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
1904        assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
1905        assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
1906        // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
1907        assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
1908        assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
1909        assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
1910        assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
1911        assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
1912        assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
1913        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
1914        // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
1915        assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
1916        assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
1917        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
1918        assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
1919        assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
1920        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
1921        // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
1922        assert_eq!(classify("postgres", "postgres::Client::open"), None);
1923        assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
1924        // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
1925        assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
1926        assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
1927        assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
1928        // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
1929        assert_eq!(classify("sqlx", "sqlx::query"), None);
1930        // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
1931        // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
1932        // pure, same principle as the `log` facade).
1933        assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
1934        assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
1935        assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
1936        assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
1937        assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
1938        assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
1939        assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
1940        assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
1941        assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
1942        // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
1943        // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
1944        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
1945        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
1946        assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
1947        assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
1948        assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
1949        assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
1950        assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
1951        assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
1952        // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
1953        // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
1954        assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
1955        assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
1956        assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
1957        assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
1958        assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
1959        assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
1960        assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
1961        // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
1962        // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
1963        assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
1964        assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
1965        assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
1966        assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
1967        assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
1968        assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
1969        assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
1970        // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
1971        // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
1972        assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
1973        assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
1974        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
1975        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
1976        assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
1977        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
1978        assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
1979        assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
1980        assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
1981        assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
1982        // FFI tiers (matched by distinctive leaf, alias-independent)
1983        assert_eq!(classify("libc", "libc::open"), Some("Fs"));
1984        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
1985        assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
1986        assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
1987        assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
1988        // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
1989        // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
1990        // `src/build.rs` module was being dropped as if it were the Cargo build script).
1991        assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
1992        assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
1993        assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
1994        assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
1995        // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
1996        // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
1997        // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
1998        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
1999        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
2000        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
2001        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
2002        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
2003        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
2004        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
2005        // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
2006        assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
2007        assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
2008        assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
2009        assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
2010        assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
2011        assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
2012        // pure crates stay pure
2013        assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
2014        assert_eq!(classify("std", "std::vec::Vec::push"), None);
2015
2016        // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
2017        // [24] std::net socket accessors are pure; the I/O verbs stay Net.
2018        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2019        assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
2020        assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
2021        assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
2022        assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
2023        // [37] std DNS resolution is Net (was floored).
2024        assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
2025        assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
2026        // [23] std::process getters are pure; spawn/new stay Exec.
2027        assert_eq!(classify("std", "std::process::Command::get_program"), None);
2028        assert_eq!(classify("std", "std::process::Command::get_args"), None);
2029        assert_eq!(classify("std", "std::process::Child::id"), None);
2030        assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
2031        // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
2032        assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
2033        assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
2034        // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
2035        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
2036        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
2037        assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
2038    }
2039
2040    #[test]
2041    fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
2042        // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
2043        // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
2044        assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
2045        assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
2046        assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
2047        assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
2048        assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
2049        assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
2050    }
2051
2052    #[test]
2053    fn redis_connection_manager_config_builder_is_pure() {
2054        // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
2055        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
2056        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
2057        // the LIVE manager still round-trips (Db).
2058        assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
2059        assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
2060    }
2061
2062    #[test]
2063    fn pure_fd_transfer_is_not_an_effect() {
2064        // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
2065        // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
2066        // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
2067        // `as_raw_fd` all fabricated effects.)
2068        assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
2069        assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
2070        assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
2071        assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
2072        assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
2073        assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
2074        assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
2075        assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
2076        assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
2077        // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
2078        assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
2079        assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
2080        assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
2081        assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
2082        // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
2083        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2084        assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
2085        assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
2086        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
2087        assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
2088    }
2089
2090    #[test]
2091    fn command_head_refines_the_exec_cliff() {
2092        use super::classify_command_head as h;
2093        // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
2094        assert_eq!(h("curl"), &["Net"]);
2095        assert_eq!(h("telnet"), &["Net"]);
2096        assert_eq!(h("sftp"), &["Net"]);
2097        assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
2098        assert_eq!(h("mongo"), &["Db"]);
2099        assert_eq!(h("cqlsh"), &["Db"]);
2100        // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
2101        assert_eq!(h("candor-scan"), &["Env", "Fs"]);
2102        assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
2103        // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
2104        // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
2105        // cliff rather than fabricate an effect for the common case.
2106        assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
2107        assert_eq!(h("make"), &[] as &[&str]);
2108        assert_eq!(h("npm"), &[] as &[&str]);
2109        assert_eq!(h("git"), &[] as &[&str]);
2110        assert_eq!(h("rsync"), &[] as &[&str]);
2111        // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
2112        // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
2113        assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
2114        assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
2115        assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
2116        // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
2117        // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
2118        // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
2119        // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
2120        // name a program, so only they may refine.
2121        assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
2122        assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
2123        assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
2124    }
2125
2126    #[test]
2127    fn net_establishing_allowlist() {
2128        // sweep [3]/[7]: the masking guard's establishing-verb allowlist — host-bearing connect/request
2129        // verbs establish (a runtime host there is invisible); USE-verbs on a connected socket do NOT.
2130        assert!(is_net_establishing("connect") && is_net_establishing("connect_timeout"));
2131        assert!(is_net_establishing("get") && is_net_establishing("post") && is_net_establishing("request"));
2132        assert!(is_net_establishing("send_to") && is_net_establishing("to_socket_addrs"));
2133        // use-verbs (host fixed at connect) must NOT be establishing — else `connect("h").write()` flags.
2134        assert!(!is_net_establishing("write") && !is_net_establishing("read") && !is_net_establishing("send"));
2135        assert!(!is_net_establishing("flush") && !is_net_establishing("recv") && !is_net_establishing("peek"));
2136    }
2137
2138    #[test]
2139    fn fs_path_arg_allowlist() {
2140        // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
2141        // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
2142        // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
2143        // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
2144        assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
2145        assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
2146        assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
2147        assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
2148        // handle ops / pure builders take NO path arg — never path-naming.
2149        assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
2150        assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
2151    }
2152
2153    #[test]
2154    fn db_query_arg_allowlist() {
2155        // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
2156        // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
2157        // ops carry no SQL string and must NOT flag.
2158        assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
2159        assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
2160        assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
2161        // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
2162        assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
2163        assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
2164        // connection / lifecycle ops take no SQL — must NOT flag.
2165        assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
2166        assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
2167    }
2168}