1use std::collections::BTreeSet;
25
26use oxc_allocator::Allocator;
27use oxc_ast::{
28 AstKind,
29 ast::{
30 AssignmentTarget, BindingPattern, Expression, FunctionType, IdentifierReference,
31 SimpleAssignmentTarget, UnaryOperator, UpdateOperator,
32 },
33};
34use oxc_parser::Parser;
35use oxc_semantic::{NodeId, Semantic, SemanticBuilder, SymbolId};
36use oxc_span::{GetSpan, SourceType, Span};
37use serde::{Deserialize, Serialize};
38
39use crate::js_instrumenter::line_and_utf16_column;
40
41const IO_METHODS: &[&str] = &[
43 "write",
44 "end",
45 "send",
46 "json",
47 "status",
48 "writeHead",
49 "setHeader",
50 "sendStatus",
51 "redirect",
52 "emit",
53 "kill",
54 "listen",
55 "close",
56 "connect",
57 "handleRequest",
58 "terminate",
59 "destroy",
60 "exit",
61 "request",
62];
63const IO_FUNCTIONS: &[&str] = &["spawn", "exec", "execFile", "fork", "createServer"];
65const SCHEDULE: &[&str] = &[
66 "setTimeout",
67 "setInterval",
68 "clearTimeout",
69 "clearInterval",
70 "setImmediate",
71 "queueMicrotask",
72];
73const MUTATORS: &[&str] = &[
75 "set", "delete", "clear", "push", "pop", "shift", "unshift", "splice", "add",
76];
77const PURE_GLOBALS: &[&str] = &[
79 "Math",
80 "JSON",
81 "Object",
82 "Array",
83 "Number",
84 "String",
85 "Boolean",
86 "Symbol",
87 "BigInt",
88 "Date",
89 "RegExp",
90 "Promise",
91 "Reflect",
92 "Proxy",
93 "Intl",
94 "Map",
95 "Set",
96 "WeakMap",
97 "WeakSet",
98 "WeakRef",
99 "Error",
100 "TypeError",
101 "RangeError",
102 "SyntaxError",
103 "parseInt",
104 "parseFloat",
105 "isNaN",
106 "isFinite",
107 "encodeURIComponent",
108 "decodeURIComponent",
109 "encodeURI",
110 "decodeURI",
111 "structuredClone",
112 "atob",
113 "btoa",
114 "URL",
115 "URLSearchParams",
116 "TextEncoder",
117 "TextDecoder",
118 "Buffer",
119 "Response",
120 "Request",
121 "Headers",
122 "FormData",
123 "Blob",
124 "ArrayBuffer",
125 "Uint8Array",
126 "Function",
127 "globalThis",
128 "undefined",
129 "NaN",
130 "Infinity",
131 "AbortController",
132 "AbortSignal",
133 "Event",
134 "CustomEvent",
135 "queueMicrotask",
136];
137const GLOBAL_ROOTS: &[&str] = &["globalThis", "window", "self", "global"];
139
140pub const LIMIT_CONTAINER_TYPES: &str = "js-sites-mutator-needs-types";
142pub const LIMIT_PROJECT_TYPES: &str = "js-sites-state-call-needs-types";
144pub const LIMIT_THIS_CALL_TYPES: &str = "js-sites-this-call-needs-types";
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "kebab-case")]
152pub enum SiteCategory {
153 IoCall,
154 Schedule,
155 StateWrite,
156 ParamWrite,
157 Return,
158 CallbackReturn,
159 Throw,
160 Log,
161 ExternalCall,
162}
163
164impl SiteCategory {
165 pub fn as_str(self) -> &'static str {
166 match self {
167 SiteCategory::IoCall => "io-call",
168 SiteCategory::Schedule => "schedule",
169 SiteCategory::StateWrite => "state-write",
170 SiteCategory::ParamWrite => "param-write",
171 SiteCategory::Return => "return",
172 SiteCategory::CallbackReturn => "callback-return",
173 SiteCategory::Throw => "throw",
174 SiteCategory::Log => "log",
175 SiteCategory::ExternalCall => "external-call",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "lowercase")]
182pub enum Classification {
183 Contractual,
184 Incidental,
185 Review,
186}
187
188impl Classification {
189 pub fn as_str(self) -> &'static str {
190 match self {
191 Classification::Contractual => "contractual",
192 Classification::Incidental => "incidental",
193 Classification::Review => "review",
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200pub struct SitePosition {
201 pub line: usize,
202 pub column: usize,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct SiteMeta {
208 pub id: String,
209 pub file: String,
210 pub category: SiteCategory,
211 pub classification: Classification,
212 pub start: SitePosition,
213 pub end: SitePosition,
214 pub function: String,
216 pub owner: String,
218 pub exported: bool,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub note: Option<String>,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
223 pub chain: Vec<String>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub method: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub arg0: Option<String>,
228 pub text: String,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct SiteDiscovery {
234 pub sites: Vec<SiteMeta>,
235 pub limitations: Vec<String>,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum SiteError {
241 UnknownSourceType(String),
242 Parse(Vec<String>),
243}
244
245impl std::fmt::Display for SiteError {
246 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 SiteError::UnknownSourceType(message) => {
249 write!(formatter, "unknown source type: {message}")
250 }
251 SiteError::Parse(errors) => write!(formatter, "parse errors: {}", errors.join("; ")),
252 }
253 }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259enum Locality {
260 Local,
261 Alias,
263 Param,
264 NonLocal,
265 This,
266 Global,
267}
268
269impl Locality {
270 fn escapes(self) -> bool {
272 matches!(
273 self,
274 Locality::NonLocal | Locality::This | Locality::Global | Locality::Alias
275 )
276 }
277
278 fn as_str(self) -> &'static str {
279 match self {
280 Locality::Local => "local",
281 Locality::Alias => "alias",
282 Locality::Param => "param",
283 Locality::NonLocal => "nonlocal",
284 Locality::This => "this",
285 Locality::Global => "global",
286 }
287 }
288}
289
290enum Root<'a, 'b> {
292 This,
293 Identifier(&'b IdentifierReference<'a>),
294 Other,
295}
296
297pub fn discover_effect_sites(file: &str, source: &str) -> Result<SiteDiscovery, SiteError> {
298 let source_type = SourceType::from_path(std::path::Path::new(file))
299 .map_err(|error| SiteError::UnknownSourceType(error.to_string()))?;
300 let allocator = Allocator::default();
301 let parsed = Parser::new(&allocator, source, source_type).parse();
302 if !parsed.errors.is_empty() {
303 return Err(SiteError::Parse(
304 parsed.errors.iter().map(ToString::to_string).collect(),
305 ));
306 }
307 let semantic = SemanticBuilder::new().build(&parsed.program).semantic;
308 let mut finder = Finder {
309 file,
310 source,
311 semantic: &semantic,
312 sites: Vec::new(),
313 limitations: BTreeSet::new(),
314 };
315 finder.run();
316 Ok(SiteDiscovery {
317 sites: finder.sites,
318 limitations: finder.limitations.into_iter().collect(),
319 })
320}
321
322struct Finder<'a, 's> {
323 file: &'s str,
324 source: &'s str,
325 semantic: &'s Semantic<'a>,
326 sites: Vec<SiteMeta>,
327 limitations: BTreeSet<String>,
328}
329
330impl<'a> Finder<'a, '_> {
331 fn run(&mut self) {
332 let ids: Vec<NodeId> = self
334 .semantic
335 .nodes()
336 .iter_enumerated()
337 .map(|(id, _)| id)
338 .collect();
339 for id in ids {
340 self.classify(id);
341 }
342 }
343
344 fn classify(&mut self, id: NodeId) {
345 match self.semantic.nodes().kind(id) {
346 AstKind::CallExpression(call) => self.classify_call(id, call),
347 AstKind::AssignmentExpression(assignment) => {
348 let how = assignment.operator.as_str();
349 match &assignment.left {
350 AssignmentTarget::AssignmentTargetIdentifier(identifier) => {
351 self.write_to_identifier(id, identifier, how);
352 }
353 AssignmentTarget::StaticMemberExpression(member) => {
354 self.write_to_member(id, &member.object, how);
355 }
356 AssignmentTarget::ComputedMemberExpression(member) => {
357 self.write_to_member(id, &member.object, how);
358 }
359 AssignmentTarget::PrivateFieldExpression(member) => {
360 self.write_to_member(id, &member.object, how);
361 }
362 _ => {}
363 }
364 }
365 AstKind::UpdateExpression(update) => {
366 let how = match update.operator {
367 UpdateOperator::Increment => "++",
368 UpdateOperator::Decrement => "--",
369 };
370 match &update.argument {
371 SimpleAssignmentTarget::AssignmentTargetIdentifier(identifier) => {
372 self.write_to_identifier(id, identifier, how);
373 }
374 SimpleAssignmentTarget::StaticMemberExpression(member) => {
375 self.write_to_member(id, &member.object, how);
376 }
377 SimpleAssignmentTarget::ComputedMemberExpression(member) => {
378 self.write_to_member(id, &member.object, how);
379 }
380 SimpleAssignmentTarget::PrivateFieldExpression(member) => {
381 self.write_to_member(id, &member.object, how);
382 }
383 _ => {}
384 }
385 }
386 AstKind::UnaryExpression(unary) if unary.operator == UnaryOperator::Delete => {
387 let target = unwrap_expr(&unary.argument);
388 if let Some(object) = member_object(target) {
389 self.write_to_member(id, object, "delete");
390 }
391 }
392 AstKind::ReturnStatement(statement) if statement.argument.is_some() => {
393 let category = if self
394 .enclosing_function(id)
395 .is_some_and(|function| self.is_callback_function(function))
396 {
397 SiteCategory::CallbackReturn
398 } else {
399 SiteCategory::Return
400 };
401 self.push(id, category, Classification::Contractual, None);
402 }
403 AstKind::ArrowFunctionExpression(arrow) => {
404 if !arrow.expression {
407 return;
408 }
409 let Some(oxc_ast::ast::Statement::ExpressionStatement(body)) =
410 arrow.body.statements.first()
411 else {
412 return;
413 };
414 if matches!(
415 unwrap_expr(&body.expression),
416 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
417 ) {
418 return;
419 }
420 let category = if self.is_callback_function(id) {
421 SiteCategory::CallbackReturn
422 } else {
423 SiteCategory::Return
424 };
425 self.push_span(
426 body.expression.span(),
427 id,
428 category,
429 Classification::Contractual,
430 Some("expression body".to_owned()),
431 None,
432 );
433 }
434 AstKind::ThrowStatement(_) => {
435 self.push(id, SiteCategory::Throw, Classification::Contractual, None);
436 }
437 _ => {}
438 }
439 }
440
441 fn classify_call(&mut self, id: NodeId, call: &oxc_ast::ast::CallExpression<'a>) {
442 let callee = unwrap_expr(&call.callee);
443 let Some(method) = callee_method(callee) else {
444 return;
445 };
446 let chain = chain_names(callee);
447 if is_log_callee(&chain) {
448 let direct = chain.first().is_some_and(|name| name == "console");
452 let (classification, note) = if direct {
453 (Classification::Review, "console")
454 } else {
455 (Classification::Incidental, "logger")
456 };
457 self.push(id, SiteCategory::Log, classification, Some(note.to_owned()));
458 return;
459 }
460 let plain_identifier = matches!(callee, Expression::Identifier(_));
461 if plain_identifier && SCHEDULE.contains(&method) {
462 self.push(
463 id,
464 SiteCategory::Schedule,
465 Classification::Contractual,
466 None,
467 );
468 return;
469 }
470 if plain_identifier && IO_FUNCTIONS.contains(&method) {
471 self.push(id, SiteCategory::IoCall, Classification::Contractual, None);
472 return;
473 }
474 let receiver = member_object(callee);
475 if receiver.is_some() && IO_METHODS.contains(&method) {
476 let classification = if self.inside_log_call(id) {
477 Classification::Incidental
478 } else {
479 Classification::Contractual
480 };
481 self.push(id, SiteCategory::IoCall, classification, None);
482 return;
483 }
484 if let Some(receiver) = receiver {
485 let locality = self.locality(root_kind(root_of(receiver)), id);
486 if MUTATORS.contains(&method) && (locality.escapes() || locality == Locality::Param) {
491 self.limitations.insert(LIMIT_CONTAINER_TYPES.to_owned());
492 let (category, classification) = if locality == Locality::Param {
493 (SiteCategory::ParamWrite, Classification::Review)
494 } else {
495 (SiteCategory::StateWrite, Classification::Contractual)
496 };
497 let note = if locality == Locality::Param {
498 format!("mutator:{method}")
499 } else {
500 format!("mutator:{method} {}", locality.as_str())
501 };
502 self.push(id, category, classification, Some(note));
503 return;
504 }
505 if matches!(locality, Locality::NonLocal | Locality::Global) {
508 self.limitations.insert(LIMIT_PROJECT_TYPES.to_owned());
509 }
510 }
511 let root = root_of(callee);
512 if let Expression::Identifier(identifier) = root {
513 let symbol = self.symbol_of_reference(identifier);
514 match symbol.map(|symbol| self.declaration_kind(symbol)) {
515 Some(DeclarationKind::Import) => {
516 self.push(
517 id,
518 SiteCategory::ExternalCall,
519 Classification::Review,
520 Some("import".to_owned()),
521 );
522 }
523 Some(DeclarationKind::Parameter) | Some(DeclarationKind::BindingElement) => {
524 self.push(
525 id,
526 SiteCategory::ExternalCall,
527 Classification::Review,
528 Some("param".to_owned()),
529 );
530 }
531 _ => {
532 let name = identifier.name.as_str();
536 let head = if GLOBAL_ROOTS.contains(&name) {
537 chain.get(1).map(String::as_str)
538 } else {
539 Some(name)
540 };
541 let callee_is_pure = plain_identifier && PURE_GLOBALS.contains(&method);
542 if symbol.is_none()
543 && head
544 .is_some_and(|head| !head.is_empty() && !PURE_GLOBALS.contains(&head))
545 && !callee_is_pure
546 {
547 self.push(
548 id,
549 SiteCategory::ExternalCall,
550 Classification::Review,
551 Some("global".to_owned()),
552 );
553 }
554 }
555 }
556 } else if matches!(root, Expression::ThisExpression(_)) && receiver.is_some() {
557 if self.this_call_is_not_a_method(id, method) {
560 if chain.len() > 2 {
563 self.limitations.insert(LIMIT_THIS_CALL_TYPES.to_owned());
564 }
565 self.push(
566 id,
567 SiteCategory::ExternalCall,
568 Classification::Review,
569 Some("this-callback".to_owned()),
570 );
571 }
572 }
573 }
574
575 fn write_to_identifier(&mut self, id: NodeId, target: &IdentifierReference<'a>, how: &str) {
576 let locality = self.locality(Root::Identifier(target), id);
577 if matches!(locality, Locality::NonLocal | Locality::Global) {
578 self.push(
579 id,
580 SiteCategory::StateWrite,
581 Classification::Contractual,
582 Some(format!("{how} variable {}", locality.as_str())),
583 );
584 }
585 }
586
587 fn write_to_member(&mut self, id: NodeId, object: &Expression<'a>, how: &str) {
588 let locality = self.locality(root_kind(root_of(object)), id);
589 if locality.escapes() {
590 self.push(
591 id,
592 SiteCategory::StateWrite,
593 Classification::Contractual,
594 Some(format!("{how} property {}", locality.as_str())),
595 );
596 } else if locality == Locality::Param {
597 self.push(
598 id,
599 SiteCategory::ParamWrite,
600 Classification::Review,
601 Some(format!("{how} property")),
602 );
603 }
604 }
605
606 fn symbol_of_reference(&self, identifier: &IdentifierReference<'a>) -> Option<SymbolId> {
611 identifier
612 .reference_id
613 .get()
614 .and_then(|reference| self.semantic.scoping().get_reference(reference).symbol_id())
615 }
616
617 fn declaration_kind(&self, symbol: SymbolId) -> DeclarationKind {
618 let declaration = self.semantic.symbol_declaration(symbol).id();
619 let own = std::iter::once(self.semantic.nodes().kind(declaration));
622 for kind in own.chain(self.semantic.nodes().ancestor_kinds(declaration)) {
623 match kind {
624 AstKind::ImportDeclaration(_) => return DeclarationKind::Import,
625 AstKind::FormalParameter(_) => return DeclarationKind::Parameter,
626 AstKind::VariableDeclarator(declarator) => {
627 return if matches!(declarator.id, BindingPattern::BindingIdentifier(_)) {
628 DeclarationKind::Variable
629 } else {
630 DeclarationKind::BindingElement
631 };
632 }
633 AstKind::Function(_)
634 | AstKind::ArrowFunctionExpression(_)
635 | AstKind::Program(_) => {
636 break;
637 }
638 _ => {}
639 }
640 }
641 DeclarationKind::Other
642 }
643
644 fn locality(&self, root: Root<'a, '_>, at: NodeId) -> Locality {
646 let identifier = match root {
647 Root::This => return Locality::This,
648 Root::Other => return Locality::Local,
649 Root::Identifier(identifier) => identifier,
650 };
651 let Some(symbol) = self.symbol_of_reference(identifier) else {
652 return Locality::Global;
653 };
654 let declaration = self.semantic.symbol_declaration(symbol).id();
655 let kind = self.declaration_kind(symbol);
656 if kind == DeclarationKind::Import {
657 return Locality::NonLocal;
659 }
660 let declaring = self.enclosing_function(declaration);
661 let using = self.enclosing_function(at);
662 match kind {
663 DeclarationKind::Parameter => {
664 return if declaring == using {
665 Locality::Param
666 } else {
667 Locality::NonLocal
668 };
669 }
670 DeclarationKind::BindingElement => {
671 return if declaring == using {
672 Locality::Local
673 } else {
674 Locality::NonLocal
675 };
676 }
677 _ => {}
678 }
679 let Some(declaring) = declaring else {
680 return Locality::NonLocal; };
682 if Some(declaring) != using {
683 return Locality::NonLocal;
684 }
685 let own = std::iter::once(self.semantic.nodes().kind(declaration));
688 for kind in own.chain(self.semantic.nodes().ancestor_kinds(declaration)) {
689 if let AstKind::VariableDeclarator(declarator) = kind {
690 return match declarator.init.as_ref().map(unwrap_expr) {
691 None => Locality::Local,
692 Some(init) if fresh_value(init) => Locality::Local,
693 Some(_) => Locality::Alias,
694 };
695 }
696 }
697 Locality::Local
698 }
699
700 fn this_call_is_not_a_method(&self, at: NodeId, name: &str) -> bool {
704 let mut class_body = None;
705 for (node_id, node) in self.semantic.nodes().ancestors_enumerated(at) {
706 if matches!(node.kind(), AstKind::ClassBody(_)) {
707 class_body = Some(node_id);
708 break;
709 }
710 }
711 let Some(class_body) = class_body else {
712 return false;
713 };
714 let AstKind::ClassBody(body) = self.semantic.nodes().kind(class_body) else {
715 return false;
716 };
717 let declares_method = body.body.iter().any(|element| match element {
718 oxc_ast::ast::ClassElement::MethodDefinition(method) => {
719 method.key.static_name().as_deref() == Some(name)
720 }
721 _ => false,
722 });
723 !declares_method
724 }
725
726 fn enclosing_function(&self, id: NodeId) -> Option<NodeId> {
729 if is_function_kind(self.semantic.nodes().kind(id)) {
730 return Some(id);
731 }
732 self.semantic
733 .nodes()
734 .ancestors_enumerated(id)
735 .find(|(_, node)| is_function_kind(node.kind()))
736 .map(|(node_id, _)| node_id)
737 }
738
739 fn is_callback_function(&self, function: NodeId) -> bool {
742 match self.semantic.nodes().kind(function) {
743 AstKind::Function(declaration) => {
744 if declaration.r#type == FunctionType::FunctionDeclaration {
745 return false;
746 }
747 }
748 AstKind::ArrowFunctionExpression(_) => {}
749 _ => return false,
750 }
751 !matches!(
755 self.semantic.nodes().parent_kind(function),
756 AstKind::VariableDeclarator(_) | AstKind::MethodDefinition(_)
757 )
758 }
759
760 fn function_name(&self, id: NodeId) -> String {
761 match self.enclosing_function(id) {
762 Some(function) => self.name_of_function(function),
763 None => "<module>".to_owned(),
764 }
765 }
766
767 fn name_of_function(&self, function: NodeId) -> String {
768 let kind = self.semantic.nodes().kind(function);
769 if let AstKind::Function(declaration) = kind
770 && declaration.r#type == FunctionType::FunctionDeclaration
771 && let Some(name) = &declaration.id
772 {
773 return name.name.to_string();
774 }
775 let parent = self.semantic.nodes().parent_id(function);
776 match self.semantic.nodes().kind(parent) {
777 AstKind::MethodDefinition(method) => {
778 let class = self.enclosing_class_name(parent);
779 let name = if method.kind.is_constructor() {
780 "constructor".to_owned()
781 } else {
782 method.key.static_name().unwrap_or_default().to_string()
783 };
784 format!("{class}.{name}")
785 }
786 AstKind::VariableDeclarator(declarator) => match &declarator.id {
787 BindingPattern::BindingIdentifier(identifier) => identifier.name.to_string(),
788 _ => self.anonymous_name(kind.span()),
789 },
790 AstKind::ObjectProperty(property) => property
791 .key
792 .static_name()
793 .map(|name| name.to_string())
794 .unwrap_or_else(|| self.anonymous_name(kind.span())),
795 AstKind::PropertyDefinition(property) => property
796 .key
797 .static_name()
798 .map(|name| name.to_string())
799 .unwrap_or_else(|| self.anonymous_name(kind.span())),
800 AstKind::AssignmentExpression(assignment) => {
801 format!("{} =", self.text_of(assignment.left.span(), 40))
802 }
803 AstKind::CallExpression(call) => {
804 format!("{}(cb)", self.text_of(call.callee.span(), 40))
805 }
806 _ => self.anonymous_name(kind.span()),
807 }
808 }
809
810 fn anonymous_name(&self, span: Span) -> String {
811 let (line, _) = line_and_utf16_column(self.source, span.start as usize);
812 format!("anonymous@{line}")
813 }
814
815 fn enclosing_class_name(&self, id: NodeId) -> String {
816 for kind in self.semantic.nodes().ancestor_kinds(id) {
817 if let AstKind::Class(class) = kind {
818 return class
819 .id
820 .as_ref()
821 .map(|name| name.name.to_string())
822 .unwrap_or_else(|| "?".to_owned());
823 }
824 }
825 "?".to_owned()
826 }
827
828 fn owner_name(&self, id: NodeId) -> String {
830 let mut function = self.enclosing_function(id);
831 while let Some(candidate) = function {
832 if !self.is_callback_function(candidate) {
833 return self.name_of_function(candidate);
834 }
835 function = self
836 .semantic
837 .nodes()
838 .ancestors_enumerated(candidate)
839 .find(|(_, node)| is_function_kind(node.kind()))
840 .map(|(node_id, _)| node_id);
841 }
842 "<module>".to_owned()
843 }
844
845 fn is_exported(&self, id: NodeId) -> bool {
846 let Some(function) = self.enclosing_function(id) else {
847 return false;
848 };
849 for kind in self.semantic.nodes().ancestor_kinds(function) {
850 match kind {
851 AstKind::ExportNamedDeclaration(_) | AstKind::ExportDefaultDeclaration(_) => {
852 return true;
853 }
854 AstKind::Program(_) => return false,
855 _ => {}
856 }
857 }
858 false
859 }
860
861 fn inside_log_call(&self, id: NodeId) -> bool {
864 for node in self.semantic.nodes().ancestors(id) {
865 if let AstKind::CallExpression(call) = node.kind()
866 && is_log_callee(&chain_names(unwrap_expr(&call.callee)))
867 {
868 return true;
869 }
870 if is_statement_kind(node.kind()) {
871 return false;
872 }
873 }
874 false
875 }
876
877 fn push(
882 &mut self,
883 id: NodeId,
884 category: SiteCategory,
885 classification: Classification,
886 note: Option<String>,
887 ) {
888 let kind = self.semantic.nodes().kind(id);
889 let call = match kind {
890 AstKind::CallExpression(call) => Some(call),
891 _ => None,
892 };
893 self.push_span(kind.span(), id, category, classification, note, call);
894 }
895
896 fn push_span(
897 &mut self,
898 span: Span,
899 at: NodeId,
900 category: SiteCategory,
901 classification: Classification,
902 note: Option<String>,
903 call: Option<&oxc_ast::ast::CallExpression<'a>>,
904 ) {
905 let (start_line, start_column) = line_and_utf16_column(self.source, span.start as usize);
906 let (end_line, end_column) = line_and_utf16_column(self.source, span.end as usize);
907 let (chain, method, arg0) = match call {
908 Some(call) => {
909 let callee = unwrap_expr(&call.callee);
910 (
911 chain_names(callee),
912 callee_method(callee).map(str::to_owned),
913 call.arguments
914 .first()
915 .map(|argument| self.text_of(argument.span(), 240)),
916 )
917 }
918 None => (Vec::new(), None, None),
919 };
920 self.sites.push(SiteMeta {
921 id: format!("{}#{}", self.file, self.sites.len() + 1),
922 file: self.file.to_owned(),
923 category,
924 classification,
925 start: SitePosition {
926 line: start_line,
927 column: start_column,
928 },
929 end: SitePosition {
930 line: end_line,
931 column: end_column,
932 },
933 function: self.function_name(at),
934 owner: self.owner_name(at),
935 exported: self.is_exported(at),
936 note,
937 chain,
938 method,
939 arg0,
940 text: self.text_of(span, 100),
941 });
942 }
943
944 fn text_of(&self, span: Span, limit: usize) -> String {
946 let raw = &self.source[span.start as usize..span.end as usize];
947 let mut collapsed = String::with_capacity(raw.len());
948 let mut in_space = false;
949 for character in raw.chars() {
950 if character.is_whitespace() {
951 if !in_space {
952 collapsed.push(' ');
953 in_space = true;
954 }
955 } else {
956 collapsed.push(character);
957 in_space = false;
958 }
959 }
960 collapsed.chars().take(limit).collect()
961 }
962}
963
964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
965enum DeclarationKind {
966 Import,
967 Parameter,
968 BindingElement,
969 Variable,
970 Other,
971}
972
973fn is_function_kind(kind: AstKind<'_>) -> bool {
974 matches!(
975 kind,
976 AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
977 )
978}
979
980fn is_statement_kind(kind: AstKind<'_>) -> bool {
981 matches!(
982 kind,
983 AstKind::ExpressionStatement(_)
984 | AstKind::VariableDeclaration(_)
985 | AstKind::ReturnStatement(_)
986 | AstKind::IfStatement(_)
987 | AstKind::ForStatement(_)
988 | AstKind::ForInStatement(_)
989 | AstKind::ForOfStatement(_)
990 | AstKind::WhileStatement(_)
991 | AstKind::DoWhileStatement(_)
992 | AstKind::ThrowStatement(_)
993 | AstKind::TryStatement(_)
994 | AstKind::SwitchStatement(_)
995 | AstKind::BlockStatement(_)
996 | AstKind::BreakStatement(_)
997 | AstKind::ContinueStatement(_)
998 | AstKind::LabeledStatement(_)
999 | AstKind::WithStatement(_)
1000 )
1001}
1002
1003fn unwrap_expr<'a, 'b>(expression: &'b Expression<'a>) -> &'b Expression<'a> {
1005 let mut current = expression;
1006 loop {
1007 current = match current {
1008 Expression::ParenthesizedExpression(inner) => &inner.expression,
1009 Expression::TSNonNullExpression(inner) => &inner.expression,
1010 Expression::TSAsExpression(inner) => &inner.expression,
1011 Expression::TSSatisfiesExpression(inner) => &inner.expression,
1012 Expression::TSTypeAssertion(inner) => &inner.expression,
1013 other => return other,
1014 };
1015 }
1016}
1017
1018fn member_object<'a, 'b>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> {
1020 match expression {
1021 Expression::StaticMemberExpression(member) => Some(&member.object),
1022 Expression::ComputedMemberExpression(member) => Some(&member.object),
1023 Expression::PrivateFieldExpression(member) => Some(&member.object),
1024 _ => None,
1025 }
1026}
1027
1028fn callee_method<'a>(callee: &'a Expression<'_>) -> Option<&'a str> {
1029 match callee {
1030 Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
1031 Expression::ComputedMemberExpression(member) => match &member.expression {
1032 Expression::StringLiteral(literal) => Some(literal.value.as_str()),
1033 _ => None,
1034 },
1035 Expression::Identifier(identifier) => Some(identifier.name.as_str()),
1036 _ => None,
1037 }
1038}
1039
1040fn chain_names(expression: &Expression<'_>) -> Vec<String> {
1043 let mut names = Vec::new();
1044 let mut current = unwrap_expr(expression);
1045 loop {
1046 match current {
1047 Expression::StaticMemberExpression(member) => {
1048 names.push(member.property.name.to_string());
1049 current = unwrap_expr(&member.object);
1050 }
1051 Expression::ComputedMemberExpression(member) => current = unwrap_expr(&member.object),
1052 Expression::PrivateFieldExpression(member) => current = unwrap_expr(&member.object),
1053 Expression::CallExpression(call) => current = unwrap_expr(&call.callee),
1054 _ => break,
1055 }
1056 }
1057 match current {
1058 Expression::Identifier(identifier) => names.push(identifier.name.to_string()),
1059 Expression::ThisExpression(_) => names.push("this".to_owned()),
1060 _ => {}
1061 }
1062 names.reverse();
1063 names
1064}
1065
1066fn root_of<'a, 'b>(expression: &'b Expression<'a>) -> &'b Expression<'a> {
1068 let mut current = unwrap_expr(expression);
1069 loop {
1070 current = match current {
1071 Expression::StaticMemberExpression(member) => unwrap_expr(&member.object),
1072 Expression::ComputedMemberExpression(member) => unwrap_expr(&member.object),
1073 Expression::PrivateFieldExpression(member) => unwrap_expr(&member.object),
1074 Expression::CallExpression(call) => unwrap_expr(&call.callee),
1075 other => return other,
1076 };
1077 }
1078}
1079
1080fn root_kind<'a, 'b>(expression: &'b Expression<'a>) -> Root<'a, 'b> {
1081 match expression {
1082 Expression::ThisExpression(_) => Root::This,
1083 Expression::Identifier(identifier) => Root::Identifier(identifier),
1084 _ => Root::Other,
1085 }
1086}
1087
1088fn fresh_value(expression: &Expression<'_>) -> bool {
1090 matches!(
1091 expression,
1092 Expression::ObjectExpression(_)
1093 | Expression::ArrayExpression(_)
1094 | Expression::NewExpression(_)
1095 | Expression::StringLiteral(_)
1096 | Expression::NumericLiteral(_)
1097 | Expression::TemplateLiteral(_)
1098 | Expression::BooleanLiteral(_)
1099 | Expression::NullLiteral(_)
1100 )
1101}
1102
1103fn is_log_callee(chain: &[String]) -> bool {
1104 match chain.first().map(String::as_str) {
1105 Some("logger" | "console") => true,
1106 Some("this") => chain.get(1).is_some_and(|name| name == "logger"),
1107 _ => false,
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114
1115 fn sites(source: &str) -> Vec<SiteMeta> {
1116 discover_effect_sites("src/a.ts", source)
1117 .expect("the fixture parses")
1118 .sites
1119 }
1120
1121 fn categories(source: &str) -> Vec<(&'static str, &'static str, Option<String>)> {
1122 sites(source)
1123 .into_iter()
1124 .map(|site| {
1125 (
1126 site.category.as_str(),
1127 site.classification.as_str(),
1128 site.note,
1129 )
1130 })
1131 .collect()
1132 }
1133
1134 #[test]
1135 fn a_return_with_a_value_is_a_contractual_site() {
1136 let found = sites("export function f(x: number) { return x + 1 }");
1137 assert_eq!(found.len(), 1);
1138 assert_eq!(found[0].category, SiteCategory::Return);
1139 assert_eq!(found[0].classification, Classification::Contractual);
1140 assert_eq!(found[0].owner, "f");
1141 assert!(found[0].exported);
1142 assert_eq!(found[0].start.line, 1);
1143 }
1144
1145 #[test]
1146 fn a_bare_return_is_not_a_site() {
1147 assert!(sites("function f() { return }").is_empty());
1148 }
1149
1150 #[test]
1151 fn a_return_inside_a_callback_belongs_to_the_callback() {
1152 let found = sites("function f(xs: number[]) { return xs.map((x) => x * 2) }");
1153 let callback = found
1154 .iter()
1155 .find(|site| site.category == SiteCategory::CallbackReturn)
1156 .expect("the arrow body is a callback return");
1157 assert_eq!(callback.owner, "f");
1158 assert_eq!(callback.note.as_deref(), Some("expression body"));
1159 }
1160
1161 #[test]
1162 fn an_arrow_returning_an_arrow_is_a_factory_not_a_value() {
1163 let found = sites("export const f = (a: number) => (b: number) => a + b");
1164 assert_eq!(found.len(), 1);
1166 assert_eq!(found[0].function, "anonymous@1");
1167 }
1168
1169 #[test]
1170 fn a_throw_is_a_contractual_site() {
1171 let found = sites("function f() { throw new Error('no') }");
1172 assert_eq!(found[0].category, SiteCategory::Throw);
1173 }
1174
1175 #[test]
1176 fn a_console_call_needs_review_and_a_logger_call_is_incidental() {
1177 assert_eq!(
1178 categories("function f() { console.log('x') }"),
1179 vec![("log", "review", Some("console".to_owned()))]
1180 );
1181 assert_eq!(
1182 categories("function f(logger: any) { logger.info('x') }"),
1183 vec![("log", "incidental", Some("logger".to_owned()))]
1184 );
1185 }
1186
1187 #[test]
1188 fn work_done_inside_a_log_call_is_incidental_too() {
1189 let found = categories("function f(res: any) { console.log(res.write('x')) }");
1190 assert!(found.contains(&("io-call", "incidental", None)));
1191 }
1192
1193 #[test]
1194 fn scheduling_and_outbound_calls_are_contractual() {
1195 assert_eq!(
1196 categories("function f() { setTimeout(() => 1, 5) }")
1197 .into_iter()
1198 .filter(|(category, _, _)| *category == "schedule")
1199 .count(),
1200 1
1201 );
1202 assert!(
1203 categories("function f(res: any) { res.json({ a: 1 }) }").contains(&(
1204 "io-call",
1205 "contractual",
1206 None
1207 ))
1208 );
1209 }
1210
1211 #[test]
1212 fn a_write_to_a_module_variable_is_a_state_write_and_a_local_one_is_not() {
1213 let found = categories("let total = 0\nfunction f() { total = 1 }");
1214 assert_eq!(
1215 found,
1216 vec![(
1217 "state-write",
1218 "contractual",
1219 Some("= variable nonlocal".to_owned())
1220 )]
1221 );
1222 assert!(sites("function f() { let total = 0; total = 1; }").is_empty());
1223 }
1224
1225 #[test]
1226 fn a_write_through_this_is_a_state_write() {
1227 let found = categories("class A { private x = 0; set(v: number) { this.x = v } }");
1228 assert!(found.contains(&(
1229 "state-write",
1230 "contractual",
1231 Some("= property this".to_owned())
1232 )));
1233 }
1234
1235 #[test]
1236 fn a_write_to_a_fresh_local_object_is_not_a_site_but_an_aliased_one_is() {
1237 assert!(sites("function f() { const o = {}; o.a = 1; }").is_empty());
1238 let aliased = categories("function f(get: any) { const o = get(); o.a = 1; }");
1239 assert!(aliased.contains(&(
1240 "state-write",
1241 "contractual",
1242 Some("= property alias".to_owned())
1243 )));
1244 }
1245
1246 #[test]
1247 fn a_write_through_a_parameter_is_a_review_site() {
1248 let found = categories("function f(target: any) { target.a = 1 }");
1249 assert!(found.contains(&("param-write", "review", Some("= property".to_owned()))));
1250 }
1251
1252 #[test]
1253 fn a_call_through_an_import_or_a_parameter_needs_review() {
1254 assert!(
1255 categories("import { helper } from './h'\nfunction f() { return helper() }")
1256 .contains(&("external-call", "review", Some("import".to_owned())))
1257 );
1258 assert!(
1259 categories("function f(admin: any) { return admin.find() }").contains(&(
1260 "external-call",
1261 "review",
1262 Some("param".to_owned())
1263 ))
1264 );
1265 }
1266
1267 #[test]
1268 fn a_call_through_an_ambient_global_needs_review_but_a_pure_builtin_does_not() {
1269 assert!(
1270 categories("function f() { return shopify.toast.show('x') }").contains(&(
1271 "external-call",
1272 "review",
1273 Some("global".to_owned())
1274 ))
1275 );
1276 let pure = categories("function f(a: unknown) { return JSON.stringify(a) }");
1277 assert_eq!(pure, vec![("return", "contractual", None)]);
1278 let coerced = categories("function f(a: string) { return Number(a) }");
1279 assert_eq!(coerced, vec![("return", "contractual", None)]);
1280 }
1281
1282 #[test]
1283 fn a_call_on_an_injected_field_needs_review_but_a_method_call_does_not() {
1284 let field = categories("class A { onMessage: any; run() { return this.onMessage(1) } }");
1285 assert!(field.contains(&("external-call", "review", Some("this-callback".to_owned()))));
1286 let method = sites("class A { helper() { return 1 } run() { return this.helper() } }");
1287 assert!(
1288 !method
1289 .iter()
1290 .any(|site| site.note.as_deref() == Some("this-callback"))
1291 );
1292 }
1293
1294 #[test]
1295 fn a_mutator_call_is_declared_a_limitation_rather_than_guessed() {
1296 let found = discover_effect_sites(
1297 "src/a.ts",
1298 "class A { private sessions = new Map(); add(k: string) { this.sessions.set(k, 1) } }",
1299 )
1300 .expect("parses");
1301 assert!(
1302 found
1303 .limitations
1304 .iter()
1305 .any(|limit| limit == LIMIT_CONTAINER_TYPES)
1306 );
1307 }
1308
1309 #[test]
1310 fn a_method_is_named_with_its_class_and_a_callback_with_what_holds_it() {
1311 let found = sites("class A { run(xs: number[]) { return xs.map((x) => x + 1) } }");
1312 let method_site = found
1313 .iter()
1314 .find(|site| site.category == SiteCategory::Return)
1315 .expect("the method returns");
1316 assert_eq!(method_site.owner, "A.run");
1317 assert_eq!(method_site.function, "A.run");
1318 let callback = found
1319 .iter()
1320 .find(|site| site.category == SiteCategory::CallbackReturn)
1321 .expect("the arrow returns");
1322 assert_eq!(callback.owner, "A.run");
1323 assert_eq!(callback.function, "xs.map(cb)");
1324 }
1325
1326 #[test]
1327 fn the_text_of_a_site_collapses_whitespace() {
1328 let found = sites("function f() {\n return {\n a: 1,\n }\n}");
1329 assert_eq!(found[0].text, "return { a: 1, }");
1330 }
1331}