1mod ast;
54
55use std::collections::{BTreeMap, BTreeSet};
56use std::path::Path;
57
58use super::{FunctionFacts, LangFindings, TransportClass, collect_files};
59
60pub(crate) const 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_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 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
139fn 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
162fn 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
182fn 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 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 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 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 #[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 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 #[test]
280 fn multi_line_import_is_found() {
281 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 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 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 #[test]
347 fn effect_lib_imports_gate_hosts() {
348 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 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 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 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 #[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 #[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 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 #[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 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 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 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}