Skip to main content

fallow_types/
extract.rs

1//! Module extraction types.
2
3use oxc_span::Span;
4
5use crate::discover::FileId;
6use crate::suppress::{Suppression, UnknownSuppressionKind};
7
8/// Extracted module information from a single file.
9#[derive(Debug, Clone)]
10pub struct ModuleInfo {
11    /// Unique identifier for this file.
12    pub file_id: FileId,
13    /// All export declarations in this module.
14    pub exports: Vec<ExportInfo>,
15    /// All import declarations in this module.
16    pub imports: Vec<ImportInfo>,
17    /// All re-export declarations (e.g., `export { foo } from './bar'`).
18    pub re_exports: Vec<ReExportInfo>,
19    /// All dynamic `import()` calls with string literal sources.
20    pub dynamic_imports: Vec<DynamicImportInfo>,
21    /// Dynamic import patterns.
22    pub dynamic_import_patterns: Vec<DynamicImportPattern>,
23    /// All `require()` calls.
24    pub require_calls: Vec<RequireCallInfo>,
25    /// Package names statically referenced through package path resolution.
26    pub package_path_references: Vec<String>,
27    /// Static member access expressions (e.g., `Status.Active`).
28    pub member_accesses: Vec<MemberAccess>,
29    /// Identifiers used in whole-object access patterns.
30    pub whole_object_uses: Vec<String>,
31    /// Whether this module uses CommonJS exports.
32    pub has_cjs_exports: bool,
33    /// Whether this module declares an Angular component `templateUrl`.
34    pub has_angular_component_template_url: bool,
35    /// xxh3 hash of the file content for incremental caching.
36    pub content_hash: u64,
37    /// Inline suppression directives parsed from comments.
38    pub suppressions: Vec<Suppression>,
39    /// Suppression tokens that did not parse to any known `IssueKind`.
40    /// Surfaced as `StaleSuppression` findings via `find_stale` so users see
41    /// typos or obsolete kind names instead of having the entire marker
42    /// silently discarded. See issue #449.
43    pub unknown_suppression_kinds: Vec<UnknownSuppressionKind>,
44    /// Local names of import bindings that are never referenced in this file.
45    /// Populated via `oxc_semantic` scope analysis. Used at graph-build time
46    /// to skip adding references for imports whose binding is never read,
47    /// improving unused-export detection precision.
48    pub unused_import_bindings: Vec<String>,
49    /// Local import bindings that are referenced from TypeScript type positions.
50    /// Used to distinguish value-namespace and type-namespace references when a
51    /// module exports both `const X` and `type X`.
52    pub type_referenced_import_bindings: Vec<String>,
53    /// Local import bindings referenced from runtime/value positions.
54    pub value_referenced_import_bindings: Vec<String>,
55    /// Pre-computed byte offsets where each line starts.
56    pub line_offsets: Vec<u32>,
57    /// Per-function complexity metrics.
58    pub complexity: Vec<FunctionComplexity>,
59    /// Feature flag use sites.
60    pub flag_uses: Vec<FlagUse>,
61    /// Heritage metadata for exported classes that declare `implements`.
62    pub class_heritage: Vec<ClassHeritageInfo>,
63    /// Angular `InjectionToken<Interface>` declarations, as
64    /// `(token_export_name, interface_name)` pairs. Recorded only for
65    /// `new InjectionToken<I>(...)` initializers whose `InjectionToken` is
66    /// imported from `@angular/core`. The analyze layer follows the token's
67    /// interface type argument to the classes that `implement` it so a template
68    /// member call through `inject(TOKEN)` credits the concrete implementation.
69    /// See issue #920 (follow-up to #911 / #913).
70    pub injection_tokens: Vec<(String, String)>,
71    /// Local type-capable declarations.
72    pub local_type_declarations: Vec<LocalTypeDeclaration>,
73    /// Type references in exported public signatures.
74    pub public_signature_type_references: Vec<PublicSignatureTypeReference>,
75    /// Aliases of namespace imports re-exported through an object literal.
76    pub namespace_object_aliases: Vec<NamespaceObjectAlias>,
77    /// Deduped Iconify collection prefixes found in static icon props.
78    pub iconify_prefixes: Vec<String>,
79    /// Deduped Nuxt UI `i-<collection>-<icon>` icon class suffixes found in
80    /// static script-side icon properties.
81    pub iconify_icon_names: Vec<String>,
82    /// Bare identifiers that may be resolved by framework auto-imports.
83    pub auto_import_candidates: Vec<String>,
84    /// File-level string directives in source order (e.g. `"use client"`,
85    /// `"use server"`, `"use strict"`). Captured from `Program::directives`.
86    /// Consumed by the security `client-server-leak` detector to identify
87    /// React Server Component client boundaries.
88    pub directives: Vec<String>,
89    /// Captured security sink sites (category-blind). Consumed by the
90    /// catalogue-driven `tainted_sink` detector. Captured only by JS/TS
91    /// extraction; empty for CSS/MDX/etc. See `security_matchers.toml`.
92    pub security_sinks: Vec<SinkSite>,
93    /// Count of sink-shaped nodes whose callee could not be flattened to a
94    /// static path (dynamic dispatch, computed members, aliased bindings).
95    /// Surfaced in-band so an empty catalogue result with a non-zero count is
96    /// not a clean bill.
97    pub security_sinks_skipped: u32,
98    /// Compact span-level diagnostics for skipped security sink callees. Kept
99    /// next to `security_sinks_skipped` so warm-cache and cold-cache security
100    /// output can explain where the blind spots are concentrated without source
101    /// snippets.
102    pub security_unresolved_callee_sites: Vec<SkippedSecurityCalleeSite>,
103    /// Local bindings whose initializer (or destructured object) is a flattened
104    /// member-access path. Used by the security `tainted_sink` detector to
105    /// back-trace a sink argument to a known untrusted source: the analyze layer
106    /// matches each binding's `source_path` against the data-driven source
107    /// catalogue (`security_matchers.toml` `[[source]]` rows) and treats the
108    /// matching `local` names as source-tainted. Intra-module and name-based
109    /// (no scope analysis); a conservative association, never a taint proof.
110    pub tainted_bindings: Vec<TaintedBinding>,
111    /// Sink arguments that were recognized as sanitizer calls at extraction
112    /// time. Used for direct sink calls such as
113    /// `el.innerHTML = DOMPurify.sanitize(input)`.
114    pub sanitized_sink_args: Vec<SanitizedSinkArg>,
115    /// Known defensive control call sites found in this module. Consumed only by
116    /// the `fallow security --surface` agent JSON path.
117    pub security_control_sites: Vec<SecurityControlSite>,
118    /// Statically flattenable callee paths invoked in this module, deduped per
119    /// unique path (first occurrence wins). Consumed by the
120    /// `boundaries.calls.forbidden` detector. Captured unconditionally because
121    /// extraction is config-blind; the per-module cost is bounded by the
122    /// unique-callee count.
123    pub callee_uses: Vec<CalleeUse>,
124}
125
126impl ModuleInfo {
127    /// Release extraction payload that resolution has already copied into the graph.
128    ///
129    /// This keeps fields needed by analysis, health, security, LSP, coverage,
130    /// and hash drift checks, while dropping vectors that otherwise duplicate
131    /// data owned by `ResolvedModule` or already credited into the module graph.
132    pub fn release_resolution_payload(&mut self) {
133        Self::release_vec(&mut self.dynamic_imports);
134        Self::release_vec(&mut self.require_calls);
135        Self::release_vec(&mut self.package_path_references);
136        Self::release_vec(&mut self.whole_object_uses);
137        Self::release_vec(&mut self.unused_import_bindings);
138        Self::release_vec(&mut self.type_referenced_import_bindings);
139        Self::release_vec(&mut self.value_referenced_import_bindings);
140        Self::release_vec(&mut self.namespace_object_aliases);
141        Self::release_vec(&mut self.auto_import_candidates);
142    }
143
144    fn release_vec<T>(values: &mut Vec<T>) {
145        *values = Vec::new();
146    }
147}
148
149/// Defensive control family detected on a source to sink path.
150#[derive(
151    Debug,
152    Clone,
153    Copy,
154    PartialEq,
155    Eq,
156    PartialOrd,
157    Ord,
158    serde::Serialize,
159    serde::Deserialize,
160    bitcode::Encode,
161    bitcode::Decode,
162)]
163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
164#[serde(rename_all = "kebab-case")]
165pub enum SecurityControlKind {
166    /// Sanitization or escaping before a sink.
167    Sanitization,
168    /// Input validation or schema parsing.
169    Validation,
170    /// Authentication check or middleware.
171    Authentication,
172    /// Authorization or permission check.
173    Authorization,
174}
175
176/// A known defensive control call site.
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
178pub struct SecurityControlSite {
179    /// Control family.
180    pub kind: SecurityControlKind,
181    /// Flattened callee path or a stable synthetic name for guard-derived
182    /// controls.
183    pub callee_path: String,
184    /// Byte offset of the control span start.
185    pub span_start: u32,
186    /// Byte offset of the control span end.
187    pub span_end: u32,
188}
189
190/// Sanitizer output domain. Kept intentionally narrow so a sanitizer for one
191/// domain cannot suppress a different sink family.
192#[derive(
193    Debug,
194    Clone,
195    Copy,
196    PartialEq,
197    Eq,
198    PartialOrd,
199    Ord,
200    serde::Serialize,
201    serde::Deserialize,
202    bitcode::Encode,
203    bitcode::Decode,
204)]
205pub enum SanitizerScope {
206    /// HTML markup sanitized by DOMPurify-compatible APIs.
207    Html,
208    /// URL or redirect target checked against a literal-backed allowlist.
209    Url,
210    /// Path value checked against a high-confidence containment guard.
211    Path,
212    /// SQL identifier quoted with a helper that doubles embedded identifier quotes.
213    SqlIdentifier,
214}
215
216/// A captured sink argument that is itself a recognized sanitizer call.
217#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
218pub struct SanitizedSinkArg {
219    /// Byte offset of the owning sink span start.
220    pub span_start: u32,
221    /// The positional argument index on the owning sink.
222    pub arg_index: u32,
223    /// The sanitizer output domain for this argument.
224    pub scope: SanitizerScope,
225}
226
227/// A local binding tied to the flattened member-access path it was initialized
228/// from. The analyze layer matches `source_path` against the data-driven source
229/// catalogue; when it matches, `local` is treated as carrying untrusted input.
230///
231/// Captured for two shapes: a direct assignment (`const id = req.query.id` ->
232/// `{ local: "id", source_path: "req.query" }`, the literal-key tail dropped so
233/// the path matches a catalogue prefix) and an object destructure
234/// (`const { id } = req.query` -> `{ local: "id", source_path: "req.query" }`).
235#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
236pub struct TaintedBinding {
237    /// The local binding name introduced by the declarator.
238    pub local: String,
239    /// The flattened object member-access path the binding was sourced from.
240    pub source_path: String,
241    /// Byte offset of the source read (the member-access expression the binding
242    /// was sourced from), so the analyze layer can anchor a taint trace's source
243    /// node at the real read line instead of the module import line. Stored as a
244    /// `u32` (not `Span`) to stay bitcode-encodable for the cache. `0` when no
245    /// concrete read expression is available (synthetic framework-param /
246    /// helper-return bindings), in which case the analyze layer falls back to the
247    /// sink site rather than claiming a spurious line.
248    pub source_span_start: u32,
249}
250
251/// Why a sink-shaped callee could not be flattened into a static catalogue
252/// path.
253#[derive(
254    Debug,
255    Clone,
256    Copy,
257    PartialEq,
258    Eq,
259    PartialOrd,
260    Ord,
261    serde::Serialize,
262    serde::Deserialize,
263    bitcode::Encode,
264    bitcode::Decode,
265)]
266#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
267#[serde(rename_all = "kebab-case")]
268pub enum SkippedSecurityCalleeReason {
269    /// A computed member access such as `client[method](input)`.
270    ComputedMember,
271    /// A dynamic non-member callee such as `(factory())(input)`.
272    DynamicDispatch,
273    /// An assignment target whose object could not be flattened.
274    UnsupportedAssignmentObject,
275}
276
277/// Syntactic expression shape for a skipped security callee.
278#[derive(
279    Debug,
280    Clone,
281    Copy,
282    PartialEq,
283    Eq,
284    PartialOrd,
285    Ord,
286    serde::Serialize,
287    serde::Deserialize,
288    bitcode::Encode,
289    bitcode::Decode,
290)]
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[serde(rename_all = "kebab-case")]
293pub enum SkippedSecurityCalleeExpressionKind {
294    /// `obj.prop(...)`.
295    StaticMemberExpression,
296    /// `obj[prop](...)`.
297    ComputedMemberExpression,
298    /// A bare identifier or private identifier callee.
299    Identifier,
300    /// Any other call-like expression that cannot be represented compactly.
301    Other,
302}
303
304/// Span-only diagnostic for a skipped security callee inside one module.
305#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
306pub struct SkippedSecurityCalleeSite {
307    /// Why the callee was skipped.
308    pub reason: SkippedSecurityCalleeReason,
309    /// Compact expression shape of the skipped callee.
310    pub expression_kind: SkippedSecurityCalleeExpressionKind,
311    /// Start byte offset of the skipped callee expression.
312    pub span_start: u32,
313    /// End byte offset of the skipped callee expression.
314    pub span_end: u32,
315}
316
317/// The syntactic shape of a captured security sink site. Category-blind: the
318/// extractor records the shape and the dotted/bare callee path; the analyze
319/// layer matches it against the data-driven catalogue. See
320/// `crates/core/data/security_matchers.toml`.
321#[derive(
322    Debug,
323    Clone,
324    Copy,
325    PartialEq,
326    Eq,
327    serde::Serialize,
328    serde::Deserialize,
329    bitcode::Encode,
330    bitcode::Decode,
331)]
332pub enum SinkShape {
333    /// A call to a bare identifier (e.g. `eval(x)`).
334    Call,
335    /// A call to a dotted member path (e.g. `child_process.exec(x)`).
336    MemberCall,
337    /// An assignment to a member target (e.g. `el.innerHTML = x`).
338    MemberAssign,
339    /// A tagged template expression (e.g. ``sql`...${x}...` ``).
340    TaggedTemplate,
341    /// A JSX attribute value (e.g. `dangerouslySetInnerHTML={x}`).
342    JsxAttr,
343    /// A constructor call (e.g. `new Function("return x")`).
344    NewExpression,
345    /// A static string literal assigned to a secret-shaped identifier or known
346    /// provider credential prefix.
347    SecretLiteral,
348}
349
350/// The shape of the argument captured at a sink site. Category-blind like
351/// [`SinkShape`], but finer-grained: it lets the catalogue matcher require or
352/// exclude specific argument shapes. The discriminator is what distinguishes an
353/// unsafe SQL string concatenation or template-into-`.execute()` from a
354/// safely-parameterized `` sql`${x}` `` tagged template, an object-literal
355/// `.execute({ sql, args })` argument, or a literal-aware sink argument.
356#[derive(
357    Debug,
358    Clone,
359    Copy,
360    PartialEq,
361    Eq,
362    serde::Serialize,
363    serde::Deserialize,
364    bitcode::Encode,
365    bitcode::Decode,
366)]
367pub enum SinkArgKind {
368    /// A template literal with at least one `${...}` substitution (e.g.
369    /// `` `SELECT ${x}` ``). On a `tagged-template` shape this is the tag's
370    /// quasi; on a `call`/`member-call` shape it is the positional argument.
371    TemplateWithSubst,
372    /// A binary `+` string concatenation (e.g. `"SELECT " + x`).
373    Concat,
374    /// An object literal (e.g. `.execute({ sql, args })`, the parameterized form).
375    Object,
376    /// A call expression argument (e.g. `query(buildSql())`).
377    Call,
378    /// A literal argument admitted by a literal-aware security matcher.
379    Literal,
380    /// A zero-argument sink captured because the callee itself is the signal.
381    NoArg,
382    /// Any other non-literal expression (bare identifier, member access, etc.).
383    Other,
384}
385
386/// Static URL construction shape captured for URL-shaped security sinks.
387#[derive(
388    Debug,
389    Clone,
390    Copy,
391    PartialEq,
392    Eq,
393    serde::Serialize,
394    serde::Deserialize,
395    bitcode::Encode,
396    bitcode::Decode,
397)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399#[serde(rename_all = "kebab-case")]
400pub enum SecurityUrlShape {
401    /// The sink target has a fixed origin, scheme, or relative root while only
402    /// path or query components are dynamic.
403    FixedOriginDynamicPath,
404    /// The sink target's scheme or origin is dynamic or opaque.
405    DynamicOrigin,
406}
407
408/// Literal values attached to literal-aware security sink captures.
409#[derive(
410    Debug,
411    Clone,
412    PartialEq,
413    Eq,
414    serde::Serialize,
415    serde::Deserialize,
416    bitcode::Encode,
417    bitcode::Decode,
418)]
419pub enum SinkLiteralValue {
420    /// A string literal value.
421    String(String),
422    /// An integer numeric literal value.
423    Integer(i64),
424    /// A boolean literal value.
425    Boolean(bool),
426    /// A null literal value.
427    Null,
428}
429
430/// Static object-literal property metadata attached to a captured sink
431/// argument. Nested object paths are flattened with dot-separated keys.
432#[derive(
433    Debug,
434    Clone,
435    PartialEq,
436    Eq,
437    serde::Serialize,
438    serde::Deserialize,
439    bitcode::Encode,
440    bitcode::Decode,
441)]
442pub struct SinkObjectProperty {
443    /// Static property name. Nested object properties use dot-separated paths.
444    pub key: String,
445    /// Literal property value when statically knowable.
446    pub value: SinkLiteralValue,
447}
448
449/// A captured sink site. The visitor records every existing non-literal call /
450/// member-assign / member-call / tagged-template / jsx-attr sink site, and a
451/// small allowlist of literal-aware sites where the literal value is the signal.
452/// It knows nothing about CWE categories.
453#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
454pub struct SinkSite {
455    /// The syntactic shape of the sink site.
456    pub sink_shape: SinkShape,
457    /// The flattened dotted/bare callee or member path.
458    pub callee_path: String,
459    /// The positional argument index. For zero-argument captures this is 0.
460    pub arg_index: u32,
461    /// Whether the relevant argument is non-literal. Existing non-literal
462    /// catalogue rows require this to remain true.
463    pub arg_is_non_literal: bool,
464    /// The finer-grained shape of the captured argument. Lets the catalogue
465    /// require unsafe shapes (concat / template-with-substitution / literal /
466    /// no-arg) and exclude safe ones (object literal, the parameterized form).
467    /// See [`SinkArgKind`].
468    pub arg_kind: SinkArgKind,
469    /// Literal argument value for literal-aware rows.
470    pub arg_literal: Option<SinkLiteralValue>,
471    /// Risky regex fragment for structural ReDoS candidates.
472    pub regex_pattern: Option<String>,
473    /// Static object-literal properties for option-object rows.
474    pub object_properties: Vec<SinkObjectProperty>,
475    /// Static top-level object-literal keys, including keys whose values are not
476    /// literal. Used by missing-option rows that only need key presence.
477    pub object_property_keys: Vec<String>,
478    /// Whether [`object_property_keys`](Self::object_property_keys) is complete.
479    /// False for non-object arguments and object literals with spread or
480    /// non-static keys, where a missing-key claim would be speculative.
481    pub object_property_keys_complete: bool,
482    /// Identifier names referenced anywhere inside the captured non-literal sink
483    /// argument, or contextual names for zero-argument captures such as a
484    /// token-like `Math.random()` assignment target. Deduped in source order.
485    /// Used by the analyze layer to back-trace the sink argument to a known
486    /// untrusted source or to apply narrow context gates. Intra-module,
487    /// name-based, conservative; it is never a taint proof.
488    pub arg_idents: Vec<String>,
489    /// Flattened static member paths referenced inside the captured non-literal
490    /// sink argument. Includes both the full path and source-object path for
491    /// leaf reads (`process.env.SECRET` records `process.env.SECRET` and
492    /// `process.env`) so direct source expressions can be matched without an
493    /// intermediate local binding.
494    pub arg_source_paths: Vec<String>,
495    /// Byte offset of the sink span start. Stored as `u32` (not `Span`) so the
496    /// struct is bitcode-encodable and can be persisted directly in the cache.
497    pub span_start: u32,
498    /// Byte offset of the sink span end.
499    pub span_end: u32,
500    /// The arg-0 URL string literal of a network-shaped call (`fetch`, `axios.*`,
501    /// `got`, ...), captured so the `secret-to-network` category (#890) can carry
502    /// a destination-host signal on its candidate: `Some(literal)` when the
503    /// destination is a static string literal (almost always intended auth, e.g.
504    /// the credential's own provider), `None` when it is dynamic (the suspicious
505    /// case). `None` for non-call sinks and calls with no arg 0.
506    pub url_arg_literal: Option<String>,
507    /// URL construction shape for URL-like sink arguments when the extractor can
508    /// classify it syntactically. `None` for non-URL sinks and URL expressions
509    /// whose shape is not visible at the sink.
510    pub url_shape: Option<SecurityUrlShape>,
511}
512
513impl SinkSite {
514    /// Reconstruct the source span from the stored byte offsets.
515    #[must_use]
516    pub fn span(&self) -> Span {
517        Span::new(self.span_start, self.span_end)
518    }
519}
520
521/// Env var-name prefixes that frameworks inline into the client bundle by
522/// convention. A read of one of these is normal and safe, so it does NOT count
523/// as a secret source (issue #890). Shared by the extract layer (so public env
524/// vars never become source signals) and the bespoke `client-server-leak` rule.
525pub const PUBLIC_ENV_PREFIXES: &[&str] = &[
526    "NEXT_PUBLIC_",
527    "VITE_",
528    "NUXT_PUBLIC_",
529    "REACT_APP_",
530    "PUBLIC_",
531    "GATSBY_",
532    "EXPO_PUBLIC_",
533    "STORYBOOK_",
534];
535
536/// Exact env var names that are public by convention (no prefix).
537pub const PUBLIC_ENV_EXACT: &[&str] = &["NODE_ENV"];
538
539/// Env var-name tokens that usually describe public build or deployment
540/// metadata rather than secrets. Secret-shaped names win over these tokens.
541pub const PUBLIC_ENV_METADATA_TOKENS: &[&str] =
542    &["BRANCH", "ENVIRONMENT", "MODE", "REF", "SHA", "TAG"];
543
544/// Env var-name tokens that should keep a variable source-backed even when the
545/// name also contains public metadata tokens such as `REF` or `SHA`.
546pub const SECRET_ENV_TOKENS: &[&str] = &[
547    "AUTH",
548    "CREDENTIAL",
549    "CREDENTIALS",
550    "KEY",
551    "PASS",
552    "PASSWORD",
553    "PRIVATE",
554    "SECRET",
555    "TOKEN",
556];
557
558fn env_name_has_token(name: &str, tokens: &[&str]) -> bool {
559    name.split(|ch: char| !ch.is_ascii_alphanumeric())
560        .filter(|part| !part.is_empty())
561        .any(|part| tokens.contains(&part))
562}
563
564/// Whether an env var name is public-by-convention (build-inlined into the
565/// client bundle), and therefore not a secret.
566#[must_use]
567pub fn is_public_env_var(name: &str) -> bool {
568    if PUBLIC_ENV_EXACT.contains(&name) || PUBLIC_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
569        return true;
570    }
571    env_name_has_token(name, PUBLIC_ENV_METADATA_TOKENS)
572        && !env_name_has_token(name, SECRET_ENV_TOKENS)
573}
574
575/// Whether a flattened member path is a PUBLIC env-secret read
576/// (`process.env.NEXT_PUBLIC_X`, `import.meta.env.VITE_Y`), which must not be
577/// recorded as a secret source. Non-env paths (`req.query.id`) are never public.
578#[must_use]
579pub fn is_public_env_path(path: &str) -> bool {
580    for object in ["process.env.", "import.meta.env."] {
581        if let Some(var) = path.strip_prefix(object) {
582            return is_public_env_var(var);
583        }
584    }
585    false
586}
587
588/// One alias entry tying an exported object's dotted property path to a namespace import.
589#[derive(Debug, Clone)]
590pub struct NamespaceObjectAlias {
591    /// Canonical export name.
592    pub via_export_name: String,
593    /// Dotted suffix of the property path relative to the export.
594    pub suffix: String,
595    /// Local name of the namespace import.
596    pub namespace_local: String,
597}
598
599/// Compute a table of line-start byte offsets from source text.
600#[must_use]
601#[expect(
602    clippy::cast_possible_truncation,
603    reason = "source files are practically < 4GB"
604)]
605pub fn compute_line_offsets(source: &str) -> Vec<u32> {
606    let mut offsets = vec![0u32];
607    for (i, byte) in source.bytes().enumerate() {
608        if byte == b'\n' {
609            debug_assert!(
610                u32::try_from(i + 1).is_ok(),
611                "source file exceeds u32::MAX bytes โ€” line offsets would overflow"
612            );
613            offsets.push((i + 1) as u32);
614        }
615    }
616    offsets
617}
618
619/// Convert a byte offset to a 1-based line number and 0-based byte column.
620#[must_use]
621#[expect(
622    clippy::cast_possible_truncation,
623    reason = "line count is bounded by source size"
624)]
625pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
626    let line_idx = match line_offsets.binary_search(&byte_offset) {
627        Ok(idx) => idx,
628        Err(idx) => idx.saturating_sub(1),
629    };
630    let line = line_idx as u32 + 1;
631    let col = byte_offset - line_offsets[line_idx];
632    (line, col)
633}
634
635/// Complexity metrics for a single function/method/arrow.
636#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
637pub struct FunctionComplexity {
638    /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
639    pub name: String,
640    /// 1-based line number where the function starts.
641    pub line: u32,
642    /// 0-based byte column where the function starts.
643    pub col: u32,
644    /// `McCabe` cyclomatic complexity (1 + decision points).
645    pub cyclomatic: u16,
646    /// `SonarSource` cognitive complexity (structural + nesting penalty).
647    pub cognitive: u16,
648    /// Number of lines in the function body.
649    pub line_count: u32,
650    /// Number of parameters (excluding TypeScript's `this` parameter).
651    pub param_count: u8,
652    /// Content digest of the function's full-span source slice.
653    pub source_hash: Option<String>,
654    /// Per-decision-point breakdown explaining WHICH constructs drove the
655    /// cyclomatic and cognitive scores. One entry per increment event (an `if`
656    /// emits one cyclomatic and one cognitive entry at the same line, because
657    /// the two metrics accrue at different granularities). Always computed and
658    /// cached; surfaced in JSON only behind `health --complexity-breakdown`.
659    pub contributions: Vec<ComplexityContribution>,
660}
661
662/// Which complexity metric a [`ComplexityContribution`] adds to.
663#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
664#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
665#[serde(rename_all = "kebab-case")]
666pub enum ComplexityMetric {
667    /// `McCabe` cyclomatic complexity (independent execution paths).
668    Cyclomatic,
669    /// `SonarSource` cognitive complexity (structural + nesting penalty).
670    Cognitive,
671}
672
673/// The syntactic construct that produced a single complexity increment.
674///
675/// Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
676/// `Case` means a `case` label carrying a test; a bare `default` adds nothing
677/// to cyclomatic complexity and so produces no contribution.
678#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
679#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
680#[serde(rename_all = "kebab-case")]
681pub enum ComplexityContributionKind {
682    /// An `if` condition.
683    If,
684    /// A bare `else` branch (cognitive only).
685    Else,
686    /// An `else if` continuation (both metrics: cyclomatic +1, cognitive flat
687    /// +1 with no nesting penalty).
688    ElseIf,
689    /// A `?:` conditional (ternary) expression.
690    Ternary,
691    /// A logical `&&` operator.
692    LogicalAnd,
693    /// A logical `||` operator.
694    LogicalOr,
695    /// A `??` nullish-coalescing operator.
696    NullishCoalescing,
697    /// A logical assignment operator (`&&=`, `||=`, `??=`); cyclomatic only.
698    LogicalAssignment,
699    /// An optional-chaining link (`?.`); cyclomatic only.
700    OptionalChain,
701    /// A `for` loop.
702    For,
703    /// A `for...in` loop.
704    ForIn,
705    /// A `for...of` loop.
706    ForOf,
707    /// A `while` loop.
708    While,
709    /// A `do...while` loop.
710    DoWhile,
711    /// A `switch` statement (cognitive only; each `case` adds cyclomatic).
712    Switch,
713    /// A `case` label carrying a test (cyclomatic only).
714    Case,
715    /// A `catch` clause.
716    Catch,
717    /// A labeled `break` (cognitive only).
718    LabeledBreak,
719    /// A labeled `continue` (cognitive only).
720    LabeledContinue,
721}
722
723/// A single complexity increment, located at its source line/column.
724///
725/// `weight` is the amount this construct added to `metric`; for nested
726/// cognitive increments `weight == 1 + nesting`. Consumers that render inline
727/// (the VS Code editor breakdown) group contributions by `line` and sum the
728/// weights, deferring the per-kind list to a hover.
729#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
730#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
731pub struct ComplexityContribution {
732    /// 1-based line number where the construct begins.
733    pub line: u32,
734    /// 0-based byte column where the construct begins.
735    pub col: u32,
736    /// Which metric this increment contributes to.
737    pub metric: ComplexityMetric,
738    /// The syntactic construct responsible for the increment.
739    pub kind: ComplexityContributionKind,
740    /// The amount added to `metric` at this site (`1 + nesting` for nested
741    /// cognitive increments, otherwise `1`).
742    pub weight: u16,
743    /// The nesting depth at the increment site (`0` when not nested). Lets a
744    /// consumer explain a cognitive `+3` as "+1 base, +2 nesting".
745    pub nesting: u16,
746}
747
748/// The kind of feature flag pattern detected.
749#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
750pub enum FlagUseKind {
751    /// `process.env.FEATURE_X` pattern.
752    EnvVar,
753    /// SDK function call like `useFlag('name')`.
754    SdkCall,
755    /// Config object access like `config.features.x`.
756    ConfigObject,
757}
758
759/// A feature flag use site.
760#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
761pub struct FlagUse {
762    /// Flag identifier.
763    pub flag_name: String,
764    /// Detection kind.
765    pub kind: FlagUseKind,
766    /// 1-based line number.
767    pub line: u32,
768    /// 0-based byte column offset.
769    pub col: u32,
770    /// Start byte offset of the guarded block.
771    pub guard_span_start: Option<u32>,
772    /// End byte offset of the guarded block.
773    pub guard_span_end: Option<u32>,
774    /// SDK/provider name.
775    pub sdk_name: Option<String>,
776}
777
778const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);
779
780/// A dynamic import with a partially resolved pattern.
781#[derive(Debug, Clone)]
782pub struct DynamicImportPattern {
783    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
784    pub prefix: String,
785    /// Static suffix of the import path (e.g., ".json"), if any.
786    pub suffix: Option<String>,
787    /// Source span in the original file.
788    pub span: Span,
789}
790
791/// Visibility tag from JSDoc/TSDoc comments that suppresses unused-export detection.
792#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
793#[serde(rename_all = "lowercase")]
794#[repr(u8)]
795pub enum VisibilityTag {
796    /// No visibility tag present.
797    #[default]
798    None = 0,
799    /// `@public` or `@api public` -- part of the public API surface.
800    Public = 1,
801    /// `@internal` -- exported for internal use (sister packages, build tools).
802    Internal = 2,
803    /// `@beta` -- public but unstable, may change without notice.
804    Beta = 3,
805    /// `@alpha` -- early preview, may change drastically without notice.
806    Alpha = 4,
807    /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
808    ExpectedUnused = 5,
809}
810
811impl VisibilityTag {
812    /// Whether this tag permanently suppresses unused-export detection.
813    /// `ExpectedUnused` is handled separately (conditionally suppresses,
814    /// reports stale when the export becomes used).
815    pub const fn suppresses_unused(self) -> bool {
816        matches!(
817            self,
818            Self::Public | Self::Internal | Self::Beta | Self::Alpha
819        )
820    }
821
822    /// For serde `skip_serializing_if`.
823    pub fn is_none(&self) -> bool {
824        matches!(self, Self::None)
825    }
826}
827
828/// An export declaration.
829#[derive(Debug, Clone, serde::Serialize)]
830pub struct ExportInfo {
831    /// The exported name (named or default).
832    pub name: ExportName,
833    /// The local binding name, if different from the exported name.
834    pub local_name: Option<String>,
835    /// Whether this is a type-only export (`export type`).
836    pub is_type_only: bool,
837    /// Whether this export is registered through a runtime side effect at module load time.
838    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
839    pub is_side_effect_used: bool,
840    /// Visibility tag from JSDoc/TSDoc comment.
841    #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
842    pub visibility: VisibilityTag,
843    /// Source span of the export declaration.
844    #[serde(serialize_with = "serialize_span")]
845    pub span: Span,
846    /// Members of this export (for enums, classes, and namespaces).
847    #[serde(default, skip_serializing_if = "Vec::is_empty")]
848    pub members: Vec<MemberInfo>,
849    /// The local name of the parent class from `extends` clause, if any.
850    #[serde(default, skip_serializing_if = "Option::is_none")]
851    pub super_class: Option<String>,
852}
853
854/// Additional heritage metadata for an exported class.
855#[derive(
856    Debug,
857    Clone,
858    serde::Serialize,
859    serde::Deserialize,
860    bitcode::Encode,
861    bitcode::Decode,
862    PartialEq,
863    Eq,
864)]
865pub struct ClassHeritageInfo {
866    /// Export name (`default` for default-exported classes).
867    pub export_name: String,
868    /// Parent class name from the `extends` clause, if any.
869    pub super_class: Option<String>,
870    /// Interface names from the class `implements` clause.
871    pub implements: Vec<String>,
872    /// Typed instance bindings used to resolve member-access chains in external templates.
873    #[serde(default, skip_serializing_if = "Vec::is_empty")]
874    pub instance_bindings: Vec<(String, String)>,
875}
876
877/// A module-scope declaration that can be used as a TypeScript type.
878#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
879pub struct LocalTypeDeclaration {
880    /// Local declaration name.
881    pub name: String,
882    /// Declaration identifier span.
883    #[serde(serialize_with = "serialize_span")]
884    pub span: Span,
885}
886
887/// A reference from an exported symbol's public signature to a type name.
888#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
889pub struct PublicSignatureTypeReference {
890    /// Exported symbol whose signature contains the reference.
891    pub export_name: String,
892    /// Referenced type name. Qualified names are reduced to their root identifier.
893    pub type_name: String,
894    /// Reference span.
895    #[serde(serialize_with = "serialize_span")]
896    pub span: Span,
897}
898
899/// A member of an enum, class, or namespace.
900#[derive(Debug, Clone, serde::Serialize)]
901pub struct MemberInfo {
902    /// Member name.
903    pub name: String,
904    /// The kind of member (enum, class method/property, or namespace member).
905    pub kind: MemberKind,
906    /// Source span of the member declaration.
907    #[serde(serialize_with = "serialize_span")]
908    pub span: Span,
909    /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
910    /// Decorated members are used by frameworks at runtime and should not be
911    /// flagged as unused class members, unless every decorator on the member
912    /// is opted out via `FallowConfig.ignore_decorators`.
913    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
914    pub has_decorator: bool,
915    /// Full dotted path of each decorator on this member, in source order.
916    /// `@step("x")` stores `"step"`; `@ns.foo` stores `"ns.foo"`. Empty for
917    /// undecorated members, Angular signal-initializer properties (which set
918    /// `has_decorator` without a literal decorator AST node), and decorators
919    /// whose expression is not an identifier ladder (the entry is the empty
920    /// string in that case, treated as never-matching by the predicate).
921    #[serde(default, skip_serializing_if = "Vec::is_empty")]
922    pub decorator_names: Vec<String>,
923    /// True when this is a static class method that returns a fresh instance
924    /// of the same class: either via `return new this()` / `return new
925    /// <SameClassName>()` in the body's last statement, or via a declared
926    /// return type matching the class name. Consumers calling such a static
927    /// method receive an instance, so the call result's member accesses are
928    /// credited against the class. See issues #346, #387.
929    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
930    pub is_instance_returning_static: bool,
931    /// True when this is an instance class method whose call result is an
932    /// instance of the same class. Qualifies when the declared return type
933    /// matches the class name (`setX(): EventBuilder { ... }`) or when the
934    /// body's last statement is `return this`. The analyze layer walks fluent
935    /// chains (`Class.factory().setX().setY()`) only through methods carrying
936    /// this flag, so the chain stops at a non-self-returning method like
937    /// `.build()`. See issue #387.
938    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
939    pub is_self_returning: bool,
940}
941
942/// The kind of member.
943#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
944#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
945#[serde(rename_all = "snake_case")]
946pub enum MemberKind {
947    /// A TypeScript enum member.
948    EnumMember,
949    /// A class method.
950    ClassMethod,
951    /// A class property.
952    ClassProperty,
953    /// A member exported from a TypeScript namespace.
954    NamespaceMember,
955}
956
957/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
958#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
959pub struct MemberAccess {
960    /// The identifier being accessed (the import name).
961    pub object: String,
962    /// The member being accessed.
963    pub member: String,
964}
965
966/// A statically flattenable callee path invoked in a module (e.g. `execSync`,
967/// `child_process.exec`, `console.log`). One entry per unique `callee_path`
968/// per module; the span anchors the first occurrence. Consumed by the
969/// `boundaries.calls.forbidden` detector.
970#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
971pub struct CalleeUse {
972    /// The dotted or bare callee path as written at the call site.
973    pub callee_path: String,
974    /// Start byte offset of the first call site using this path.
975    pub span_start: u32,
976}
977
978#[expect(
979    clippy::trivially_copy_pass_by_ref,
980    reason = "serde serialize_with requires &T"
981)]
982fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
983    use serde::ser::SerializeMap;
984    let mut map = serializer.serialize_map(Some(2))?;
985    map.serialize_entry("start", &span.start)?;
986    map.serialize_entry("end", &span.end)?;
987    map.end()
988}
989
990/// Export identifier.
991#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
992pub enum ExportName {
993    /// A named export (e.g., `export const foo`).
994    Named(String),
995    /// The default export.
996    Default,
997}
998
999impl ExportName {
1000    /// Compare against a string without allocating (avoids `to_string()`).
1001    #[must_use]
1002    pub fn matches_str(&self, s: &str) -> bool {
1003        match self {
1004            Self::Named(n) => n == s,
1005            Self::Default => s == "default",
1006        }
1007    }
1008}
1009
1010impl std::fmt::Display for ExportName {
1011    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1012        match self {
1013            Self::Named(n) => write!(f, "{n}"),
1014            Self::Default => write!(f, "default"),
1015        }
1016    }
1017}
1018
1019/// An import declaration.
1020#[derive(Debug, Clone)]
1021pub struct ImportInfo {
1022    /// The import specifier (e.g., `./utils` or `react`).
1023    pub source: String,
1024    /// How the symbol is imported (named, default, namespace, or side-effect).
1025    pub imported_name: ImportedName,
1026    /// The local binding name in the importing module.
1027    pub local_name: String,
1028    /// Whether this is a type-only import (`import type`).
1029    pub is_type_only: bool,
1030    /// Whether this import originated from a CSS-context.
1031    pub from_style: bool,
1032    /// Source span of the import declaration.
1033    pub span: Span,
1034    /// Span of the source string literal used by the LSP to highlight the specifier.
1035    pub source_span: Span,
1036}
1037
1038/// How a symbol is imported.
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub enum ImportedName {
1041    /// A named import (e.g., `import { foo }`).
1042    Named(String),
1043    /// A default import (e.g., `import React`).
1044    Default,
1045    /// A namespace import (e.g., `import * as utils`).
1046    Namespace,
1047    /// A side-effect import (e.g., `import './styles.css'`).
1048    SideEffect,
1049}
1050
1051#[cfg(target_pointer_width = "64")]
1052const _: () = assert!(std::mem::size_of::<ExportInfo>() == 112);
1053#[cfg(target_pointer_width = "64")]
1054const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
1055#[cfg(target_pointer_width = "64")]
1056const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
1057#[cfg(target_pointer_width = "64")]
1058const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
1059#[cfg(target_pointer_width = "64")]
1060const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
1061#[cfg(target_pointer_width = "64")]
1062const _: () = assert!(std::mem::size_of::<SinkSite>() == 216);
1063#[cfg(target_pointer_width = "64")]
1064const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 792);
1065
1066/// A re-export declaration.
1067#[derive(Debug, Clone)]
1068pub struct ReExportInfo {
1069    /// The module being re-exported from.
1070    pub source: String,
1071    /// The name imported from the source module (or `*` for star re-exports).
1072    pub imported_name: String,
1073    /// The name exported from this module.
1074    pub exported_name: String,
1075    /// Whether this is a type-only re-export.
1076    pub is_type_only: bool,
1077    /// Source span of the re-export declaration on this module.
1078    pub span: oxc_span::Span,
1079}
1080
1081/// A dynamic `import()` call.
1082#[derive(Debug, Clone)]
1083pub struct DynamicImportInfo {
1084    /// The import specifier.
1085    pub source: String,
1086    /// Source span of the `import()` expression.
1087    pub span: Span,
1088    /// Names destructured from the dynamic import result.
1089    /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
1090    /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
1091    pub destructured_names: Vec<String>,
1092    /// The local variable name for `const x = await import(...)`.
1093    /// Used for namespace import narrowing via member access tracking.
1094    pub local_name: Option<String>,
1095    /// True when this dynamic import was synthesised by fallow rather than appearing in user source.
1096    pub is_speculative: bool,
1097}
1098
1099/// A `require()` call.
1100#[derive(Debug, Clone)]
1101pub struct RequireCallInfo {
1102    /// The require specifier.
1103    pub source: String,
1104    /// Source span of the `require()` call.
1105    pub span: Span,
1106    /// Source span of the specifier string-literal argument (including its
1107    /// quotes), e.g. the `'./x'` in `require('./x')`. Used to anchor an
1108    /// `unresolved-import` diagnostic squiggly under the specifier rather than
1109    /// the `require` keyword. `Span::default()` when the argument is not a
1110    /// plain string literal.
1111    pub source_span: Span,
1112    /// Names destructured from the `require()` result.
1113    pub destructured_names: Vec<String>,
1114    /// The local variable name for `const x = require(...)`.
1115    pub local_name: Option<String>,
1116}
1117
1118/// Result of parsing all files, including incremental cache statistics.
1119pub struct ParseResult {
1120    /// Extracted module information for all successfully parsed files.
1121    pub modules: Vec<ModuleInfo>,
1122    /// Number of files whose parse results were loaded from cache (unchanged).
1123    pub cache_hits: usize,
1124    /// Number of files that required a full parse (new or changed).
1125    pub cache_misses: usize,
1126    /// Summed wall-clock time of the actual AST parses across all rayon workers.
1127    pub parse_cpu_ms: f64,
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132    use super::*;
1133
1134    fn span() -> Span {
1135        Span::new(0, 1)
1136    }
1137
1138    macro_rules! assert_released {
1139        ($values:expr) => {{
1140            assert!($values.is_empty());
1141            assert_eq!($values.capacity(), 0);
1142        }};
1143    }
1144
1145    #[test]
1146    fn public_env_var_includes_public_ci_metadata() {
1147        for name in ["TAG_REF", "GITHUB_SHA", "CI_COMMIT_BRANCH", "APP_MODE"] {
1148            assert!(is_public_env_var(name), "{name} should be public metadata");
1149        }
1150    }
1151
1152    #[test]
1153    fn public_env_var_keeps_secret_shaped_names_source_backed() {
1154        for name in ["GITHUB_TOKEN", "REFRESH_TOKEN", "API_KEY", "SECRET_SHA"] {
1155            assert!(
1156                !is_public_env_var(name),
1157                "{name} should remain secret-shaped"
1158            );
1159        }
1160    }
1161
1162    #[test]
1163    fn line_offsets_empty_string() {
1164        assert_eq!(compute_line_offsets(""), vec![0]);
1165    }
1166
1167    #[test]
1168    fn release_resolution_payload_drops_copied_vectors_only() {
1169        let mut module = ModuleInfo {
1170            file_id: FileId(7),
1171            exports: vec![ExportInfo {
1172                name: ExportName::Named("kept".to_string()),
1173                local_name: None,
1174                is_type_only: false,
1175                is_side_effect_used: false,
1176                visibility: VisibilityTag::None,
1177                span: span(),
1178                members: Vec::new(),
1179                super_class: None,
1180            }],
1181            imports: vec![ImportInfo {
1182                source: "node:child_process".to_string(),
1183                imported_name: ImportedName::Default,
1184                local_name: "childProcess".to_string(),
1185                is_type_only: false,
1186                from_style: false,
1187                span: span(),
1188                source_span: span(),
1189            }],
1190            re_exports: vec![ReExportInfo {
1191                source: "./kept".to_string(),
1192                imported_name: "kept".to_string(),
1193                exported_name: "kept".to_string(),
1194                is_type_only: false,
1195                span: span(),
1196            }],
1197            dynamic_imports: vec![DynamicImportInfo {
1198                source: "./dynamic".to_string(),
1199                span: span(),
1200                destructured_names: vec!["value".to_string()],
1201                local_name: None,
1202                is_speculative: false,
1203            }],
1204            dynamic_import_patterns: vec![DynamicImportPattern {
1205                prefix: "./pages/".to_string(),
1206                suffix: Some(".tsx".to_string()),
1207                span: span(),
1208            }],
1209            require_calls: vec![RequireCallInfo {
1210                source: "./required".to_string(),
1211                span: span(),
1212                source_span: span(),
1213                destructured_names: Vec::new(),
1214                local_name: Some("required".to_string()),
1215            }],
1216            package_path_references: vec!["react".to_string()],
1217            member_accesses: vec![MemberAccess {
1218                object: "Status".to_string(),
1219                member: "Active".to_string(),
1220            }],
1221            whole_object_uses: vec!["Status".to_string()],
1222            has_cjs_exports: true,
1223            has_angular_component_template_url: true,
1224            content_hash: 42,
1225            suppressions: Vec::new(),
1226            unknown_suppression_kinds: Vec::new(),
1227            unused_import_bindings: vec!["unused".to_string()],
1228            type_referenced_import_bindings: vec!["TypeOnly".to_string()],
1229            value_referenced_import_bindings: vec!["Value".to_string()],
1230            line_offsets: vec![0, 8],
1231            complexity: vec![FunctionComplexity {
1232                name: "work".to_string(),
1233                line: 1,
1234                col: 0,
1235                cyclomatic: 2,
1236                cognitive: 3,
1237                line_count: 4,
1238                param_count: 1,
1239                source_hash: Some("hash".to_string()),
1240                contributions: Vec::new(),
1241            }],
1242            flag_uses: vec![FlagUse {
1243                flag_name: "FEATURE_X".to_string(),
1244                kind: FlagUseKind::EnvVar,
1245                line: 1,
1246                col: 0,
1247                guard_span_start: None,
1248                guard_span_end: None,
1249                sdk_name: None,
1250            }],
1251            class_heritage: vec![ClassHeritageInfo {
1252                export_name: "Child".to_string(),
1253                super_class: Some("Parent".to_string()),
1254                implements: vec!["Contract".to_string()],
1255                instance_bindings: Vec::new(),
1256            }],
1257            injection_tokens: vec![("TOKEN".to_string(), "Contract".to_string())],
1258            local_type_declarations: vec![LocalTypeDeclaration {
1259                name: "Contract".to_string(),
1260                span: span(),
1261            }],
1262            public_signature_type_references: vec![PublicSignatureTypeReference {
1263                export_name: "kept".to_string(),
1264                type_name: "Contract".to_string(),
1265                span: span(),
1266            }],
1267            namespace_object_aliases: vec![NamespaceObjectAlias {
1268                via_export_name: "api".to_string(),
1269                suffix: "read".to_string(),
1270                namespace_local: "ns".to_string(),
1271            }],
1272            iconify_prefixes: vec!["hero".to_string()],
1273            iconify_icon_names: vec!["hero-home".to_string()],
1274            auto_import_candidates: vec!["useState".to_string()],
1275            directives: vec!["use client".to_string()],
1276            security_sinks: Vec::new(),
1277            security_sinks_skipped: 1,
1278            security_unresolved_callee_sites: Vec::new(),
1279            tainted_bindings: Vec::new(),
1280            sanitized_sink_args: Vec::new(),
1281            security_control_sites: Vec::new(),
1282            callee_uses: Vec::new(),
1283        };
1284
1285        module.release_resolution_payload();
1286
1287        assert_eq!(module.file_id, FileId(7));
1288        assert_eq!(module.content_hash, 42);
1289        assert_eq!(module.line_offsets, vec![0, 8]);
1290        assert_eq!(module.imports.len(), 1);
1291        assert_eq!(module.exports.len(), 1);
1292        assert_eq!(module.re_exports.len(), 1);
1293        assert_eq!(module.dynamic_import_patterns.len(), 1);
1294        assert_eq!(module.member_accesses.len(), 1);
1295        assert_eq!(module.complexity.len(), 1);
1296        assert_eq!(module.flag_uses.len(), 1);
1297        assert_eq!(module.class_heritage.len(), 1);
1298        assert_eq!(module.injection_tokens.len(), 1);
1299        assert_eq!(module.local_type_declarations.len(), 1);
1300        assert_eq!(module.public_signature_type_references.len(), 1);
1301        assert_eq!(module.iconify_prefixes.len(), 1);
1302        assert_eq!(module.iconify_icon_names.len(), 1);
1303        assert_eq!(module.directives.len(), 1);
1304        assert_eq!(module.security_sinks_skipped, 1);
1305        assert_released!(module.dynamic_imports);
1306        assert_released!(module.require_calls);
1307        assert_released!(module.package_path_references);
1308        assert_released!(module.whole_object_uses);
1309        assert_released!(module.unused_import_bindings);
1310        assert_released!(module.type_referenced_import_bindings);
1311        assert_released!(module.value_referenced_import_bindings);
1312        assert_released!(module.namespace_object_aliases);
1313        assert_released!(module.auto_import_candidates);
1314    }
1315
1316    #[test]
1317    fn sink_shape_bitcode_roundtrip() {
1318        for shape in [
1319            SinkShape::Call,
1320            SinkShape::MemberCall,
1321            SinkShape::MemberAssign,
1322            SinkShape::TaggedTemplate,
1323            SinkShape::JsxAttr,
1324            SinkShape::NewExpression,
1325            SinkShape::SecretLiteral,
1326        ] {
1327            let encoded = bitcode::encode(&shape);
1328            let decoded: SinkShape = bitcode::decode(&encoded).expect("decode sink shape");
1329            assert_eq!(shape, decoded);
1330        }
1331    }
1332
1333    #[test]
1334    fn sink_arg_kind_bitcode_roundtrip() {
1335        for kind in [
1336            SinkArgKind::TemplateWithSubst,
1337            SinkArgKind::Concat,
1338            SinkArgKind::Object,
1339            SinkArgKind::Call,
1340            SinkArgKind::Literal,
1341            SinkArgKind::NoArg,
1342            SinkArgKind::Other,
1343        ] {
1344            let encoded = bitcode::encode(&kind);
1345            let decoded: SinkArgKind = bitcode::decode(&encoded).expect("decode sink arg kind");
1346            assert_eq!(kind, decoded);
1347        }
1348    }
1349
1350    #[test]
1351    fn security_url_shape_bitcode_roundtrip() {
1352        for shape in [
1353            SecurityUrlShape::FixedOriginDynamicPath,
1354            SecurityUrlShape::DynamicOrigin,
1355        ] {
1356            let encoded = bitcode::encode(&shape);
1357            let decoded: SecurityUrlShape =
1358                bitcode::decode(&encoded).expect("decode security url shape");
1359            assert_eq!(shape, decoded);
1360        }
1361    }
1362
1363    #[test]
1364    fn sink_site_bitcode_roundtrip() {
1365        let site = SinkSite {
1366            sink_shape: SinkShape::MemberAssign,
1367            callee_path: "el.innerHTML".to_string(),
1368            arg_index: 0,
1369            arg_is_non_literal: true,
1370            arg_kind: SinkArgKind::Other,
1371            arg_literal: Some(SinkLiteralValue::Integer(511)),
1372            regex_pattern: None,
1373            object_properties: vec![SinkObjectProperty {
1374                key: "origin".to_string(),
1375                value: SinkLiteralValue::String("*".to_string()),
1376            }],
1377            object_property_keys: vec!["origin".to_string()],
1378            object_property_keys_complete: true,
1379            arg_idents: vec!["userInput".to_string()],
1380            arg_source_paths: vec!["req.body.email".to_string(), "req.body".to_string()],
1381            span_start: 10,
1382            span_end: 20,
1383            url_arg_literal: Some("https://api.example.com".to_string()),
1384            url_shape: Some(SecurityUrlShape::FixedOriginDynamicPath),
1385        };
1386        let encoded = bitcode::encode(&site);
1387        let decoded: SinkSite = bitcode::decode(&encoded).expect("decode sink site");
1388        assert_eq!(decoded.sink_shape, site.sink_shape);
1389        assert_eq!(decoded.callee_path, site.callee_path);
1390        assert_eq!(decoded.arg_index, site.arg_index);
1391        assert_eq!(decoded.arg_is_non_literal, site.arg_is_non_literal);
1392        assert_eq!(decoded.arg_kind, site.arg_kind);
1393        assert_eq!(decoded.arg_literal, site.arg_literal);
1394        assert_eq!(decoded.object_properties, site.object_properties);
1395        assert_eq!(decoded.object_property_keys, site.object_property_keys);
1396        assert_eq!(
1397            decoded.object_property_keys_complete,
1398            site.object_property_keys_complete
1399        );
1400        assert_eq!(decoded.arg_idents, site.arg_idents);
1401        assert_eq!(decoded.arg_source_paths, site.arg_source_paths);
1402        assert_eq!(decoded.url_shape, site.url_shape);
1403        assert_eq!(decoded.span(), site.span());
1404    }
1405
1406    #[test]
1407    fn line_offsets_single_line_no_newline() {
1408        assert_eq!(compute_line_offsets("hello"), vec![0]);
1409    }
1410
1411    #[test]
1412    fn line_offsets_single_line_with_newline() {
1413        assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
1414    }
1415
1416    #[test]
1417    fn line_offsets_multiple_lines() {
1418        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
1419    }
1420
1421    #[test]
1422    fn line_offsets_trailing_newline() {
1423        assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
1424    }
1425
1426    #[test]
1427    fn line_offsets_consecutive_newlines() {
1428        assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
1429    }
1430
1431    #[test]
1432    fn line_offsets_multibyte_utf8() {
1433        assert_eq!(compute_line_offsets("รก\n"), vec![0, 3]);
1434    }
1435
1436    #[test]
1437    fn line_col_offset_zero() {
1438        let offsets = compute_line_offsets("abc\ndef\nghi");
1439        let (line, col) = byte_offset_to_line_col(&offsets, 0);
1440        assert_eq!((line, col), (1, 0));
1441    }
1442
1443    #[test]
1444    fn line_col_middle_of_first_line() {
1445        let offsets = compute_line_offsets("abc\ndef\nghi");
1446        let (line, col) = byte_offset_to_line_col(&offsets, 2);
1447        assert_eq!((line, col), (1, 2));
1448    }
1449
1450    #[test]
1451    fn line_col_start_of_second_line() {
1452        let offsets = compute_line_offsets("abc\ndef\nghi");
1453        let (line, col) = byte_offset_to_line_col(&offsets, 4);
1454        assert_eq!((line, col), (2, 0));
1455    }
1456
1457    #[test]
1458    fn line_col_middle_of_second_line() {
1459        let offsets = compute_line_offsets("abc\ndef\nghi");
1460        let (line, col) = byte_offset_to_line_col(&offsets, 5);
1461        assert_eq!((line, col), (2, 1));
1462    }
1463
1464    #[test]
1465    fn line_col_start_of_third_line() {
1466        let offsets = compute_line_offsets("abc\ndef\nghi");
1467        let (line, col) = byte_offset_to_line_col(&offsets, 8);
1468        assert_eq!((line, col), (3, 0));
1469    }
1470
1471    #[test]
1472    fn line_col_end_of_file() {
1473        let offsets = compute_line_offsets("abc\ndef\nghi");
1474        let (line, col) = byte_offset_to_line_col(&offsets, 10);
1475        assert_eq!((line, col), (3, 2));
1476    }
1477
1478    #[test]
1479    fn line_col_single_line() {
1480        let offsets = compute_line_offsets("hello");
1481        let (line, col) = byte_offset_to_line_col(&offsets, 3);
1482        assert_eq!((line, col), (1, 3));
1483    }
1484
1485    #[test]
1486    fn line_col_at_newline_byte() {
1487        let offsets = compute_line_offsets("abc\ndef");
1488        let (line, col) = byte_offset_to_line_col(&offsets, 3);
1489        assert_eq!((line, col), (1, 3));
1490    }
1491
1492    #[test]
1493    fn export_name_matches_str_named() {
1494        let name = ExportName::Named("foo".to_string());
1495        assert!(name.matches_str("foo"));
1496        assert!(!name.matches_str("bar"));
1497        assert!(!name.matches_str("default"));
1498    }
1499
1500    #[test]
1501    fn export_name_matches_str_default() {
1502        let name = ExportName::Default;
1503        assert!(name.matches_str("default"));
1504        assert!(!name.matches_str("foo"));
1505    }
1506
1507    #[test]
1508    fn export_name_display_named() {
1509        let name = ExportName::Named("myExport".to_string());
1510        assert_eq!(name.to_string(), "myExport");
1511    }
1512
1513    #[test]
1514    fn export_name_display_default() {
1515        let name = ExportName::Default;
1516        assert_eq!(name.to_string(), "default");
1517    }
1518
1519    #[test]
1520    fn export_name_equality_named() {
1521        let a = ExportName::Named("foo".to_string());
1522        let b = ExportName::Named("foo".to_string());
1523        let c = ExportName::Named("bar".to_string());
1524        assert_eq!(a, b);
1525        assert_ne!(a, c);
1526    }
1527
1528    #[test]
1529    fn export_name_equality_default() {
1530        let a = ExportName::Default;
1531        let b = ExportName::Default;
1532        assert_eq!(a, b);
1533    }
1534
1535    #[test]
1536    fn export_name_named_not_equal_to_default() {
1537        let named = ExportName::Named("default".to_string());
1538        let default = ExportName::Default;
1539        assert_ne!(named, default);
1540    }
1541
1542    #[test]
1543    fn export_name_hash_consistency() {
1544        use std::collections::hash_map::DefaultHasher;
1545        use std::hash::{Hash, Hasher};
1546
1547        let mut h1 = DefaultHasher::new();
1548        let mut h2 = DefaultHasher::new();
1549        ExportName::Named("foo".to_string()).hash(&mut h1);
1550        ExportName::Named("foo".to_string()).hash(&mut h2);
1551        assert_eq!(h1.finish(), h2.finish());
1552    }
1553
1554    #[test]
1555    fn export_name_matches_str_empty_string() {
1556        let name = ExportName::Named(String::new());
1557        assert!(name.matches_str(""));
1558        assert!(!name.matches_str("foo"));
1559    }
1560
1561    #[test]
1562    fn export_name_default_does_not_match_empty() {
1563        let name = ExportName::Default;
1564        assert!(!name.matches_str(""));
1565    }
1566
1567    #[test]
1568    fn imported_name_equality() {
1569        assert_eq!(
1570            ImportedName::Named("foo".to_string()),
1571            ImportedName::Named("foo".to_string())
1572        );
1573        assert_ne!(
1574            ImportedName::Named("foo".to_string()),
1575            ImportedName::Named("bar".to_string())
1576        );
1577        assert_eq!(ImportedName::Default, ImportedName::Default);
1578        assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
1579        assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
1580        assert_ne!(ImportedName::Default, ImportedName::Namespace);
1581        assert_ne!(
1582            ImportedName::Named("default".to_string()),
1583            ImportedName::Default
1584        );
1585    }
1586
1587    #[test]
1588    fn member_kind_equality() {
1589        assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
1590        assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
1591        assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
1592        assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
1593        assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
1594        assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
1595        assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
1596    }
1597
1598    #[test]
1599    fn member_kind_bitcode_roundtrip() {
1600        let kinds = [
1601            MemberKind::EnumMember,
1602            MemberKind::ClassMethod,
1603            MemberKind::ClassProperty,
1604            MemberKind::NamespaceMember,
1605        ];
1606        for kind in &kinds {
1607            let bytes = bitcode::encode(kind);
1608            let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
1609            assert_eq!(&decoded, kind);
1610        }
1611    }
1612
1613    #[test]
1614    fn member_access_bitcode_roundtrip() {
1615        let access = MemberAccess {
1616            object: "Status".to_string(),
1617            member: "Active".to_string(),
1618        };
1619        let bytes = bitcode::encode(&access);
1620        let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
1621        assert_eq!(decoded.object, "Status");
1622        assert_eq!(decoded.member, "Active");
1623    }
1624
1625    #[test]
1626    fn line_offsets_crlf_only_counts_lf() {
1627        let offsets = compute_line_offsets("ab\r\ncd");
1628        assert_eq!(offsets, vec![0, 4]);
1629    }
1630
1631    #[test]
1632    fn line_col_empty_file_offset_zero() {
1633        let offsets = compute_line_offsets("");
1634        let (line, col) = byte_offset_to_line_col(&offsets, 0);
1635        assert_eq!((line, col), (1, 0));
1636    }
1637
1638    #[test]
1639    fn function_complexity_bitcode_roundtrip() {
1640        let fc = FunctionComplexity {
1641            name: "processData".to_string(),
1642            line: 42,
1643            col: 4,
1644            cyclomatic: 15,
1645            cognitive: 25,
1646            line_count: 80,
1647            param_count: 3,
1648            source_hash: Some("0123456789abcdef".to_string()),
1649            contributions: vec![
1650                ComplexityContribution {
1651                    line: 43,
1652                    col: 8,
1653                    metric: ComplexityMetric::Cyclomatic,
1654                    kind: ComplexityContributionKind::If,
1655                    weight: 1,
1656                    nesting: 0,
1657                },
1658                ComplexityContribution {
1659                    line: 45,
1660                    col: 12,
1661                    metric: ComplexityMetric::Cognitive,
1662                    kind: ComplexityContributionKind::ElseIf,
1663                    weight: 3,
1664                    nesting: 2,
1665                },
1666            ],
1667        };
1668        let bytes = bitcode::encode(&fc);
1669        let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
1670        assert_eq!(decoded.name, "processData");
1671        assert_eq!(decoded.line, 42);
1672        assert_eq!(decoded.col, 4);
1673        assert_eq!(decoded.cyclomatic, 15);
1674        assert_eq!(decoded.cognitive, 25);
1675        assert_eq!(decoded.line_count, 80);
1676        assert_eq!(decoded.source_hash.as_deref(), Some("0123456789abcdef"));
1677        assert_eq!(decoded.contributions.len(), 2);
1678        assert_eq!(
1679            decoded.contributions[1].kind,
1680            ComplexityContributionKind::ElseIf
1681        );
1682        assert_eq!(decoded.contributions[1].weight, 3);
1683        assert_eq!(decoded.contributions[1].nesting, 2);
1684        assert_eq!(decoded.contributions[1].metric, ComplexityMetric::Cognitive);
1685    }
1686}