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