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