mod ast;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use super::{FunctionFacts, LangFindings, TransportClass, collect_files};
pub(crate) const JS_EXTS: &[&str] = &["js", "mjs", "cjs", "ts", "mts", "cts", "jsx", "tsx"];
const RESOLVE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
#[derive(Debug, Clone, Default)]
pub struct JsScan {
pub files_scanned: usize,
pub parse_failures: Vec<String>,
pub imports: BTreeMap<String, BTreeSet<String>>,
pub findings: LangFindings,
pub functions: Vec<FunctionFacts>,
}
pub fn scan(project: &Path) -> JsScan {
let mut files = Vec::new();
collect_files(project, JS_EXTS, &mut files);
files.sort();
let rels: Vec<String> = files.iter().map(|p| relative(project, p)).collect();
let known: BTreeSet<&str> = rels.iter().map(String::as_str).collect();
let mut result = JsScan::default();
for (path, rel) in files.iter().zip(&rels) {
let Ok(src) = std::fs::read_to_string(path) else {
continue;
};
if let Some(extras) = ast::scan_source(&src, rel, &mut result.findings) {
result.files_scanned += 1;
result.functions.extend(extras.functions);
let resolved: BTreeSet<String> = extras
.relative_imports
.iter()
.filter_map(|spec| resolve_relative(rel, spec, &known))
.collect();
if !resolved.is_empty() {
result.imports.insert(rel.clone(), resolved);
}
} else {
eprintln!("keel: warning: skipped {rel}: JS/TS parse failed");
result.parse_failures.push(rel.clone());
}
}
let class = if result.findings.http_in_use {
TransportClass::Tracked
} else {
TransportClass::Unknown
};
for (host, _) in &result.findings.hosts {
result
.findings
.host_transports
.entry(host.clone())
.or_insert(class);
}
result
}
fn resolve_relative(importer: &str, spec: &str, known: &BTreeSet<&str>) -> Option<String> {
let dir = importer.rsplit_once('/').map_or("", |(d, _)| d);
let joined = normalize(dir, spec)?;
if known.contains(joined.as_str()) {
return Some(joined);
}
for ext in RESOLVE_EXTS {
let candidate = format!("{joined}.{ext}");
if known.contains(candidate.as_str()) {
return Some(candidate);
}
}
for ext in RESOLVE_EXTS {
let candidate = format!("{joined}/index.{ext}");
if known.contains(candidate.as_str()) {
return Some(candidate);
}
}
None
}
fn normalize(dir: &str, spec: &str) -> Option<String> {
let mut parts: Vec<&str> = if dir.is_empty() {
Vec::new()
} else {
dir.split('/').collect()
};
for seg in spec.split('/') {
match seg {
"" | "." => {}
".." => {
parts.pop()?;
}
other => parts.push(other),
}
}
Some(parts.join("/"))
}
fn relative(project: &Path, path: &Path) -> String {
path.strip_prefix(project)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn scan_str_named(src: &str, name: &str) -> (LangFindings, Vec<FunctionFacts>) {
let mut f = LangFindings::default();
let extras = ast::scan_source(src, name, &mut f).expect("fixture failed to parse");
(f, extras.functions)
}
fn findings_named(src: &str, name: &str) -> LangFindings {
scan_str_named(src, name).0
}
fn findings(src: &str) -> LangFindings {
findings_named(src, "app.ts")
}
fn functions_named(src: &str, name: &str) -> Vec<FunctionFacts> {
scan_str_named(src, name).1
}
fn functions(src: &str) -> Vec<FunctionFacts> {
functions_named(src, "app.ts")
}
#[test]
fn fetch_and_url_literal_are_found() {
let f = findings("const r = await fetch(\"https://api.example.com/v1/x\");\n");
assert!(f.http_in_use);
assert_eq!(f.hosts.len(), 1);
assert_eq!(f.hosts[0].0, "api.example.com");
assert_eq!(f.hosts[0].1.line, 1);
assert!(f.libs.contains("fetch"));
}
#[test]
fn provider_imports_map_to_llm_targets() {
let f = findings(
"import OpenAI from \"openai\";\nimport Anthropic from '@anthropic-ai/sdk';\n",
);
let providers: Vec<_> = f.llm.iter().map(|(p, _)| p.as_str()).collect();
assert!(providers.contains(&"openai"));
assert!(providers.contains(&"anthropic"));
assert_eq!(f.llm[0].1.line, 1);
assert_eq!(f.llm[1].1.line, 2);
}
#[test]
fn undici_import_marks_http_in_use() {
let f = findings("import { request } from \"undici\";\n");
assert!(f.http_in_use);
assert!(f.libs.contains("undici"));
}
#[test]
fn word_named_openai_variable_is_not_an_import() {
let f = findings("const openai = 3;\n");
assert!(f.llm.is_empty());
}
#[test]
fn multiple_hosts_on_one_line() {
let f = findings("fetch(1);\nx(\"https://a.example.com\", \"https://b.example.com/p\");\n");
let hosts: Vec<_> = f.hosts.iter().map(|(h, _)| h.as_str()).collect();
assert_eq!(hosts, ["a.example.com", "b.example.com"]);
assert_eq!(f.hosts[0].1.line, 2);
}
#[test]
fn member_fetch_still_counts() {
let f = findings("globalThis.fetch(\"https://api.example.com\");\n");
assert!(f.http_in_use);
assert!(f.libs.contains("fetch"));
}
#[test]
fn multi_line_import_is_found() {
let f = findings("import {\n request,\n} from \"undici\";\n");
assert!(f.http_in_use);
assert!(f.libs.contains("undici"));
}
#[test]
fn import_type_is_not_runtime_evidence() {
let f = findings("import type { ChatModel } from \"openai\";\n");
assert!(f.llm.is_empty());
assert!(!f.http_in_use);
}
#[test]
fn type_only_specifier_is_skipped_but_value_binds() {
let f =
findings("import { type ClientOptions, request } from \"undici\";\nrequest(\"x\");\n");
assert!(f.http_in_use);
let callees: Vec<_> = f.call_sites.iter().map(|c| c.callee.as_str()).collect();
assert_eq!(callees, ["undici.request"]);
}
#[test]
fn template_literal_host_is_found() {
let f = findings("const id = 1;\nawait fetch(`https://api.example.com/v1/${id}`);\n");
assert_eq!(f.hosts.len(), 1);
assert_eq!(f.hosts[0].0, "api.example.com");
assert_eq!(f.hosts[0].1.line, 2);
}
#[test]
fn interpolated_scheme_is_not_a_false_positive_host() {
let f = findings("const scheme = \"https\";\nconst u = `${scheme}://internal`;\n");
assert!(f.hosts.is_empty());
}
#[test]
fn require_and_dynamic_import_are_imports() {
let cjs = findings_named(
"const { request } = require(\"undici\");\nrequest(\"https://api.example.com\");\n",
"app.cjs",
);
assert!(cjs.http_in_use);
assert!(cjs.libs.contains("undici"));
assert_eq!(cjs.call_sites[0].callee, "undici.request");
let dynamic = findings("const undici = await import(\"undici\");\n");
assert!(dynamic.http_in_use);
assert!(dynamic.libs.contains("undici"));
}
#[test]
fn subpath_import_classifies_by_package() {
let f = findings("import { toFile } from \"openai/uploads\";\n");
assert_eq!(f.llm.len(), 1);
assert_eq!(f.llm[0].0, "openai");
}
#[test]
fn effect_lib_imports_gate_hosts() {
let f = findings(
"import { Client } from \"pg\";\nconst DSN = \"postgres://db.internal:5432/app\";\n",
);
assert!(f.http_in_use);
assert!(f.libs.contains("pg"));
assert_eq!(f.hosts[0].0, "db.internal");
}
#[test]
fn resilience_lib_imports_are_detected_separately_from_known_libs() {
let f = findings(
"import { request } from \"undici\";\nimport pRetry from \"p-retry\";\n\
const retry = require(\"async-retry\");\n",
);
assert_eq!(
f.resilience_libs,
["async-retry".to_owned(), "p-retry".to_owned()]
.into_iter()
.collect()
);
assert!(!f.libs.contains("p-retry"));
assert!(!f.libs.contains("async-retry"));
assert!(f.libs.contains("undici"));
}
#[test]
fn axios_default_import_call_sites() {
let f = findings(
"import axios from \"axios\";\nawait axios.get(\"https://api.example.com\");\n",
);
assert!(f.http_in_use);
assert!(f.libs.contains("axios"));
assert_eq!(f.call_sites[0].callee, "axios.get");
}
#[test]
fn client_instance_traces_back_to_provider() {
let f = findings(
"import OpenAI from \"openai\";\nconst client = new OpenAI();\n\
export async function ask() {\n return client.chat.completions.create({});\n}\n",
);
assert_eq!(f.call_sites.len(), 1);
let site = &f.call_sites[0];
assert_eq!(site.callee, "openai.chat.completions.create");
assert_eq!(site.function.as_deref(), Some("ask"));
assert_eq!(site.line, 4);
}
#[test]
fn ai_sdk_provider_packages_pin_llm_targets() {
let f = findings("import { anthropic } from \"@ai-sdk/anthropic\";\n");
assert!(f.libs.contains("ai-sdk"));
assert_eq!(f.llm[0].0, "anthropic");
}
#[test]
fn mcp_sdk_imports_are_detected_as_the_mcp_lib() {
let subpath =
findings("import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\n");
assert!(subpath.libs.contains("mcp"));
assert!(subpath.http_in_use);
assert!(subpath.llm.is_empty());
let bare = findings("import * as mcp from \"@modelcontextprotocol/sdk\";\n");
assert!(bare.libs.contains("mcp"));
let cjs = findings_named(
"const { Client } = require(\"@modelcontextprotocol/sdk/client/index.js\");\n",
"server.cjs",
);
assert!(cjs.libs.contains("mcp"));
}
#[test]
fn attribution_covers_functions_methods_and_arrows() {
let f = findings(
"class Api {\n async load() {\n return fetch(\"https://a.x\");\n }\n}\n\
function outer() {\n const inner = async () => fetch(\"https://b.x\");\n return inner;\n}\n\
const top = fetch(\"https://c.x\");\n",
);
let sites: Vec<(&str, Option<&str>)> = f
.call_sites
.iter()
.map(|c| (c.callee.as_str(), c.function.as_deref()))
.collect();
assert_eq!(
sites,
[
("fetch", Some("Api.load")),
("fetch", Some("outer.inner")),
("fetch", None),
]
);
}
#[test]
fn tsx_parses_with_jsx_and_types() {
let f = findings_named(
"type Props = { url: string };\n\
export function Widget({ url }: Props) {\n\
const load = () => fetch(\"https://api.example.com\");\n\
return <button onClick={load}>go</button>;\n\
}\n",
"widget.tsx",
);
assert!(f.http_in_use);
assert_eq!(f.hosts[0].0, "api.example.com");
assert_eq!(
f.call_sites[0].function.as_deref(),
Some("Widget.load"),
"arrow inside a component attributes to Widget.load"
);
}
#[test]
fn broken_file_is_skipped_never_fatal() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("broken.ts"), "function (((\n").unwrap();
fs::write(dir.path().join("ok.ts"), "import \"undici\";\n").unwrap();
let scan = scan(dir.path());
assert_eq!(scan.files_scanned, 1, "only the parseable file counts");
assert_eq!(scan.parse_failures, ["broken.ts"]);
assert!(scan.findings.http_in_use);
}
#[test]
fn import_graph_resolves_relative_specifiers() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("lib")).unwrap();
fs::write(
dir.path().join("app.ts"),
"import { helper } from \"./lib/helper\";\nimport { util } from \"./util\";\n\
import express from \"express\";\n",
)
.unwrap();
fs::write(dir.path().join("util.ts"), "export const util = 1;\n").unwrap();
fs::write(
dir.path().join("lib").join("helper.ts"),
"import { util } from \"../util\";\nexport const helper = util;\n",
)
.unwrap();
let scan = scan(dir.path());
assert_eq!(scan.files_scanned, 3);
assert_eq!(
scan.imports.get("app.ts"),
Some(&BTreeSet::from([
"lib/helper.ts".to_owned(),
"util.ts".to_owned()
]))
);
assert_eq!(
scan.imports.get("lib/helper.ts"),
Some(&BTreeSet::from(["util.ts".to_owned()]))
);
}
#[test]
fn import_graph_resolves_index_files() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("api")).unwrap();
fs::write(dir.path().join("app.js"), "import api from \"./api\";\n").unwrap();
fs::write(
dir.path().join("api").join("index.js"),
"export default 1;\n",
)
.unwrap();
let scan = scan(dir.path());
assert_eq!(
scan.imports.get("app.js"),
Some(&BTreeSet::from(["api/index.js".to_owned()]))
);
}
#[test]
fn deterministic_across_runs() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("a.ts"),
"import { request } from \"undici\";\nrequest(\"https://a.example.com\");\n",
)
.unwrap();
fs::write(
dir.path().join("b.ts"),
"await fetch(\"https://b.example.com\");\n",
)
.unwrap();
let one = scan(dir.path());
let two = scan(dir.path());
assert_eq!(format!("{:?}", one.findings), format!("{:?}", two.findings));
assert_eq!(one.imports, two.imports);
}
#[test]
fn hosts_are_tracked_when_the_scan_saw_http_evidence_anywhere() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("app.ts"),
"await fetch(\"https://api.example.com/v1\");\n",
)
.unwrap();
let scan = scan(dir.path());
assert_eq!(
scan.findings.host_transports.get("api.example.com"),
Some(&TransportClass::Tracked)
);
}
#[test]
fn hosts_are_unknown_transport_without_http_evidence_anywhere() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("app.ts"),
"const url = \"https://api.mystery.com/v1\";\n",
)
.unwrap();
let scan = scan(dir.path());
assert_eq!(
scan.findings.host_transports.get("api.mystery.com"),
Some(&TransportClass::Unknown)
);
}
#[test]
fn attributes_fetch_time_random_to_top_level_functions() {
let src = "\
export async function ingest(rows) {
const started = Date.now();
const res = await fetch(\"https://api.example.com/v1/x\", { method: \"POST\" });
const id = crypto.randomUUID();
return { started, id, body: await res.json() };
}
function pure(a, b) {
return a + b;
}
";
let fns = functions_named(src, "app.mjs");
assert_eq!(fns.len(), 2);
let ingest = &fns[0];
assert_eq!(ingest.entrypoint, "ts:app.mjs#ingest");
assert_eq!((ingest.file.as_str(), ingest.line), ("app.mjs", 1));
assert_eq!(ingest.effects, 1);
assert_eq!(ingest.idempotent_unsafe, 1, "object-literal POST method");
assert_eq!(ingest.time_reads, 1);
assert_eq!(ingest.random_reads, 1);
assert!(ingest.targets.contains("api.example.com"));
assert!(ingest.unsafe_reasons.is_empty());
assert_eq!(fns[1].entrypoint, "ts:app.mjs#pure");
assert_eq!(fns[1].effects, 0);
}
#[test]
fn single_line_arrow_and_nested_callback_attribution() {
let src = "\
const ping = () => fetch(\"https://a.example.com/health\");
const nightly = async () => {
const results = await Promise.all(urls.map((u) => fetch(u)));
return results;
};
";
let fns = functions_named(src, "jobs.ts");
assert_eq!(fns.len(), 2);
assert_eq!(fns[0].entrypoint, "ts:jobs.ts#ping");
assert_eq!(fns[0].effects, 1);
assert_eq!(fns[1].entrypoint, "ts:jobs.ts#nightly");
assert_eq!(fns[1].effects, 1);
}
#[test]
fn child_process_defeats_the_replay_safe_estimate() {
let src = "\
export function shellOut() {
const { execSync } = require(\"child_process\");
execSync(\"ls\");
return fetch(\"https://api.example.com/v1/x\");
}
";
let fns = functions_named(src, "run.js");
assert_eq!(fns.len(), 1);
assert_eq!(fns[0].effects, 1);
assert_eq!(
fns[0].unsafe_reasons,
vec!["child_process use at run.js:2".to_owned()]
);
}
#[test]
fn subprocess_launches_are_itemized_with_literal_argv() {
let src = "\
import { spawn, exec } from \"child_process\";
import * as cp from \"child_process\";
export function launch(cmd) {
spawn(\"uvx\");
exec(cmd);
cp.spawn(\"./scripts/kill_switch.sh\");
}
";
let f = findings_named(src, "launch.ts");
let items: Vec<(&str, &str)> = f
.subprocesses
.iter()
.map(|x| (x.launcher.as_str(), x.command.as_str()))
.collect();
assert_eq!(
items,
[
("child_process.spawn", "uvx"),
("child_process.exec", "<dynamic>"),
("child_process.spawn", "./scripts/kill_switch.sh"),
]
);
assert!(f.subprocesses.iter().all(|x| x.file == "launch.ts"));
assert!(f.call_sites.is_empty());
assert!(!f.http_in_use);
assert!(!f.libs.contains("child_process"));
}
#[test]
fn class_methods_and_plain_calls_are_not_tracked_as_functions() {
let src = "\
class Api {
async load() {
return fetch(\"https://a.example.com\");
}
}
functional(1, 2);
const url = \"https://b.example.com\";
";
assert!(functions(src).is_empty());
}
#[test]
fn braces_in_strings_and_comments_do_not_desync_depth() {
let src = "\
function outer() {
const s = \"{ not a brace }\";
// } neither is this
return fetch(\"https://a.example.com\");
}
function after() {
return Date.now();
}
";
let fns = functions(src);
assert_eq!(fns.len(), 2);
assert_eq!(fns[0].effects, 1);
assert_eq!(fns[1].entrypoint, "ts:app.ts#after");
assert_eq!(fns[1].time_reads, 1);
}
}