1mod ast;
54
55use std::collections::{BTreeMap, BTreeSet};
56use std::path::Path;
57
58use super::{FunctionFacts, LangFindings, 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 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 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 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 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 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 #[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 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 #[test]
259 fn multi_line_import_is_found() {
260 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 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 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 #[test]
326 fn effect_lib_imports_gate_hosts() {
327 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 #[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 #[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 #[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 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 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}