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 axios_default_import_call_sites() {
360        let f = findings(
361            "import axios from \"axios\";\nawait axios.get(\"https://api.example.com\");\n",
362        );
363        assert!(f.http_in_use);
364        assert!(f.libs.contains("axios"));
365        assert_eq!(f.call_sites[0].callee, "axios.get");
366    }
367
368    #[test]
369    fn client_instance_traces_back_to_provider() {
370        let f = findings(
371            "import OpenAI from \"openai\";\nconst client = new OpenAI();\n\
372             export async function ask() {\n  return client.chat.completions.create({});\n}\n",
373        );
374        assert_eq!(f.call_sites.len(), 1);
375        let site = &f.call_sites[0];
376        assert_eq!(site.callee, "openai.chat.completions.create");
377        assert_eq!(site.function.as_deref(), Some("ask"));
378        assert_eq!(site.line, 4);
379    }
380
381    #[test]
382    fn ai_sdk_provider_packages_pin_llm_targets() {
383        let f = findings("import { anthropic } from \"@ai-sdk/anthropic\";\n");
384        assert!(f.libs.contains("ai-sdk"));
385        assert_eq!(f.llm[0].0, "anthropic");
386    }
387
388    // ---- attribution ----
389
390    #[test]
391    fn attribution_covers_functions_methods_and_arrows() {
392        let f = findings(
393            "class Api {\n  async load() {\n    return fetch(\"https://a.x\");\n  }\n}\n\
394             function outer() {\n  const inner = async () => fetch(\"https://b.x\");\n  return inner;\n}\n\
395             const top = fetch(\"https://c.x\");\n",
396        );
397        let sites: Vec<(&str, Option<&str>)> = f
398            .call_sites
399            .iter()
400            .map(|c| (c.callee.as_str(), c.function.as_deref()))
401            .collect();
402        assert_eq!(
403            sites,
404            [
405                ("fetch", Some("Api.load")),
406                ("fetch", Some("outer.inner")),
407                ("fetch", None),
408            ]
409        );
410    }
411
412    #[test]
413    fn tsx_parses_with_jsx_and_types() {
414        let f = findings_named(
415            "type Props = { url: string };\n\
416             export function Widget({ url }: Props) {\n\
417               const load = () => fetch(\"https://api.example.com\");\n\
418               return <button onClick={load}>go</button>;\n\
419             }\n",
420            "widget.tsx",
421        );
422        assert!(f.http_in_use);
423        assert_eq!(f.hosts[0].0, "api.example.com");
424        assert_eq!(
425            f.call_sites[0].function.as_deref(),
426            Some("Widget.load"),
427            "arrow inside a component attributes to Widget.load"
428        );
429    }
430
431    // ---- pass-level behavior (filesystem) ----
432
433    #[test]
434    fn broken_file_is_skipped_never_fatal() {
435        let dir = TempDir::new().unwrap();
436        fs::write(dir.path().join("broken.ts"), "function (((\n").unwrap();
437        fs::write(dir.path().join("ok.ts"), "import \"undici\";\n").unwrap();
438        let scan = scan(dir.path());
439        assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
440        assert_eq!(scan.parse_failures, ["broken.ts"]);
441        assert!(scan.findings.http_in_use);
442    }
443
444    #[test]
445    fn import_graph_resolves_relative_specifiers() {
446        let dir = TempDir::new().unwrap();
447        fs::create_dir(dir.path().join("lib")).unwrap();
448        fs::write(
449            dir.path().join("app.ts"),
450            "import { helper } from \"./lib/helper\";\nimport { util } from \"./util\";\n\
451             import express from \"express\";\n",
452        )
453        .unwrap();
454        fs::write(dir.path().join("util.ts"), "export const util = 1;\n").unwrap();
455        fs::write(
456            dir.path().join("lib").join("helper.ts"),
457            "import { util } from \"../util\";\nexport const helper = util;\n",
458        )
459        .unwrap();
460        let scan = scan(dir.path());
461        assert_eq!(scan.files_scanned, 3);
462        assert_eq!(
463            scan.imports.get("app.ts"),
464            Some(&BTreeSet::from([
465                "lib/helper.ts".to_owned(),
466                "util.ts".to_owned()
467            ]))
468        );
469        assert_eq!(
470            scan.imports.get("lib/helper.ts"),
471            Some(&BTreeSet::from(["util.ts".to_owned()]))
472        );
473    }
474
475    #[test]
476    fn import_graph_resolves_index_files() {
477        let dir = TempDir::new().unwrap();
478        fs::create_dir(dir.path().join("api")).unwrap();
479        fs::write(dir.path().join("app.js"), "import api from \"./api\";\n").unwrap();
480        fs::write(
481            dir.path().join("api").join("index.js"),
482            "export default 1;\n",
483        )
484        .unwrap();
485        let scan = scan(dir.path());
486        assert_eq!(
487            scan.imports.get("app.js"),
488            Some(&BTreeSet::from(["api/index.js".to_owned()]))
489        );
490    }
491
492    #[test]
493    fn deterministic_across_runs() {
494        let dir = TempDir::new().unwrap();
495        fs::write(
496            dir.path().join("a.ts"),
497            "import { request } from \"undici\";\nrequest(\"https://a.example.com\");\n",
498        )
499        .unwrap();
500        fs::write(
501            dir.path().join("b.ts"),
502            "await fetch(\"https://b.example.com\");\n",
503        )
504        .unwrap();
505        let one = scan(dir.path());
506        let two = scan(dir.path());
507        assert_eq!(format!("{:?}", one.findings), format!("{:?}", two.findings));
508        assert_eq!(one.imports, two.imports);
509    }
510
511    #[test]
512    fn hosts_are_tracked_when_the_scan_saw_http_evidence_anywhere() {
513        let dir = TempDir::new().unwrap();
514        fs::write(
515            dir.path().join("app.ts"),
516            "await fetch(\"https://api.example.com/v1\");\n",
517        )
518        .unwrap();
519        let scan = scan(dir.path());
520        assert_eq!(
521            scan.findings.host_transports.get("api.example.com"),
522            Some(&TransportClass::Tracked)
523        );
524    }
525
526    #[test]
527    fn hosts_are_unknown_transport_without_http_evidence_anywhere() {
528        let dir = TempDir::new().unwrap();
529        // A bare URL literal, no fetch/undici/http import anywhere in the scan.
530        fs::write(
531            dir.path().join("app.ts"),
532            "const url = \"https://api.mystery.com/v1\";\n",
533        )
534        .unwrap();
535        let scan = scan(dir.path());
536        assert_eq!(
537            scan.findings.host_transports.get("api.mystery.com"),
538            Some(&TransportClass::Unknown)
539        );
540    }
541
542    // ---- function attribution (real AST scope containment) ----
543
544    #[test]
545    fn attributes_fetch_time_random_to_top_level_functions() {
546        let src = "\
547export async function ingest(rows) {
548  const started = Date.now();
549  const res = await fetch(\"https://api.example.com/v1/x\", { method: \"POST\" });
550  const id = crypto.randomUUID();
551  return { started, id, body: await res.json() };
552}
553
554function pure(a, b) {
555  return a + b;
556}
557";
558        let fns = functions_named(src, "app.mjs");
559        assert_eq!(fns.len(), 2);
560        let ingest = &fns[0];
561        assert_eq!(ingest.entrypoint, "ts:app.mjs#ingest");
562        assert_eq!((ingest.file.as_str(), ingest.line), ("app.mjs", 1));
563        assert_eq!(ingest.effects, 1);
564        assert_eq!(ingest.idempotent_unsafe, 1, "object-literal POST method");
565        assert_eq!(ingest.time_reads, 1);
566        assert_eq!(ingest.random_reads, 1);
567        assert!(ingest.targets.contains("api.example.com"));
568        assert!(ingest.unsafe_reasons.is_empty());
569        assert_eq!(fns[1].entrypoint, "ts:app.mjs#pure");
570        assert_eq!(fns[1].effects, 0);
571    }
572
573    #[test]
574    fn single_line_arrow_and_nested_callback_attribution() {
575        let src = "\
576const ping = () => fetch(\"https://a.example.com/health\");
577const nightly = async () => {
578  const results = await Promise.all(urls.map((u) => fetch(u)));
579  return results;
580};
581";
582        let fns = functions_named(src, "jobs.ts");
583        assert_eq!(fns.len(), 2);
584        assert_eq!(fns[0].entrypoint, "ts:jobs.ts#ping");
585        assert_eq!(fns[0].effects, 1);
586        // The nested map callback's fetch attributes to the enclosing
587        // top-level function — real scope containment, not a line heuristic.
588        assert_eq!(fns[1].entrypoint, "ts:jobs.ts#nightly");
589        assert_eq!(fns[1].effects, 1);
590    }
591
592    #[test]
593    fn child_process_defeats_the_replay_safe_estimate() {
594        let src = "\
595export function shellOut() {
596  const { execSync } = require(\"child_process\");
597  execSync(\"ls\");
598  return fetch(\"https://api.example.com/v1/x\");
599}
600";
601        let fns = functions_named(src, "run.js");
602        assert_eq!(fns.len(), 1);
603        assert_eq!(fns[0].effects, 1);
604        assert_eq!(
605            fns[0].unsafe_reasons,
606            vec!["child_process use at run.js:2".to_owned()]
607        );
608    }
609
610    #[test]
611    fn subprocess_launches_are_itemized_with_literal_argv() {
612        let src = "\
613import { spawn, exec } from \"child_process\";
614import * as cp from \"child_process\";
615
616export function launch(cmd) {
617  spawn(\"uvx\");
618  exec(cmd);
619  cp.spawn(\"./scripts/kill_switch.sh\");
620}
621";
622        let f = findings_named(src, "launch.ts");
623        let items: Vec<(&str, &str)> = f
624            .subprocesses
625            .iter()
626            .map(|x| (x.launcher.as_str(), x.command.as_str()))
627            .collect();
628        assert_eq!(
629            items,
630            [
631                ("child_process.spawn", "uvx"),
632                ("child_process.exec", "<dynamic>"),
633                ("child_process.spawn", "./scripts/kill_switch.sh"),
634            ]
635        );
636        assert!(f.subprocesses.iter().all(|x| x.file == "launch.ts"));
637        // child_process is not an effect library: no call sites, no
638        // http_in_use, no libs entry — a process boundary, not a network one.
639        assert!(f.call_sites.is_empty());
640        assert!(!f.http_in_use);
641        assert!(!f.libs.contains("child_process"));
642    }
643
644    #[test]
645    fn class_methods_and_plain_calls_are_not_tracked_as_functions() {
646        let src = "\
647class Api {
648  async load() {
649    return fetch(\"https://a.example.com\");
650  }
651}
652functional(1, 2);
653const url = \"https://b.example.com\";
654";
655        assert!(functions(src).is_empty());
656    }
657
658    #[test]
659    fn braces_in_strings_and_comments_do_not_desync_depth() {
660        // A regression fixture from the old line-oriented scan, where braces
661        // inside string/comment text could desync brace-depth tracking. A
662        // real parse never has this problem by construction — kept as a
663        // cheap sanity check that nothing about the new pass reintroduces it.
664        let src = "\
665function outer() {
666  const s = \"{ not a brace }\";
667  // } neither is this
668  return fetch(\"https://a.example.com\");
669}
670function after() {
671  return Date.now();
672}
673";
674        let fns = functions(src);
675        assert_eq!(fns.len(), 2);
676        assert_eq!(fns[0].effects, 1);
677        assert_eq!(fns[1].entrypoint, "ts:app.ts#after");
678        assert_eq!(fns[1].time_reads, 1);
679    }
680}