Skip to main content

supercov_engine/
js_sites.rs

1//! Effect sites in JavaScript and TypeScript: the part of the asserted-coverage denominator that is not
2//! already a decision.
3//!
4//! Supercov's decisions and their conditions are the decision half of that denominator and are discovered
5//! elsewhere. This module finds the other half: the places where a value leaves the enclosing code. A
6//! `return`, a `throw`, an outbound call, scheduling, a write to state that is not local, a call through
7//! an import, a parameter or an ambient global. Each carries a classification, because a denominator is
8//! only honest if what it leaves out is visible:
9//!
10//! - **contractual**: behavior a test could reasonably be expected to pin.
11//! - **incidental**: diagnostics. Logging through a logger abstraction, and anything computed inside a
12//!   logging call, is excluded by rule rather than by judgement.
13//! - **review**: a site whose nature cannot be settled from syntax, such as a call through an import,
14//!   which may be a pure computation or the boundary of the system. Counted separately, never as locked.
15//!
16//! Sites are *not* coverage obligations. A `return` statement is already a statement obligation; adding
17//! it again would change what 100% means. They answer a different question, and the manifest keeps them
18//! apart for that reason.
19//!
20//! Two rules need a type checker, which oxc does not provide: whether a mutator call's receiver is a
21//! container (`Map`, `Set`, an array), and whether a method call's receiver has a type declared in this
22//! project. Both are declared as limitations rather than guessed at in either direction.
23
24use 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
41/// A method whose call sends something outward: I/O, a process, a socket.
42const 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];
63/// Free functions with outward effects.
64const IO_FUNCTIONS: &[&str] = &["spawn", "exec", "execFile", "fork", "createServer"];
65const SCHEDULE: &[&str] = &[
66    "setTimeout",
67    "setInterval",
68    "clearTimeout",
69    "clearInterval",
70    "setImmediate",
71    "queueMicrotask",
72];
73/// Methods that mutate a container in place.
74const MUTATORS: &[&str] = &[
75    "set", "delete", "clear", "push", "pop", "shift", "unshift", "splice", "add",
76];
77/// Ambient globals whose calls compute rather than act, so they are not effect sites.
78const 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];
137/// Roots whose property, not themselves, names what is being called.
138const GLOBAL_ROOTS: &[&str] = &["globalThis", "window", "self", "global"];
139
140/// A mutator call whose receiver could not be confirmed to be a container.
141pub const LIMIT_CONTAINER_TYPES: &str = "js-sites-mutator-needs-types";
142/// A method call whose receiver could not be confirmed to have a project-declared type.
143pub const LIMIT_PROJECT_TYPES: &str = "js-sites-state-call-needs-types";
144/// A `this.field.method()` call whose method the enclosing class does not declare. Whether it belongs to
145/// a class or to an interface decides whether the call runs code this class owns, and that needs types,
146/// so such calls are reported as review sites: never fewer than a type checker would find, sometimes
147/// more.
148pub 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/// 1-based line, 1-based UTF-16 column: the manifest's convention everywhere else.
199#[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    /// the innermost enclosing function, callbacks included
215    pub function: String,
216    /// the innermost enclosing *named* function: whose contract this site belongs to
217    pub owner: String,
218    pub exported: bool,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub note: Option<String>,
221    /// for a call: the callee chain, its method name, and the first argument's text
222    #[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    /// rules this pass could not decide from syntax alone, by id
236    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/// Where the root of a write lives relative to the code doing the writing. Only state that outlives the
257/// writing call is a contractual site; a fresh local object is not.
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
259enum Locality {
260    Local,
261    /// a local binding that may alias state obtained from elsewhere
262    Alias,
263    Param,
264    NonLocal,
265    This,
266    Global,
267}
268
269impl Locality {
270    /// Does a write here outlive the call?
271    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
290/// The root of a chain, in the only three shapes locality cares about.
291enum 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        // Node order follows the parse, so sites come out in source order, as they do from a traversal.
333        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                // `const f = (x) => expr`: the expression body is the function's return. An arrow whose
405                // body is another function is a factory, not a value, so it is left alone.
406                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            // Calls through a logger abstraction are diagnostics. Direct console calls are stream
449            // writes, and inside a logger implementation they *are* the boundary, so they need
450            // judgement rather than a blanket verdict.
451            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            // A mutator call on state that outlives the call is a state write. Confirming the receiver
487            // is a container (`Map`, `Set`, an array) needs types, and the choice of what to do without
488            // them matters: emitting nothing would shrink the denominator and flatter the metric, so the
489            // site is reported and the limitation declared.
490            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            // A method call on a non-local object whose type is declared in this project drives
506            // project state. Deciding that needs types too.
507            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                    // A call through an ambient global leaves the module the way an import call does.
533                    // `globalThis.x.y()` is read through its property, since globalThis computes
534                    // nothing itself.
535                    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            // `this.onMessage(...)` where the property is a field rather than a method of the class:
558            // the call runs whatever was injected there.
559            if self.this_call_is_not_a_method(id, method) {
560                // `this.field.method()`: what the field holds decides whether this is the class's own
561                // code, and that needs types. Declared, and resolved towards reporting the site.
562                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    // -----------------------------------------------------------------------
607    // Semantics
608    // -----------------------------------------------------------------------
609
610    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        // The declaration node may itself be the parameter or declarator, so it is examined before its
620        // ancestors: a binding identifier inside a pattern is what makes a destructured binding.
621        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    /// Where the root of a write lives relative to the code writing it.
645    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            // the binding lives at this file's module scope, so what it holds outlives any call here
658            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; // module-scope variable
681        };
682        if Some(declaring) != using {
683            return Locality::NonLocal;
684        }
685        // Same function: a fresh literal, object, array or `new` is truly local. Anything else may
686        // alias state obtained from elsewhere. The declaration node may be the declarator itself.
687        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    /// Is `this.…<name>()` calling something other than a method of the enclosing class? A method call
701    /// runs code this class owns; anything else runs whatever was assigned or injected there, including
702    /// a container's own method such as `this.sessions.get(...)`.
703    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    /// The innermost function at or above this node. A site on a function node itself, such as an
727    /// arrow's expression body, belongs to that function and not to the one around it.
728    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    /// A function is a callback unless it is a declaration, a method, a constructor, or the value of a
740    /// variable or property: those have names their caller can hold.
741    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        // A variable's value and a method have names their caller can hold. A function held by an
752        // object property or a class field does not: it is passed somewhere and called from there,
753        // so a `return` inside it belongs to the callback, not to the surrounding contract.
754        !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    /// The innermost enclosing function that is not a callback: whose contract this site belongs to.
829    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    /// Is this node inside a logging call, without crossing a statement boundary? Anything computed for
862    /// a log message is a diagnostic too.
863    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    // -----------------------------------------------------------------------
878    // Emitting
879    // -----------------------------------------------------------------------
880
881    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    /// Source text with runs of whitespace collapsed, truncated the way the prototype truncates.
945    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
1003/// Strip the wrappers that do not change what an expression is: parentheses and TypeScript assertions.
1004fn 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
1018/// The object a member access reads through, if this expression is one.
1019fn 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
1040/// The chain of names a callee reads through: `admin.rest.resources.Article.find` gives those five, with
1041/// `this` standing for itself.
1042fn 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
1066/// The object a chain of member accesses and calls starts from.
1067fn 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
1088/// A value that cannot alias state from elsewhere.
1089fn 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        // the inner arrow's body is the only value returned
1165        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}