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 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 #[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 #[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 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 #[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 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 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 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}