use std::collections::{BTreeMap, BTreeSet};
use oxc_allocator::Allocator;
use oxc_ast::ast::{
Argument, BindingPattern, CallExpression, Class, Expression, Function, ImportDeclaration,
ImportDeclarationSpecifier, ImportExpression, MethodDefinition, NewExpression,
ObjectExpression, ObjectProperty, ObjectPropertyKind, PropertyDefinition, StringLiteral,
TemplateLiteral, VariableDeclarator,
};
use oxc_ast_visit::{Visit, walk};
use oxc_parser::Parser;
use oxc_span::SourceType;
use oxc_syntax::scope::ScopeFlags;
use super::super::{
CallSite, FunctionFacts, LangFindings, Sighting, SubprocessSighting, host_from_url,
};
#[derive(Debug, Clone, Copy)]
struct LibClass {
lib: &'static str,
provider: Option<&'static str>,
}
fn classify(specifier: &str) -> Option<LibClass> {
let pkg = package_name(specifier);
let lib = |lib| {
Some(LibClass {
lib,
provider: None,
})
};
match pkg {
"undici" => lib("undici"),
"node:http" | "node:https" | "http" | "https" => lib("http"),
"axios" => lib("axios"),
"got" => lib("got"),
"node-fetch" => lib("node-fetch"),
"superagent" => lib("superagent"),
"openai" => Some(LibClass {
lib: "openai",
provider: Some("openai"),
}),
"anthropic" | "@anthropic-ai/sdk" => Some(LibClass {
lib: "anthropic",
provider: Some("anthropic"),
}),
"ai" => lib("ai-sdk"),
"@ai-sdk/openai" => Some(LibClass {
lib: "ai-sdk",
provider: Some("openai"),
}),
"@ai-sdk/anthropic" => Some(LibClass {
lib: "ai-sdk",
provider: Some("anthropic"),
}),
p if p.starts_with("@ai-sdk/") => lib("ai-sdk"),
"@modelcontextprotocol/sdk" => lib("mcp"),
"pg" => lib("pg"),
"redis" => lib("redis"),
"ioredis" => lib("ioredis"),
"mongodb" => lib("mongodb"),
"mysql2" => lib("mysql2"),
_ => None,
}
}
fn resilience_lib_name(specifier: &str) -> Option<&'static str> {
match package_name(specifier) {
"p-retry" => Some("p-retry"),
"async-retry" => Some("async-retry"),
_ => None,
}
}
fn package_name(specifier: &str) -> &str {
let seg_end = |from: usize| {
specifier[from..]
.find('/')
.map_or(specifier.len(), |i| from + i)
};
if specifier.starts_with('@') {
let scope_end = seg_end(0);
if scope_end == specifier.len() {
return specifier;
}
&specifier[..seg_end(scope_end + 1)]
} else {
&specifier[..seg_end(0)]
}
}
#[derive(Debug, Clone)]
struct Binding {
lib: &'static str,
member: Option<String>,
}
#[derive(Debug, Default)]
pub(super) struct FileExtras {
pub relative_imports: Vec<String>,
pub functions: Vec<FunctionFacts>,
}
pub(super) fn scan_source(src: &str, rel: &str, findings: &mut LangFindings) -> Option<FileExtras> {
let source_type = SourceType::from_path(rel).unwrap_or_else(|_| SourceType::tsx());
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, src, source_type).parse();
if parsed.panicked || !parsed.diagnostics.is_empty() {
return None;
}
let mut visitor = ScanVisitor {
rel,
line_starts: line_starts(src),
findings,
extras: FileExtras::default(),
scope: Vec::new(),
pending_name: None,
pending_top_level: false,
bindings: BTreeMap::new(),
open_function: None,
cp_namespace: BTreeSet::new(),
cp_members: BTreeMap::new(),
};
visitor.visit_program(&parsed.program);
Some(visitor.extras)
}
fn line_starts(src: &str) -> Vec<u32> {
let mut starts = vec![0u32];
for (i, b) in src.bytes().enumerate() {
if b == b'\n' {
starts.push(u32::try_from(i + 1).unwrap_or(u32::MAX));
}
}
starts
}
struct ScanVisitor<'s> {
rel: &'s str,
line_starts: Vec<u32>,
findings: &'s mut LangFindings,
extras: FileExtras,
scope: Vec<String>,
pending_name: Option<String>,
pending_top_level: bool,
bindings: BTreeMap<String, Binding>,
open_function: Option<usize>,
cp_namespace: BTreeSet<String>,
cp_members: BTreeMap<String, String>,
}
impl ScanVisitor<'_> {
fn line_of(&self, offset: u32) -> u32 {
let idx = self.line_starts.partition_point(|&s| s <= offset);
u32::try_from(idx).unwrap_or(u32::MAX)
}
fn sighting(&self, offset: u32) -> Sighting {
Sighting {
file: self.rel.to_owned(),
line: self.line_of(offset),
}
}
fn record_lib(&mut self, class: LibClass, offset: u32) {
self.findings.libs.insert(class.lib.to_owned());
self.findings.http_in_use = true;
if let Some(provider) = class.provider {
if let Some(idx) = self.open_function {
self.extras.functions[idx]
.targets
.insert(format!("llm:{provider}"));
}
let sighting = self.sighting(offset);
self.findings.llm.push((provider.to_owned(), sighting));
}
}
fn record_call_site(&mut self, callee: String, offset: u32) {
let function = if self.scope.is_empty() {
None
} else {
Some(self.scope.join("."))
};
if let Some(idx) = self.open_function {
self.extras.functions[idx].effects += 1;
}
self.findings.call_sites.push(CallSite {
file: self.rel.to_owned(),
line: self.line_of(offset),
callee,
function,
});
}
fn record_hosts(&mut self, text: &str, offset: u32) {
for host in hosts_in_text(text) {
if let Some(idx) = self.open_function {
self.extras.functions[idx].targets.insert(host.clone());
}
let sighting = self.sighting(offset);
self.findings.hosts.push((host, sighting));
}
}
fn record_import(&mut self, specifier: &str, offset: u32) -> Option<LibClass> {
if specifier.starts_with("./") || specifier.starts_with("../") {
self.extras.relative_imports.push(specifier.to_owned());
return None;
}
if matches!(specifier, "child_process" | "worker_threads")
&& let Some(idx) = self.open_function
{
let line = self.line_of(offset);
self.extras.functions[idx]
.unsafe_reasons
.push(format!("{specifier} use at {}:{line}", self.rel));
}
if let Some(name) = resilience_lib_name(specifier) {
self.findings.resilience_libs.insert(name.to_owned());
}
let class = classify(specifier)?;
self.record_lib(class, offset);
Some(class)
}
fn bind_child_process_import(&mut self, it: &ImportDeclaration<'_>) {
let Some(specifiers) = &it.specifiers else {
return;
};
for specifier in specifiers {
match specifier {
ImportDeclarationSpecifier::ImportSpecifier(s) => {
if s.import_kind.is_type() {
continue;
}
self.cp_members.insert(
s.local.name.as_str().to_owned(),
s.imported.name().as_str().to_owned(),
);
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
self.cp_namespace.insert(s.local.name.as_str().to_owned());
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
self.cp_namespace.insert(s.local.name.as_str().to_owned());
}
}
}
}
fn bind_child_process_pattern(&mut self, pattern: &BindingPattern<'_>) {
match pattern {
BindingPattern::BindingIdentifier(ident) => {
self.cp_namespace.insert(ident.name.as_str().to_owned());
}
BindingPattern::ObjectPattern(obj) => {
for prop in &obj.properties {
let (Some(name), Some(local)) =
(prop.key.static_name(), prop.value.get_binding_identifier())
else {
continue;
};
self.cp_members
.insert(local.name.as_str().to_owned(), name.into_owned());
}
}
_ => {}
}
}
fn subprocess_launcher(&self, chain: &[String]) -> Option<&'static str> {
let member = match chain {
[ns, member] if self.cp_namespace.contains(ns) => member.as_str(),
[name] => self.cp_members.get(name)?.as_str(),
_ => return None,
};
match member {
"spawn" => Some("child_process.spawn"),
"exec" => Some("child_process.exec"),
_ => None,
}
}
fn record_subprocess(&mut self, launcher: &'static str, command: String, offset: u32) {
self.findings.subprocesses.push(SubprocessSighting {
file: self.rel.to_owned(),
line: self.line_of(offset),
launcher: launcher.to_owned(),
command,
argv: None,
});
}
fn mark_idempotent_unsafe(&mut self) {
if let Some(idx) = self.open_function {
self.extras.functions[idx].idempotent_unsafe += 1;
}
}
fn mark_time_read(&mut self) {
if let Some(idx) = self.open_function {
self.extras.functions[idx].time_reads += 1;
}
}
fn mark_random_read(&mut self) {
if let Some(idx) = self.open_function {
self.extras.functions[idx].random_reads += 1;
}
}
fn with_top_level_function(
&mut self,
name: String,
offset: u32,
inner: impl FnOnce(&mut Self),
) {
let line = self.line_of(offset);
self.extras.functions.push(FunctionFacts {
entrypoint: format!("ts:{}#{name}", self.rel),
file: self.rel.to_owned(),
line,
..FunctionFacts::default()
});
self.open_function = Some(self.extras.functions.len() - 1);
self.scope.push(name);
inner(self);
self.scope.pop();
self.open_function = None;
}
fn bind_pattern(&mut self, pattern: &BindingPattern<'_>, lib: &'static str) {
match pattern {
BindingPattern::BindingIdentifier(ident) => {
self.bindings.insert(
ident.name.as_str().to_owned(),
Binding { lib, member: None },
);
}
BindingPattern::ObjectPattern(obj) => {
for prop in &obj.properties {
let (Some(name), Some(local)) =
(prop.key.static_name(), prop.value.get_binding_identifier())
else {
continue;
};
self.bindings.insert(
local.name.as_str().to_owned(),
Binding {
lib,
member: Some(name.into_owned()),
},
);
}
}
_ => {}
}
}
fn bound_callee(&self, chain: &[String]) -> Option<String> {
let binding = self.bindings.get(chain.first()?)?;
let mut parts = vec![binding.lib.to_owned()];
parts.extend(binding.member.clone());
parts.extend(chain.iter().skip(1).cloned());
Some(parts.join("."))
}
fn scoped(&mut self, name: Option<String>, inner: impl FnOnce(&mut Self)) {
let pushed = name.is_some();
if let Some(name) = name {
self.scope.push(name);
}
inner(self);
if pushed {
self.scope.pop();
}
}
}
fn static_chain(expr: &Expression<'_>) -> Option<Vec<String>> {
match expr {
Expression::Identifier(ident) => Some(vec![ident.name.as_str().to_owned()]),
Expression::StaticMemberExpression(member) => {
let mut chain = static_chain(&member.object)?;
chain.push(member.property.name.as_str().to_owned());
Some(chain)
}
_ => None,
}
}
fn names_a_function(expr: &Expression<'_>) -> bool {
matches!(
expr,
Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_)
)
}
fn require_specifier<'a>(call: &'a CallExpression<'_>) -> Option<&'a str> {
if !matches!(&call.callee, Expression::Identifier(id) if id.name == "require") {
return None;
}
match call.arguments.as_slice() {
[Argument::StringLiteral(s)] => Some(s.value.as_str()),
_ => None,
}
}
fn subprocess_command(call: &CallExpression<'_>) -> String {
match call.arguments.first() {
Some(Argument::StringLiteral(s)) => s.value.as_str().to_owned(),
_ => "<dynamic>".to_owned(),
}
}
fn has_unsafe_method_argument(call: &CallExpression<'_>) -> bool {
call.arguments.iter().any(|arg| {
let Argument::ObjectExpression(obj) = arg else {
return false;
};
object_has_unsafe_method(obj)
})
}
fn object_has_unsafe_method(obj: &ObjectExpression<'_>) -> bool {
obj.properties.iter().any(|prop| {
let ObjectPropertyKind::ObjectProperty(p) = prop else {
return false;
};
p.key.static_name().as_deref() == Some("method")
&& matches!(&p.value, Expression::StringLiteral(s) if matches!(s.value.as_str(), "POST" | "PATCH"))
})
}
fn is_time_chain(chain: &[String]) -> bool {
match chain {
[a, b] => (a == "Date" || a == "performance") && b == "now",
_ => false,
}
}
fn is_random_chain(chain: &[String]) -> bool {
match chain {
[a, b] => matches!(
(a.as_str(), b.as_str()),
("Math", "random") | ("crypto", "randomUUID" | "getRandomValues")
),
_ => false,
}
}
impl<'a> Visit<'a> for ScanVisitor<'_> {
fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) {
if it.import_kind.is_type() {
return;
}
if it.source.value.as_str() == "child_process" {
self.bind_child_process_import(it);
}
let Some(class) = self.record_import(it.source.value.as_str(), it.span.start) else {
return;
};
let Some(specifiers) = &it.specifiers else {
return;
};
for specifier in specifiers {
match specifier {
ImportDeclarationSpecifier::ImportSpecifier(s) => {
if s.import_kind.is_type() {
continue; }
self.bindings.insert(
s.local.name.as_str().to_owned(),
Binding {
lib: class.lib,
member: Some(s.imported.name().as_str().to_owned()),
},
);
}
ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
self.bindings.insert(
s.local.name.as_str().to_owned(),
Binding {
lib: class.lib,
member: None,
},
);
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
self.bindings.insert(
s.local.name.as_str().to_owned(),
Binding {
lib: class.lib,
member: None,
},
);
}
}
}
}
fn visit_import_expression(&mut self, it: &ImportExpression<'a>) {
if let Expression::StringLiteral(source) = &it.source {
self.record_import(source.value.as_str(), it.span.start);
}
walk::walk_import_expression(self, it);
}
fn visit_call_expression(&mut self, it: &CallExpression<'a>) {
if let Some(specifier) = require_specifier(it) {
self.record_import(specifier, it.span.start);
} else if let Some(chain) = static_chain(&it.callee) {
if let Some(launcher) = self.subprocess_launcher(&chain) {
let command = subprocess_command(it);
self.record_subprocess(launcher, command, it.span.start);
} else if let Some(callee) = self.bound_callee(&chain) {
self.record_call_site(callee, it.span.start);
if has_unsafe_method_argument(it) {
self.mark_idempotent_unsafe();
}
} else if chain.last().is_some_and(|last| last == "fetch") {
self.findings.http_in_use = true;
self.findings.libs.insert("fetch".to_owned());
self.record_call_site("fetch".to_owned(), it.span.start);
if has_unsafe_method_argument(it) {
self.mark_idempotent_unsafe();
}
} else if is_time_chain(&chain) {
self.mark_time_read();
} else if is_random_chain(&chain) {
self.mark_random_read();
}
}
walk::walk_call_expression(self, it);
}
fn visit_new_expression(&mut self, it: &NewExpression<'a>) {
if let Some(chain) = static_chain(&it.callee)
&& chain.as_slice() == [String::from("Date")]
{
self.mark_time_read();
}
walk::walk_new_expression(self, it);
}
fn visit_string_literal(&mut self, it: &StringLiteral<'a>) {
self.record_hosts(it.value.as_str(), it.span.start);
}
fn visit_template_literal(&mut self, it: &TemplateLiteral<'a>) {
for quasi in &it.quasis {
let text = quasi
.value
.cooked
.as_ref()
.map_or_else(|| quasi.value.raw.as_str(), |cooked| cooked.as_str());
self.record_hosts(text, quasi.span.start);
}
walk::walk_template_literal(self, it);
}
fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) {
match &it.init {
Some(Expression::CallExpression(call)) => {
if let Some(specifier) = require_specifier(call) {
if specifier == "child_process" {
self.bind_child_process_pattern(&it.id);
}
if let Some(class) = classify(specifier) {
self.bind_pattern(&it.id, class.lib);
}
}
}
Some(Expression::NewExpression(new_expr)) => {
if let Some(chain) = static_chain(&new_expr.callee)
&& let Some(binding) = chain.first().and_then(|n| self.bindings.get(n))
{
let lib = binding.lib;
self.bind_pattern(&it.id, lib);
}
}
Some(init) if names_a_function(init) => {
self.pending_name = it
.id
.get_binding_identifier()
.map(|ident| ident.name.as_str().to_owned());
self.pending_top_level = self.pending_name.is_some() && self.scope.is_empty();
}
_ => {}
}
walk::walk_variable_declarator(self, it);
}
fn visit_object_property(&mut self, it: &ObjectProperty<'a>) {
if names_a_function(&it.value) {
self.pending_name = it.key.static_name().map(std::borrow::Cow::into_owned);
self.pending_top_level = false;
}
walk::walk_object_property(self, it);
}
fn visit_property_definition(&mut self, it: &PropertyDefinition<'a>) {
if it.value.as_ref().is_some_and(names_a_function) {
self.pending_name = it.key.static_name().map(std::borrow::Cow::into_owned);
self.pending_top_level = false;
}
walk::walk_property_definition(self, it);
}
fn visit_function(&mut self, it: &Function<'a>, flags: ScopeFlags) {
let own_name = it.name().map(|n| n.as_str().to_owned());
let top_level = self.scope.is_empty() && (own_name.is_some() || self.pending_top_level);
let name = own_name.or_else(|| self.pending_name.take());
self.pending_top_level = false;
match name {
Some(name) if top_level => {
self.with_top_level_function(name, it.span.start, |v| {
walk::walk_function(v, it, flags);
});
}
name => self.scoped(name, |v| walk::walk_function(v, it, flags)),
}
}
fn visit_arrow_function_expression(&mut self, it: &oxc_ast::ast::ArrowFunctionExpression<'a>) {
let top_level = self.scope.is_empty() && self.pending_top_level;
let name = self.pending_name.take();
self.pending_top_level = false;
match name {
Some(name) if top_level => {
self.with_top_level_function(name, it.span.start, |v| {
walk::walk_arrow_function_expression(v, it);
});
}
name => self.scoped(name, |v| walk::walk_arrow_function_expression(v, it)),
}
}
fn visit_class(&mut self, it: &Class<'a>) {
let name = it
.id
.as_ref()
.map(|id| id.name.as_str().to_owned())
.or_else(|| self.pending_name.take());
self.scoped(name, |v| walk::walk_class(v, it));
}
fn visit_method_definition(&mut self, it: &MethodDefinition<'a>) {
let name = it.key.static_name().map(std::borrow::Cow::into_owned);
self.scoped(name, |v| walk::walk_method_definition(v, it));
}
}
fn hosts_in_text(text: &str) -> Vec<String> {
let mut hosts = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while let Some(rel) = text[i..].find("://") {
let scheme_end = i + rel;
let mut start = scheme_end;
while start > 0 {
let c = bytes[start - 1];
if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'.' | b'-') {
start -= 1;
} else {
break;
}
}
let tail_start = scheme_end + 3;
let end = text[tail_start..]
.find(|c: char| c == '"' || c == '\'' || c == '`' || c.is_whitespace() || c == ')')
.map_or(text.len(), |off| tail_start + off);
let candidate = &text[start..end];
if let Some(host) = host_from_url(candidate) {
hosts.push(host);
}
i = end.max(scheme_end + 3);
}
hosts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_name_handles_scopes_and_subpaths() {
assert_eq!(package_name("openai"), "openai");
assert_eq!(package_name("openai/resources"), "openai");
assert_eq!(package_name("@anthropic-ai/sdk"), "@anthropic-ai/sdk");
assert_eq!(
package_name("@anthropic-ai/sdk/resources"),
"@anthropic-ai/sdk"
);
assert_eq!(package_name("@scope"), "@scope");
assert_eq!(package_name("node:https"), "node:https");
}
#[test]
fn interpolated_scheme_is_not_a_host() {
assert!(hosts_in_text("://internal").is_empty());
}
#[test]
fn embedded_url_in_text_is_found() {
assert_eq!(
hosts_in_text("see https://api.example.com/v1 for details"),
["api.example.com"]
);
}
}