1mod ast;
54
55use std::collections::{BTreeMap, BTreeSet};
56use std::path::Path;
57
58use super::{FunctionFacts, LangFindings, SKIP_DIRS};
59
60const JS_EXTS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "jsx", "tsx"];
62
63const RESOLVE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
66
67#[derive(Debug, Clone, Default)]
69pub struct JsScan {
70 pub files_scanned: usize,
72 pub parse_failures: Vec<String>,
74 pub imports: BTreeMap<String, BTreeSet<String>>,
78 pub findings: LangFindings,
80 pub functions: Vec<FunctionFacts>,
83}
84
85pub 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
118fn 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
141fn 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
161fn 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
185fn 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 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 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 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 #[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 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 #[test]
283 fn multi_line_import_is_found() {
284 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 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 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 #[test]
350 fn effect_lib_imports_gate_hosts() {
351 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 #[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 #[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 #[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 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 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}