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, SKIP_DIRS};
59
60/// Extensions the scan reads.
61const 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(project, &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    result
116}
117
118/// Resolve a relative import specifier against the scanned file set:
119/// exact path, then `<spec>.<ext>`, then `<spec>/index.<ext>`.
120fn resolve_relative(importer: &str, spec: &str, known: &BTreeSet<&str>) -> Option<String> {
121    let dir = importer.rsplit_once('/').map_or("", |(d, _)| d);
122    let joined = normalize(dir, spec)?;
123    if known.contains(joined.as_str()) {
124        return Some(joined);
125    }
126    for ext in RESOLVE_EXTS {
127        let candidate = format!("{joined}.{ext}");
128        if known.contains(candidate.as_str()) {
129            return Some(candidate);
130        }
131    }
132    for ext in RESOLVE_EXTS {
133        let candidate = format!("{joined}/index.{ext}");
134        if known.contains(candidate.as_str()) {
135            return Some(candidate);
136        }
137    }
138    None
139}
140
141/// Join `dir` and a `./`/`../` specifier, normalizing `.` and `..` segments.
142/// `None` when `..` escapes the project root.
143fn normalize(dir: &str, spec: &str) -> Option<String> {
144    let mut parts: Vec<&str> = if dir.is_empty() {
145        Vec::new()
146    } else {
147        dir.split('/').collect()
148    };
149    for seg in spec.split('/') {
150        match seg {
151            "" | "." => {}
152            ".." => {
153                parts.pop()?;
154            }
155            other => parts.push(other),
156        }
157    }
158    Some(parts.join("/"))
159}
160
161/// Recursively collect scannable files, skipping [`SKIP_DIRS`] and dotdirs.
162fn collect(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
163    let Ok(entries) = std::fs::read_dir(dir) else {
164        return;
165    };
166    for entry in entries.flatten() {
167        let path = entry.path();
168        let name = entry.file_name();
169        let name = name.to_string_lossy();
170        if path.is_dir() {
171            if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') {
172                continue;
173            }
174            collect(&path, out);
175        } else if path
176            .extension()
177            .and_then(|e| e.to_str())
178            .is_some_and(|e| JS_EXTS.contains(&e))
179        {
180            out.push(path);
181        }
182    }
183}
184
185/// Project-relative path with `/` separators.
186fn relative(project: &Path, path: &Path) -> String {
187    path.strip_prefix(project)
188        .unwrap_or(path)
189        .to_string_lossy()
190        .replace('\\', "/")
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::fs;
197    use tempfile::TempDir;
198
199    /// Parse one in-memory source as `name` and return its findings and
200    /// per-top-level-function attribution.
201    fn scan_str_named(src: &str, name: &str) -> (LangFindings, Vec<FunctionFacts>) {
202        let mut f = LangFindings::default();
203        let extras = ast::scan_source(src, name, &mut f).expect("fixture failed to parse");
204        (f, extras.functions)
205    }
206
207    /// Parse one in-memory source as `name` and return its findings.
208    fn findings_named(src: &str, name: &str) -> LangFindings {
209        scan_str_named(src, name).0
210    }
211
212    fn findings(src: &str) -> LangFindings {
213        findings_named(src, "app.ts")
214    }
215
216    /// Parse one in-memory source as `name` and return its per-top-level-
217    /// function attribution.
218    fn functions_named(src: &str, name: &str) -> Vec<FunctionFacts> {
219        scan_str_named(src, name).1
220    }
221
222    fn functions(src: &str) -> Vec<FunctionFacts> {
223        functions_named(src, "app.ts")
224    }
225
226    // ---- conformance with the old regex scan (same inputs, same findings) ----
227
228    #[test]
229    fn fetch_and_url_literal_are_found() {
230        let f = findings("const r = await fetch(\"https://api.example.com/v1/x\");\n");
231        assert!(f.http_in_use);
232        assert_eq!(f.hosts.len(), 1);
233        assert_eq!(f.hosts[0].0, "api.example.com");
234        assert_eq!(f.hosts[0].1.line, 1);
235        assert!(f.libs.contains("fetch"));
236    }
237
238    #[test]
239    fn provider_imports_map_to_llm_targets() {
240        let f = findings(
241            "import OpenAI from \"openai\";\nimport Anthropic from '@anthropic-ai/sdk';\n",
242        );
243        let providers: Vec<_> = f.llm.iter().map(|(p, _)| p.as_str()).collect();
244        assert!(providers.contains(&"openai"));
245        assert!(providers.contains(&"anthropic"));
246        assert_eq!(f.llm[0].1.line, 1);
247        assert_eq!(f.llm[1].1.line, 2);
248    }
249
250    #[test]
251    fn undici_import_marks_http_in_use() {
252        let f = findings("import { request } from \"undici\";\n");
253        assert!(f.http_in_use);
254        assert!(f.libs.contains("undici"));
255    }
256
257    #[test]
258    fn word_named_openai_variable_is_not_an_import() {
259        let f = findings("const openai = 3;\n");
260        assert!(f.llm.is_empty());
261    }
262
263    #[test]
264    fn multiple_hosts_on_one_line() {
265        let f = findings("fetch(1);\nx(\"https://a.example.com\", \"https://b.example.com/p\");\n");
266        let hosts: Vec<_> = f.hosts.iter().map(|(h, _)| h.as_str()).collect();
267        assert_eq!(hosts, ["a.example.com", "b.example.com"]);
268        assert_eq!(f.hosts[0].1.line, 2);
269    }
270
271    #[test]
272    fn member_fetch_still_counts() {
273        // The old scan accepted `.fetch(` — keep that looseness: it is how
274        // `globalThis.fetch(…)` and `this.fetch(…)` appear.
275        let f = findings("globalThis.fetch(\"https://api.example.com\");\n");
276        assert!(f.http_in_use);
277        assert!(f.libs.contains("fetch"));
278    }
279
280    // ---- cases the regex provably got wrong, now correct ----
281
282    #[test]
283    fn multi_line_import_is_found() {
284        // The specifier and the `import` keyword sit on different lines: the
285        // line-oriented scan missed this entirely.
286        let f = findings("import {\n  request,\n} from \"undici\";\n");
287        assert!(f.http_in_use);
288        assert!(f.libs.contains("undici"));
289    }
290
291    #[test]
292    fn import_type_is_not_runtime_evidence() {
293        // `import type` is erased by tsc: the regex flagged it as an OpenAI
294        // dependency; the AST walk knows better.
295        let f = findings("import type { ChatModel } from \"openai\";\n");
296        assert!(f.llm.is_empty());
297        assert!(!f.http_in_use);
298    }
299
300    #[test]
301    fn type_only_specifier_is_skipped_but_value_binds() {
302        let f =
303            findings("import { type ClientOptions, request } from \"undici\";\nrequest(\"x\");\n");
304        assert!(f.http_in_use);
305        let callees: Vec<_> = f.call_sites.iter().map(|c| c.callee.as_str()).collect();
306        assert_eq!(callees, ["undici.request"]);
307    }
308
309    #[test]
310    fn template_literal_host_is_found() {
311        let f = findings("const id = 1;\nawait fetch(`https://api.example.com/v1/${id}`);\n");
312        assert_eq!(f.hosts.len(), 1);
313        assert_eq!(f.hosts[0].0, "api.example.com");
314        assert_eq!(f.hosts[0].1.line, 2);
315    }
316
317    #[test]
318    fn interpolated_scheme_is_not_a_false_positive_host() {
319        // The regex walked back from `://` across the `}` and reported
320        // `internal` as a host. The quasi has no scheme, so no host.
321        let f = findings("const scheme = \"https\";\nconst u = `${scheme}://internal`;\n");
322        assert!(f.hosts.is_empty());
323    }
324
325    #[test]
326    fn require_and_dynamic_import_are_imports() {
327        let cjs = findings_named(
328            "const { request } = require(\"undici\");\nrequest(\"https://api.example.com\");\n",
329            "app.cjs",
330        );
331        assert!(cjs.http_in_use);
332        assert!(cjs.libs.contains("undici"));
333        assert_eq!(cjs.call_sites[0].callee, "undici.request");
334
335        let dynamic = findings("const undici = await import(\"undici\");\n");
336        assert!(dynamic.http_in_use);
337        assert!(dynamic.libs.contains("undici"));
338    }
339
340    #[test]
341    fn subpath_import_classifies_by_package() {
342        let f = findings("import { toFile } from \"openai/uploads\";\n");
343        assert_eq!(f.llm.len(), 1);
344        assert_eq!(f.llm[0].0, "openai");
345    }
346
347    // ---- new evidence the regex never had ----
348
349    #[test]
350    fn effect_lib_imports_gate_hosts() {
351        // A pg import plus a DSN literal yields the DB host (the Python pass
352        // resolves the same shape via psycopg + DSN).
353        let f = findings(
354            "import { Client } from \"pg\";\nconst DSN = \"postgres://db.internal:5432/app\";\n",
355        );
356        assert!(f.http_in_use);
357        assert!(f.libs.contains("pg"));
358        assert_eq!(f.hosts[0].0, "db.internal");
359    }
360
361    #[test]
362    fn axios_default_import_call_sites() {
363        let f = findings(
364            "import axios from \"axios\";\nawait axios.get(\"https://api.example.com\");\n",
365        );
366        assert!(f.http_in_use);
367        assert!(f.libs.contains("axios"));
368        assert_eq!(f.call_sites[0].callee, "axios.get");
369    }
370
371    #[test]
372    fn client_instance_traces_back_to_provider() {
373        let f = findings(
374            "import OpenAI from \"openai\";\nconst client = new OpenAI();\n\
375             export async function ask() {\n  return client.chat.completions.create({});\n}\n",
376        );
377        assert_eq!(f.call_sites.len(), 1);
378        let site = &f.call_sites[0];
379        assert_eq!(site.callee, "openai.chat.completions.create");
380        assert_eq!(site.function.as_deref(), Some("ask"));
381        assert_eq!(site.line, 4);
382    }
383
384    #[test]
385    fn ai_sdk_provider_packages_pin_llm_targets() {
386        let f = findings("import { anthropic } from \"@ai-sdk/anthropic\";\n");
387        assert!(f.libs.contains("ai-sdk"));
388        assert_eq!(f.llm[0].0, "anthropic");
389    }
390
391    // ---- attribution ----
392
393    #[test]
394    fn attribution_covers_functions_methods_and_arrows() {
395        let f = findings(
396            "class Api {\n  async load() {\n    return fetch(\"https://a.x\");\n  }\n}\n\
397             function outer() {\n  const inner = async () => fetch(\"https://b.x\");\n  return inner;\n}\n\
398             const top = fetch(\"https://c.x\");\n",
399        );
400        let sites: Vec<(&str, Option<&str>)> = f
401            .call_sites
402            .iter()
403            .map(|c| (c.callee.as_str(), c.function.as_deref()))
404            .collect();
405        assert_eq!(
406            sites,
407            [
408                ("fetch", Some("Api.load")),
409                ("fetch", Some("outer.inner")),
410                ("fetch", None),
411            ]
412        );
413    }
414
415    #[test]
416    fn tsx_parses_with_jsx_and_types() {
417        let f = findings_named(
418            "type Props = { url: string };\n\
419             export function Widget({ url }: Props) {\n\
420               const load = () => fetch(\"https://api.example.com\");\n\
421               return <button onClick={load}>go</button>;\n\
422             }\n",
423            "widget.tsx",
424        );
425        assert!(f.http_in_use);
426        assert_eq!(f.hosts[0].0, "api.example.com");
427        assert_eq!(
428            f.call_sites[0].function.as_deref(),
429            Some("Widget.load"),
430            "arrow inside a component attributes to Widget.load"
431        );
432    }
433
434    // ---- pass-level behavior (filesystem) ----
435
436    #[test]
437    fn broken_file_is_skipped_never_fatal() {
438        let dir = TempDir::new().unwrap();
439        fs::write(dir.path().join("broken.ts"), "function (((\n").unwrap();
440        fs::write(dir.path().join("ok.ts"), "import \"undici\";\n").unwrap();
441        let scan = scan(dir.path());
442        assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
443        assert_eq!(scan.parse_failures, ["broken.ts"]);
444        assert!(scan.findings.http_in_use);
445    }
446
447    #[test]
448    fn import_graph_resolves_relative_specifiers() {
449        let dir = TempDir::new().unwrap();
450        fs::create_dir(dir.path().join("lib")).unwrap();
451        fs::write(
452            dir.path().join("app.ts"),
453            "import { helper } from \"./lib/helper\";\nimport { util } from \"./util\";\n\
454             import express from \"express\";\n",
455        )
456        .unwrap();
457        fs::write(dir.path().join("util.ts"), "export const util = 1;\n").unwrap();
458        fs::write(
459            dir.path().join("lib").join("helper.ts"),
460            "import { util } from \"../util\";\nexport const helper = util;\n",
461        )
462        .unwrap();
463        let scan = scan(dir.path());
464        assert_eq!(scan.files_scanned, 3);
465        assert_eq!(
466            scan.imports.get("app.ts"),
467            Some(&BTreeSet::from([
468                "lib/helper.ts".to_owned(),
469                "util.ts".to_owned()
470            ]))
471        );
472        assert_eq!(
473            scan.imports.get("lib/helper.ts"),
474            Some(&BTreeSet::from(["util.ts".to_owned()]))
475        );
476    }
477
478    #[test]
479    fn import_graph_resolves_index_files() {
480        let dir = TempDir::new().unwrap();
481        fs::create_dir(dir.path().join("api")).unwrap();
482        fs::write(dir.path().join("app.js"), "import api from \"./api\";\n").unwrap();
483        fs::write(
484            dir.path().join("api").join("index.js"),
485            "export default 1;\n",
486        )
487        .unwrap();
488        let scan = scan(dir.path());
489        assert_eq!(
490            scan.imports.get("app.js"),
491            Some(&BTreeSet::from(["api/index.js".to_owned()]))
492        );
493    }
494
495    #[test]
496    fn deterministic_across_runs() {
497        let dir = TempDir::new().unwrap();
498        fs::write(
499            dir.path().join("a.ts"),
500            "import { request } from \"undici\";\nrequest(\"https://a.example.com\");\n",
501        )
502        .unwrap();
503        fs::write(
504            dir.path().join("b.ts"),
505            "await fetch(\"https://b.example.com\");\n",
506        )
507        .unwrap();
508        let one = scan(dir.path());
509        let two = scan(dir.path());
510        assert_eq!(format!("{:?}", one.findings), format!("{:?}", two.findings));
511        assert_eq!(one.imports, two.imports);
512    }
513
514    // ---- function attribution (real AST scope containment) ----
515
516    #[test]
517    fn attributes_fetch_time_random_to_top_level_functions() {
518        let src = "\
519export async function ingest(rows) {
520  const started = Date.now();
521  const res = await fetch(\"https://api.example.com/v1/x\", { method: \"POST\" });
522  const id = crypto.randomUUID();
523  return { started, id, body: await res.json() };
524}
525
526function pure(a, b) {
527  return a + b;
528}
529";
530        let fns = functions_named(src, "app.mjs");
531        assert_eq!(fns.len(), 2);
532        let ingest = &fns[0];
533        assert_eq!(ingest.entrypoint, "ts:app.mjs#ingest");
534        assert_eq!((ingest.file.as_str(), ingest.line), ("app.mjs", 1));
535        assert_eq!(ingest.effects, 1);
536        assert_eq!(ingest.idempotent_unsafe, 1, "object-literal POST method");
537        assert_eq!(ingest.time_reads, 1);
538        assert_eq!(ingest.random_reads, 1);
539        assert!(ingest.targets.contains("api.example.com"));
540        assert!(ingest.unsafe_reasons.is_empty());
541        assert_eq!(fns[1].entrypoint, "ts:app.mjs#pure");
542        assert_eq!(fns[1].effects, 0);
543    }
544
545    #[test]
546    fn single_line_arrow_and_nested_callback_attribution() {
547        let src = "\
548const ping = () => fetch(\"https://a.example.com/health\");
549const nightly = async () => {
550  const results = await Promise.all(urls.map((u) => fetch(u)));
551  return results;
552};
553";
554        let fns = functions_named(src, "jobs.ts");
555        assert_eq!(fns.len(), 2);
556        assert_eq!(fns[0].entrypoint, "ts:jobs.ts#ping");
557        assert_eq!(fns[0].effects, 1);
558        // The nested map callback's fetch attributes to the enclosing
559        // top-level function — real scope containment, not a line heuristic.
560        assert_eq!(fns[1].entrypoint, "ts:jobs.ts#nightly");
561        assert_eq!(fns[1].effects, 1);
562    }
563
564    #[test]
565    fn child_process_defeats_the_replay_safe_estimate() {
566        let src = "\
567export function shellOut() {
568  const { execSync } = require(\"child_process\");
569  execSync(\"ls\");
570  return fetch(\"https://api.example.com/v1/x\");
571}
572";
573        let fns = functions_named(src, "run.js");
574        assert_eq!(fns.len(), 1);
575        assert_eq!(fns[0].effects, 1);
576        assert_eq!(
577            fns[0].unsafe_reasons,
578            vec!["child_process use at run.js:2".to_owned()]
579        );
580    }
581
582    #[test]
583    fn class_methods_and_plain_calls_are_not_tracked_as_functions() {
584        let src = "\
585class Api {
586  async load() {
587    return fetch(\"https://a.example.com\");
588  }
589}
590functional(1, 2);
591const url = \"https://b.example.com\";
592";
593        assert!(functions(src).is_empty());
594    }
595
596    #[test]
597    fn braces_in_strings_and_comments_do_not_desync_depth() {
598        // A regression fixture from the old line-oriented scan, where braces
599        // inside string/comment text could desync brace-depth tracking. A
600        // real parse never has this problem by construction — kept as a
601        // cheap sanity check that nothing about the new pass reintroduces it.
602        let src = "\
603function outer() {
604  const s = \"{ not a brace }\";
605  // } neither is this
606  return fetch(\"https://a.example.com\");
607}
608function after() {
609  return Date.now();
610}
611";
612        let fns = functions(src);
613        assert_eq!(fns.len(), 2);
614        assert_eq!(fns[0].effects, 1);
615        assert_eq!(fns[1].entrypoint, "ts:app.ts#after");
616        assert_eq!(fns[1].time_reads, 1);
617    }
618}