Skip to main content

keel_cli/scan/
js.rs

1//! The JS/TS static scan — a real parse on [oxc](https://oxc.rs).
2//!
3//! oxc is pure Rust, so `keel init` still needs no Node toolchain (the design
4//! constraint the old line-oriented scan existed to satisfy — this replaces
5//! that documented simplification with an actual AST walk, dx-spec §2). Per
6//! file, [`ast`] extracts:
7//!
8//! - HTTP in use: a `fetch(…)` call, or an import/require/dynamic-import of a
9//!   known outbound client (`undici`, `node:http`/`https`, `axios`, `got`,
10//!   `node-fetch`, `superagent`, DB clients like `pg`/`redis`) — including
11//!   multi-line and aliased forms the old regex missed.
12//! - provider SDKs: `openai`, `@anthropic-ai/sdk` (also bare `anthropic`),
13//!   AI-SDK provider packages → `llm:*` targets. TS `import type` is excluded:
14//!   type-only imports are erased at runtime and are not evidence.
15//! - URL literals: hosts from string literals *and* template-literal quasis
16//!   (`` `https://api.x.com/${id}` `` resolves; `` `${scheme}://x` `` no
17//!   longer false-positives).
18//! - effect call sites with enclosing-function attribution
19//!   ([`super::CallSite`]) for `keel flows suggest`, and a relative-import
20//!   module graph ([`JsScan::imports`]).
21//!
22//! The tradeoff (accepted here): a URL built by concatenation, or an exotic
23//! import form, is missed — exactly the ~20% the import-time and observed-run
24//! evidence sources exist to catch (dx-spec §2). Line numbers are exact.
25//!
26//! ## Function attribution (for `keel flows suggest`)
27//!
28//! [`ast::ScanVisitor`] tracks a real enclosing-scope stack, so attribution
29//! is exact scope containment, not a line heuristic. A [`super::FunctionFacts`]
30//! entry opens for a **function bound directly at module top level**: a
31//! function declaration (`function f(…) {`, `export async function f(…) {`),
32//! a named function expression, or an arrow assigned directly to a top-level
33//! `const`/`let`/`var` (`const f = () => {…}`, `const f = function () {…}`).
34//! Everything lexically nested inside it — inner arrows, callbacks, helper
35//! closures — attributes to that top-level entry, exactly like the Python
36//! pass's real `ast` containment (`scan::python`).
37//!
38//! Class methods and object-literal methods do **not** open their own entry
39//! — even though the scope walk names them (`Class.method` shows up in
40//! [`super::CallSite::function`] for call-site evidence), a class body is not
41//! a flow entrypoint any more than a Python `class` body is. A `fetch` inside
42//! `class Api { async load() {…} }` is evidenced in the file-level scan (host
43//! literals, call sites) but not credited to a function. The same holds for
44//! anonymous top-level values (`export default function () {…}`): still
45//! evidenced, never a flow candidate. This is strictly more precise than the
46//! old line-heuristic scan it replaces, which additionally missed Allman-brace
47//! functions and desynced on template-literal interpolation.
48//!
49//! A file that fails to parse is warned about on stderr and skipped — never a
50//! crash, and never silent narrowing (mirrors the Python pass's
51//! parse-or-skip). `files_scanned` counts parsed files only.
52
53mod ast;
54
55use std::collections::{BTreeMap, BTreeSet};
56use std::path::Path;
57
58use super::{FunctionFacts, LangFindings, TransportClass, collect_files};
59
60/// Extensions the scan reads.
61pub(crate) const JS_EXTS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "jsx", "tsx"];
62
63/// Extensions tried, in order, when resolving an extensionless relative
64/// import (`./x` → `x.ts`, …, then `x/index.ts`, …).
65const RESOLVE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
66
67/// The JS pass result.
68#[derive(Debug, Clone, Default)]
69pub struct JsScan {
70    /// Files parsed (a file that failed to parse is not counted).
71    pub files_scanned: usize,
72    /// Project-relative paths that failed to parse (warned on stderr, skipped).
73    pub parse_failures: Vec<String>,
74    /// Module graph: file → project-local files it imports. Relative
75    /// specifiers only, resolved against the scanned file set (exact path,
76    /// then per-extension, then `…/index.<ext>`).
77    pub imports: BTreeMap<String, BTreeSet<String>>,
78    /// Findings, ready to merge.
79    pub findings: LangFindings,
80    /// Per-function attribution (top-level named functions only — real AST
81    /// scope containment, see the module docs for the exact policy).
82    pub functions: Vec<FunctionFacts>,
83}
84
85/// Scan `project` for JS/TS effect seams.
86pub fn scan(project: &Path) -> JsScan {
87    let mut files = Vec::new();
88    collect_files(project, JS_EXTS, &mut files);
89    files.sort();
90
91    let rels: Vec<String> = files.iter().map(|p| relative(project, p)).collect();
92    let known: BTreeSet<&str> = rels.iter().map(String::as_str).collect();
93
94    let mut result = JsScan::default();
95    for (path, rel) in files.iter().zip(&rels) {
96        let Ok(src) = std::fs::read_to_string(path) else {
97            continue;
98        };
99        if let Some(extras) = ast::scan_source(&src, rel, &mut result.findings) {
100            result.files_scanned += 1;
101            result.functions.extend(extras.functions);
102            let resolved: BTreeSet<String> = extras
103                .relative_imports
104                .iter()
105                .filter_map(|spec| resolve_relative(rel, spec, &known))
106                .collect();
107            if !resolved.is_empty() {
108                result.imports.insert(rel.clone(), resolved);
109            }
110        } else {
111            eprintln!("keel: warning: skipped {rel}: JS/TS parse failed");
112            result.parse_failures.push(rel.clone());
113        }
114    }
115    // Transport classification, project-scan-wide (not per-file): `http_in_use`
116    // is only known once every file has been visited, since a URL literal and
117    // the fetch/undici/http import that reaches it can live in different
118    // files — the same coarse granularity `merge_lang` already uses to gate
119    // `hosts` into `targets` (see the module docs). Every JS-sighted host is
120    // `Tracked` when the scan saw an HTTP-capable import or `fetch` call
121    // anywhere, else `Unknown` — JS URLs are only emitted alongside that
122    // evidence today, so this blanket split is faithful without needing
123    // per-file reach the walker's per-file passes don't do here.
124    let class = if result.findings.http_in_use {
125        TransportClass::Tracked
126    } else {
127        TransportClass::Unknown
128    };
129    for (host, _) in &result.findings.hosts {
130        result
131            .findings
132            .host_transports
133            .entry(host.clone())
134            .or_insert(class);
135    }
136    result
137}
138
139/// Resolve a relative import specifier against the scanned file set:
140/// exact path, then `<spec>.<ext>`, then `<spec>/index.<ext>`.
141fn resolve_relative(importer: &str, spec: &str, known: &BTreeSet<&str>) -> Option<String> {
142    let dir = importer.rsplit_once('/').map_or("", |(d, _)| d);
143    let joined = normalize(dir, spec)?;
144    if known.contains(joined.as_str()) {
145        return Some(joined);
146    }
147    for ext in RESOLVE_EXTS {
148        let candidate = format!("{joined}.{ext}");
149        if known.contains(candidate.as_str()) {
150            return Some(candidate);
151        }
152    }
153    for ext in RESOLVE_EXTS {
154        let candidate = format!("{joined}/index.{ext}");
155        if known.contains(candidate.as_str()) {
156            return Some(candidate);
157        }
158    }
159    None
160}
161
162/// Join `dir` and a `./`/`../` specifier, normalizing `.` and `..` segments.
163/// `None` when `..` escapes the project root.
164fn normalize(dir: &str, spec: &str) -> Option<String> {
165    let mut parts: Vec<&str> = if dir.is_empty() {
166        Vec::new()
167    } else {
168        dir.split('/').collect()
169    };
170    for seg in spec.split('/') {
171        match seg {
172            "" | "." => {}
173            ".." => {
174                parts.pop()?;
175            }
176            other => parts.push(other),
177        }
178    }
179    Some(parts.join("/"))
180}
181
182/// Project-relative path with `/` separators.
183fn relative(project: &Path, path: &Path) -> String {
184    path.strip_prefix(project)
185        .unwrap_or(path)
186        .to_string_lossy()
187        .replace('\\', "/")
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::fs;
194    use tempfile::TempDir;
195
196    /// Parse one in-memory source as `name` and return its findings and
197    /// per-top-level-function attribution.
198    fn scan_str_named(src: &str, name: &str) -> (LangFindings, Vec<FunctionFacts>) {
199        let mut f = LangFindings::default();
200        let extras = ast::scan_source(src, name, &mut f).expect("fixture failed to parse");
201        (f, extras.functions)
202    }
203
204    /// Parse one in-memory source as `name` and return its findings.
205    fn findings_named(src: &str, name: &str) -> LangFindings {
206        scan_str_named(src, name).0
207    }
208
209    fn findings(src: &str) -> LangFindings {
210        findings_named(src, "app.ts")
211    }
212
213    /// Parse one in-memory source as `name` and return its per-top-level-
214    /// function attribution.
215    fn functions_named(src: &str, name: &str) -> Vec<FunctionFacts> {
216        scan_str_named(src, name).1
217    }
218
219    fn functions(src: &str) -> Vec<FunctionFacts> {
220        functions_named(src, "app.ts")
221    }
222
223    // ---- conformance with the old regex scan (same inputs, same findings) ----
224
225    #[test]
226    fn fetch_and_url_literal_are_found() {
227        let f = findings("const r = await fetch(\"https://api.example.com/v1/x\");\n");
228        assert!(f.http_in_use);
229        assert_eq!(f.hosts.len(), 1);
230        assert_eq!(f.hosts[0].0, "api.example.com");
231        assert_eq!(f.hosts[0].1.line, 1);
232        assert!(f.libs.contains("fetch"));
233    }
234
235    #[test]
236    fn provider_imports_map_to_llm_targets() {
237        let f = findings(
238            "import OpenAI from \"openai\";\nimport Anthropic from '@anthropic-ai/sdk';\n",
239        );
240        let providers: Vec<_> = f.llm.iter().map(|(p, _)| p.as_str()).collect();
241        assert!(providers.contains(&"openai"));
242        assert!(providers.contains(&"anthropic"));
243        assert_eq!(f.llm[0].1.line, 1);
244        assert_eq!(f.llm[1].1.line, 2);
245    }
246
247    #[test]
248    fn undici_import_marks_http_in_use() {
249        let f = findings("import { request } from \"undici\";\n");
250        assert!(f.http_in_use);
251        assert!(f.libs.contains("undici"));
252    }
253
254    #[test]
255    fn word_named_openai_variable_is_not_an_import() {
256        let f = findings("const openai = 3;\n");
257        assert!(f.llm.is_empty());
258    }
259
260    #[test]
261    fn multiple_hosts_on_one_line() {
262        let f = findings("fetch(1);\nx(\"https://a.example.com\", \"https://b.example.com/p\");\n");
263        let hosts: Vec<_> = f.hosts.iter().map(|(h, _)| h.as_str()).collect();
264        assert_eq!(hosts, ["a.example.com", "b.example.com"]);
265        assert_eq!(f.hosts[0].1.line, 2);
266    }
267
268    #[test]
269    fn member_fetch_still_counts() {
270        // The old scan accepted `.fetch(` — keep that looseness: it is how
271        // `globalThis.fetch(…)` and `this.fetch(…)` appear.
272        let f = findings("globalThis.fetch(\"https://api.example.com\");\n");
273        assert!(f.http_in_use);
274        assert!(f.libs.contains("fetch"));
275    }
276
277    // ---- cases the regex provably got wrong, now correct ----
278
279    #[test]
280    fn multi_line_import_is_found() {
281        // The specifier and the `import` keyword sit on different lines: the
282        // line-oriented scan missed this entirely.
283        let f = findings("import {\n  request,\n} from \"undici\";\n");
284        assert!(f.http_in_use);
285        assert!(f.libs.contains("undici"));
286    }
287
288    #[test]
289    fn import_type_is_not_runtime_evidence() {
290        // `import type` is erased by tsc: the regex flagged it as an OpenAI
291        // dependency; the AST walk knows better.
292        let f = findings("import type { ChatModel } from \"openai\";\n");
293        assert!(f.llm.is_empty());
294        assert!(!f.http_in_use);
295    }
296
297    #[test]
298    fn type_only_specifier_is_skipped_but_value_binds() {
299        let f =
300            findings("import { type ClientOptions, request } from \"undici\";\nrequest(\"x\");\n");
301        assert!(f.http_in_use);
302        let callees: Vec<_> = f.call_sites.iter().map(|c| c.callee.as_str()).collect();
303        assert_eq!(callees, ["undici.request"]);
304    }
305
306    #[test]
307    fn template_literal_host_is_found() {
308        let f = findings("const id = 1;\nawait fetch(`https://api.example.com/v1/${id}`);\n");
309        assert_eq!(f.hosts.len(), 1);
310        assert_eq!(f.hosts[0].0, "api.example.com");
311        assert_eq!(f.hosts[0].1.line, 2);
312    }
313
314    #[test]
315    fn interpolated_scheme_is_not_a_false_positive_host() {
316        // The regex walked back from `://` across the `}` and reported
317        // `internal` as a host. The quasi has no scheme, so no host.
318        let f = findings("const scheme = \"https\";\nconst u = `${scheme}://internal`;\n");
319        assert!(f.hosts.is_empty());
320    }
321
322    #[test]
323    fn require_and_dynamic_import_are_imports() {
324        let cjs = findings_named(
325            "const { request } = require(\"undici\");\nrequest(\"https://api.example.com\");\n",
326            "app.cjs",
327        );
328        assert!(cjs.http_in_use);
329        assert!(cjs.libs.contains("undici"));
330        assert_eq!(cjs.call_sites[0].callee, "undici.request");
331
332        let dynamic = findings("const undici = await import(\"undici\");\n");
333        assert!(dynamic.http_in_use);
334        assert!(dynamic.libs.contains("undici"));
335    }
336
337    #[test]
338    fn subpath_import_classifies_by_package() {
339        let f = findings("import { toFile } from \"openai/uploads\";\n");
340        assert_eq!(f.llm.len(), 1);
341        assert_eq!(f.llm[0].0, "openai");
342    }
343
344    // ---- new evidence the regex never had ----
345
346    #[test]
347    fn effect_lib_imports_gate_hosts() {
348        // A pg import plus a DSN literal yields the DB host (the Python pass
349        // resolves the same shape via psycopg + DSN).
350        let f = findings(
351            "import { Client } from \"pg\";\nconst DSN = \"postgres://db.internal:5432/app\";\n",
352        );
353        assert!(f.http_in_use);
354        assert!(f.libs.contains("pg"));
355        assert_eq!(f.hosts[0].0, "db.internal");
356    }
357
358    #[test]
359    fn resilience_lib_imports_are_detected_separately_from_known_libs() {
360        // Node parity to python.rs's
361        // `resilience_lib_imports_are_detected_separately_from_known_libs`.
362        let f = findings(
363            "import { request } from \"undici\";\nimport pRetry from \"p-retry\";\n\
364             const retry = require(\"async-retry\");\n",
365        );
366        assert_eq!(
367            f.resilience_libs,
368            ["async-retry".to_owned(), "p-retry".to_owned()]
369                .into_iter()
370                .collect()
371        );
372        // Resilience libs never pollute `libs` (which feeds doctor's
373        // "invisible/unadapted effect library" coverage classification —
374        // p-retry/async-retry were never adapter candidates), and they don't
375        // gate `http_in_use` the way a real effect-library import does.
376        assert!(!f.libs.contains("p-retry"));
377        assert!(!f.libs.contains("async-retry"));
378        assert!(f.libs.contains("undici"));
379    }
380
381    #[test]
382    fn axios_default_import_call_sites() {
383        let f = findings(
384            "import axios from \"axios\";\nawait axios.get(\"https://api.example.com\");\n",
385        );
386        assert!(f.http_in_use);
387        assert!(f.libs.contains("axios"));
388        assert_eq!(f.call_sites[0].callee, "axios.get");
389    }
390
391    #[test]
392    fn client_instance_traces_back_to_provider() {
393        let f = findings(
394            "import OpenAI from \"openai\";\nconst client = new OpenAI();\n\
395             export async function ask() {\n  return client.chat.completions.create({});\n}\n",
396        );
397        assert_eq!(f.call_sites.len(), 1);
398        let site = &f.call_sites[0];
399        assert_eq!(site.callee, "openai.chat.completions.create");
400        assert_eq!(site.function.as_deref(), Some("ask"));
401        assert_eq!(site.line, 4);
402    }
403
404    #[test]
405    fn ai_sdk_provider_packages_pin_llm_targets() {
406        let f = findings("import { anthropic } from \"@ai-sdk/anthropic\";\n");
407        assert!(f.libs.contains("ai-sdk"));
408        assert_eq!(f.llm[0].0, "anthropic");
409    }
410
411    #[test]
412    fn mcp_sdk_imports_are_detected_as_the_mcp_lib() {
413        // Issue #17: a pure-Node MCP project imports `@modelcontextprotocol/sdk`,
414        // almost always through a deep subpath (`.../client/index.js`) that
415        // `package_name` must reduce back to the scoped package. Every import
416        // shape must land `"mcp"` in `libs` so doctor's merged python+node `mcp`
417        // registry row shows `detected: true` for a Node-only project. Unlike
418        // the provider SDKs, `mcp` carries no `llm:` provider, so `llm` stays
419        // empty (its target grammar is the per-server `mcp:<server>`).
420        let subpath =
421            findings("import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\n");
422        assert!(subpath.libs.contains("mcp"));
423        assert!(subpath.http_in_use);
424        assert!(subpath.llm.is_empty());
425
426        let bare = findings("import * as mcp from \"@modelcontextprotocol/sdk\";\n");
427        assert!(bare.libs.contains("mcp"));
428
429        let cjs = findings_named(
430            "const { Client } = require(\"@modelcontextprotocol/sdk/client/index.js\");\n",
431            "server.cjs",
432        );
433        assert!(cjs.libs.contains("mcp"));
434    }
435
436    // ---- attribution ----
437
438    #[test]
439    fn attribution_covers_functions_methods_and_arrows() {
440        let f = findings(
441            "class Api {\n  async load() {\n    return fetch(\"https://a.x\");\n  }\n}\n\
442             function outer() {\n  const inner = async () => fetch(\"https://b.x\");\n  return inner;\n}\n\
443             const top = fetch(\"https://c.x\");\n",
444        );
445        let sites: Vec<(&str, Option<&str>)> = f
446            .call_sites
447            .iter()
448            .map(|c| (c.callee.as_str(), c.function.as_deref()))
449            .collect();
450        assert_eq!(
451            sites,
452            [
453                ("fetch", Some("Api.load")),
454                ("fetch", Some("outer.inner")),
455                ("fetch", None),
456            ]
457        );
458    }
459
460    #[test]
461    fn tsx_parses_with_jsx_and_types() {
462        let f = findings_named(
463            "type Props = { url: string };\n\
464             export function Widget({ url }: Props) {\n\
465               const load = () => fetch(\"https://api.example.com\");\n\
466               return <button onClick={load}>go</button>;\n\
467             }\n",
468            "widget.tsx",
469        );
470        assert!(f.http_in_use);
471        assert_eq!(f.hosts[0].0, "api.example.com");
472        assert_eq!(
473            f.call_sites[0].function.as_deref(),
474            Some("Widget.load"),
475            "arrow inside a component attributes to Widget.load"
476        );
477    }
478
479    // ---- pass-level behavior (filesystem) ----
480
481    #[test]
482    fn broken_file_is_skipped_never_fatal() {
483        let dir = TempDir::new().unwrap();
484        fs::write(dir.path().join("broken.ts"), "function (((\n").unwrap();
485        fs::write(dir.path().join("ok.ts"), "import \"undici\";\n").unwrap();
486        let scan = scan(dir.path());
487        assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
488        assert_eq!(scan.parse_failures, ["broken.ts"]);
489        assert!(scan.findings.http_in_use);
490    }
491
492    #[test]
493    fn import_graph_resolves_relative_specifiers() {
494        let dir = TempDir::new().unwrap();
495        fs::create_dir(dir.path().join("lib")).unwrap();
496        fs::write(
497            dir.path().join("app.ts"),
498            "import { helper } from \"./lib/helper\";\nimport { util } from \"./util\";\n\
499             import express from \"express\";\n",
500        )
501        .unwrap();
502        fs::write(dir.path().join("util.ts"), "export const util = 1;\n").unwrap();
503        fs::write(
504            dir.path().join("lib").join("helper.ts"),
505            "import { util } from \"../util\";\nexport const helper = util;\n",
506        )
507        .unwrap();
508        let scan = scan(dir.path());
509        assert_eq!(scan.files_scanned, 3);
510        assert_eq!(
511            scan.imports.get("app.ts"),
512            Some(&BTreeSet::from([
513                "lib/helper.ts".to_owned(),
514                "util.ts".to_owned()
515            ]))
516        );
517        assert_eq!(
518            scan.imports.get("lib/helper.ts"),
519            Some(&BTreeSet::from(["util.ts".to_owned()]))
520        );
521    }
522
523    #[test]
524    fn import_graph_resolves_index_files() {
525        let dir = TempDir::new().unwrap();
526        fs::create_dir(dir.path().join("api")).unwrap();
527        fs::write(dir.path().join("app.js"), "import api from \"./api\";\n").unwrap();
528        fs::write(
529            dir.path().join("api").join("index.js"),
530            "export default 1;\n",
531        )
532        .unwrap();
533        let scan = scan(dir.path());
534        assert_eq!(
535            scan.imports.get("app.js"),
536            Some(&BTreeSet::from(["api/index.js".to_owned()]))
537        );
538    }
539
540    #[test]
541    fn deterministic_across_runs() {
542        let dir = TempDir::new().unwrap();
543        fs::write(
544            dir.path().join("a.ts"),
545            "import { request } from \"undici\";\nrequest(\"https://a.example.com\");\n",
546        )
547        .unwrap();
548        fs::write(
549            dir.path().join("b.ts"),
550            "await fetch(\"https://b.example.com\");\n",
551        )
552        .unwrap();
553        let one = scan(dir.path());
554        let two = scan(dir.path());
555        assert_eq!(format!("{:?}", one.findings), format!("{:?}", two.findings));
556        assert_eq!(one.imports, two.imports);
557    }
558
559    #[test]
560    fn hosts_are_tracked_when_the_scan_saw_http_evidence_anywhere() {
561        let dir = TempDir::new().unwrap();
562        fs::write(
563            dir.path().join("app.ts"),
564            "await fetch(\"https://api.example.com/v1\");\n",
565        )
566        .unwrap();
567        let scan = scan(dir.path());
568        assert_eq!(
569            scan.findings.host_transports.get("api.example.com"),
570            Some(&TransportClass::Tracked)
571        );
572    }
573
574    #[test]
575    fn hosts_are_unknown_transport_without_http_evidence_anywhere() {
576        let dir = TempDir::new().unwrap();
577        // A bare URL literal, no fetch/undici/http import anywhere in the scan.
578        fs::write(
579            dir.path().join("app.ts"),
580            "const url = \"https://api.mystery.com/v1\";\n",
581        )
582        .unwrap();
583        let scan = scan(dir.path());
584        assert_eq!(
585            scan.findings.host_transports.get("api.mystery.com"),
586            Some(&TransportClass::Unknown)
587        );
588    }
589
590    // ---- function attribution (real AST scope containment) ----
591
592    #[test]
593    fn attributes_fetch_time_random_to_top_level_functions() {
594        let src = "\
595export async function ingest(rows) {
596  const started = Date.now();
597  const res = await fetch(\"https://api.example.com/v1/x\", { method: \"POST\" });
598  const id = crypto.randomUUID();
599  return { started, id, body: await res.json() };
600}
601
602function pure(a, b) {
603  return a + b;
604}
605";
606        let fns = functions_named(src, "app.mjs");
607        assert_eq!(fns.len(), 2);
608        let ingest = &fns[0];
609        assert_eq!(ingest.entrypoint, "ts:app.mjs#ingest");
610        assert_eq!((ingest.file.as_str(), ingest.line), ("app.mjs", 1));
611        assert_eq!(ingest.effects, 1);
612        assert_eq!(ingest.idempotent_unsafe, 1, "object-literal POST method");
613        assert_eq!(ingest.time_reads, 1);
614        assert_eq!(ingest.random_reads, 1);
615        assert!(ingest.targets.contains("api.example.com"));
616        assert!(ingest.unsafe_reasons.is_empty());
617        assert_eq!(fns[1].entrypoint, "ts:app.mjs#pure");
618        assert_eq!(fns[1].effects, 0);
619    }
620
621    #[test]
622    fn single_line_arrow_and_nested_callback_attribution() {
623        let src = "\
624const ping = () => fetch(\"https://a.example.com/health\");
625const nightly = async () => {
626  const results = await Promise.all(urls.map((u) => fetch(u)));
627  return results;
628};
629";
630        let fns = functions_named(src, "jobs.ts");
631        assert_eq!(fns.len(), 2);
632        assert_eq!(fns[0].entrypoint, "ts:jobs.ts#ping");
633        assert_eq!(fns[0].effects, 1);
634        // The nested map callback's fetch attributes to the enclosing
635        // top-level function — real scope containment, not a line heuristic.
636        assert_eq!(fns[1].entrypoint, "ts:jobs.ts#nightly");
637        assert_eq!(fns[1].effects, 1);
638    }
639
640    #[test]
641    fn child_process_defeats_the_replay_safe_estimate() {
642        let src = "\
643export function shellOut() {
644  const { execSync } = require(\"child_process\");
645  execSync(\"ls\");
646  return fetch(\"https://api.example.com/v1/x\");
647}
648";
649        let fns = functions_named(src, "run.js");
650        assert_eq!(fns.len(), 1);
651        assert_eq!(fns[0].effects, 1);
652        assert_eq!(
653            fns[0].unsafe_reasons,
654            vec!["child_process use at run.js:2".to_owned()]
655        );
656    }
657
658    #[test]
659    fn subprocess_launches_are_itemized_with_literal_argv() {
660        let src = "\
661import { spawn, exec } from \"child_process\";
662import * as cp from \"child_process\";
663
664export function launch(cmd) {
665  spawn(\"uvx\");
666  exec(cmd);
667  cp.spawn(\"./scripts/kill_switch.sh\");
668}
669";
670        let f = findings_named(src, "launch.ts");
671        let items: Vec<(&str, &str)> = f
672            .subprocesses
673            .iter()
674            .map(|x| (x.launcher.as_str(), x.command.as_str()))
675            .collect();
676        assert_eq!(
677            items,
678            [
679                ("child_process.spawn", "uvx"),
680                ("child_process.exec", "<dynamic>"),
681                ("child_process.spawn", "./scripts/kill_switch.sh"),
682            ]
683        );
684        assert!(f.subprocesses.iter().all(|x| x.file == "launch.ts"));
685        // child_process is not an effect library: no call sites, no
686        // http_in_use, no libs entry — a process boundary, not a network one.
687        assert!(f.call_sites.is_empty());
688        assert!(!f.http_in_use);
689        assert!(!f.libs.contains("child_process"));
690    }
691
692    #[test]
693    fn class_methods_and_plain_calls_are_not_tracked_as_functions() {
694        let src = "\
695class Api {
696  async load() {
697    return fetch(\"https://a.example.com\");
698  }
699}
700functional(1, 2);
701const url = \"https://b.example.com\";
702";
703        assert!(functions(src).is_empty());
704    }
705
706    #[test]
707    fn braces_in_strings_and_comments_do_not_desync_depth() {
708        // A regression fixture from the old line-oriented scan, where braces
709        // inside string/comment text could desync brace-depth tracking. A
710        // real parse never has this problem by construction — kept as a
711        // cheap sanity check that nothing about the new pass reintroduces it.
712        let src = "\
713function outer() {
714  const s = \"{ not a brace }\";
715  // } neither is this
716  return fetch(\"https://a.example.com\");
717}
718function after() {
719  return Date.now();
720}
721";
722        let fns = functions(src);
723        assert_eq!(fns.len(), 2);
724        assert_eq!(fns[0].effects, 1);
725        assert_eq!(fns[1].entrypoint, "ts:app.ts#after");
726        assert_eq!(fns[1].time_reads, 1);
727    }
728}