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