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    /// Byte-offset starts of dynamic `import()` expressions wrapped in
90    /// `next/dynamic(() => import('./X'), { ssr: false })`. The ssr:false option
91    /// is Next.js's sanctioned way to pull a client-only module, so a server-only
92    /// module reached ONLY through such an import is NOT a client-server leak. The
93    /// security `client-server-leak` BFS resolves each dynamic import to a graph
94    /// edge; these span starts let the BFS exclude exactly those edges (matched
95    /// against the edge's `import_span`). Empty for files with no ssr:false
96    /// dynamic import. Captured only by JS/TS extraction.
97    pub client_only_dynamic_import_spans: Vec<u32>,
98    /// Captured security sink sites (category-blind). Consumed by the
99    /// catalogue-driven `tainted_sink` detector. Captured only by JS/TS
100    /// extraction; empty for CSS/MDX/etc. See `security_matchers.toml`.
101    pub security_sinks: Vec<SinkSite>,
102    /// Count of sink-shaped nodes whose callee could not be flattened to a
103    /// static path (dynamic dispatch, computed members, aliased bindings).
104    /// Surfaced in-band so an empty catalogue result with a non-zero count is
105    /// not a clean bill.
106    pub security_sinks_skipped: u32,
107    /// Compact span-level diagnostics for skipped security sink callees. Kept
108    /// next to `security_sinks_skipped` so warm-cache and cold-cache security
109    /// output can explain where the blind spots are concentrated without source
110    /// snippets.
111    pub security_unresolved_callee_sites: Vec<SkippedSecurityCalleeSite>,
112    /// Local bindings whose initializer (or destructured object) is a flattened
113    /// member-access path. Used by the security `tainted_sink` detector to
114    /// back-trace a sink argument to a known untrusted source: the analyze layer
115    /// matches each binding's `source_path` against the data-driven source
116    /// catalogue (`security_matchers.toml` `[[source]]` rows) and treats the
117    /// matching `local` names as source-tainted. Intra-module and name-based
118    /// (no scope analysis); a conservative association, never a taint proof.
119    pub tainted_bindings: Vec<TaintedBinding>,
120    /// Sink arguments that were recognized as sanitizer calls at extraction
121    /// time. Used for direct sink calls such as
122    /// `el.innerHTML = DOMPurify.sanitize(input)`.
123    pub sanitized_sink_args: Vec<SanitizedSinkArg>,
124    /// Known defensive control call sites found in this module. Consumed only by
125    /// the `fallow security --surface` agent JSON path.
126    pub security_control_sites: Vec<SecurityControlSite>,
127    /// Statically flattenable callee paths invoked in this module, deduped per
128    /// unique path (first occurrence wins). Consumed by the
129    /// `boundaries.calls.forbidden` detector. Captured unconditionally because
130    /// extraction is config-blind; the per-module cost is bounded by the
131    /// unique-callee count.
132    pub callee_uses: Vec<CalleeUse>,
133    /// `"use client"` / `"use server"` directive strings written as expression
134    /// statements in `program.body` (misplaced, NOT in the leading
135    /// prologue), so the RSC bundler silently ignores them. One entry per
136    /// occurrence. Consumed by the `misplaced-directive` detector. Captured
137    /// only by JS/TS extraction.
138    pub misplaced_directives: Vec<MisplacedDirectiveSite>,
139    /// Export LOCAL NAMES of exported functions / const-arrows whose body has an
140    /// inline `"use server"` directive (`export async function f() { "use server"
141    /// }`), captured in a NON-`"use server"` file. Consumed by the
142    /// `unused-server-action` detector to reclassify an unused inline Server
143    /// Action export out of `unused-export`. Captured only by JS/TS extraction.
144    pub inline_server_action_exports: Vec<String>,
145    /// Vue `provide`/`inject` and Svelte `setContext`/`getContext` call sites
146    /// keyed by an identifier symbol. Consumed by the `unprovided-inject`
147    /// detector to find an inject/getContext whose key is provided nowhere
148    /// project-wide. Only identifier-keyed sites are recorded (string-literal
149    /// and computed keys abstain). Captured by JS/TS and SFC extraction.
150    pub di_key_sites: Vec<DiKeySite>,
151    /// `true` when this module contains a `provide(...)` / `*.provide(...)` /
152    /// `setContext(...)` call whose key argument is NOT a plain identifier
153    /// (spread, computed, member, loop variable). Such a call can provide an
154    /// unknowable key, so the `unprovided-inject` detector abstains on ALL
155    /// inject findings project-wide when any reachable module sets this flag.
156    /// Mirrors the spread-return whole-object abstain used for Pinia stores.
157    pub has_dynamic_provide: bool,
158    /// Local names of import bindings that ARE referenced somewhere in this file
159    /// (script value/type position OR template/markup). The complement of
160    /// `unused_import_bindings` among `imports`. Derived in
161    /// `release_resolution_payload` (where both `imports` and
162    /// `unused_import_bindings` are still present) so it survives the release and
163    /// is readable by the analyze layer; it is never cached (recomputed on every
164    /// cache load). Consumed by the `unrendered-component` detector to credit a
165    /// Vue/Svelte SFC that some file actually imports-and-uses, distinguishing it
166    /// from a component reachable only through a barrel re-export.
167    pub referenced_import_bindings: Vec<String>,
168    /// Vue `<script setup>` `defineProps` declared props. Consumed by the
169    /// `unused-component-prop` detector to flag a prop referenced nowhere in its
170    /// own SFC. Each entry carries `used_in_script` / `used_in_template`.
171    pub component_props: Vec<ComponentProp>,
172    /// `true` when the template spreads the whole props/attrs object
173    /// (`v-bind="$attrs"` / `v-bind="$props"` / `v-bind="props"`) or the
174    /// `defineProps` return is destructured with a rest element. Either form can
175    /// consume a prop indirectly, so the detector abstains on the whole file.
176    pub has_props_attrs_fallthrough: bool,
177    /// `true` when the SFC calls `defineExpose(...)`. A prop may be re-exposed,
178    /// so the detector conservatively abstains on the whole file.
179    pub has_define_expose: bool,
180    /// `true` when the SFC calls `defineModel(...)`. Two-way model props are out
181    /// of scope for v1, so the detector abstains on the whole file.
182    pub has_define_model: bool,
183    /// `true` when `defineProps` was called with an unharvestable argument (a
184    /// type-reference type argument such as `defineProps<Props>()` whose names
185    /// require cross-file type resolution). The detector abstains on the whole
186    /// file so a prop is never falsely flagged.
187    pub has_unharvestable_props: bool,
188    /// Vue `<script setup>` `defineEmits` declared events. Consumed by the
189    /// `unused-component-emit` detector to flag an event emitted nowhere in its
190    /// own SFC. Each entry carries `used`.
191    pub component_emits: Vec<ComponentEmit>,
192    /// Angular component/directive inputs declared via `@Input()` decorators or
193    /// signal `input()` / `input.required()` / `model()` initializers. Consumed
194    /// by the `unused-component-input` detector to flag an input read nowhere in
195    /// its own component. Empty for every non-Angular class.
196    pub angular_inputs: Vec<AngularInputMember>,
197    /// Angular component/directive outputs declared via `@Output()` decorators or
198    /// signal `output()` / `outputFromObservable()` initializers. Consumed by the
199    /// `unused-component-output` detector to flag an output emitted nowhere in its
200    /// own component. A `model()` is recorded as an input only (see
201    /// `AngularOutputMember`). Empty for every non-Angular class.
202    pub angular_outputs: Vec<AngularOutputMember>,
203    /// Angular `@Component` declarations with their `selector` value(s), harvested
204    /// from `@Component({ selector: '...' })` decorators. Consumed by the Angular
205    /// arm of the `unrendered-component` detector. Empty for every non-Angular
206    /// class and for `@Directive`. See `AngularComponentSelector`.
207    pub angular_component_selectors: Vec<AngularComponentSelector>,
208    /// Custom element selector tag names referenced in this file's Angular
209    /// templates (inline `@Component({ template })` and the linked external
210    /// `templateUrl` `.html` module), e.g. `<app-foo>` -> `app-foo`. Native HTML
211    /// tag names are excluded at harvest. The detector unions these project-wide
212    /// into the used-selector set. Empty for non-Angular files.
213    pub angular_used_selectors: Vec<String>,
214    /// Angular component class names referenced as a route entry or bootstrap
215    /// target: a route `component: Foo` / `loadComponent: () => import().then(m =>
216    /// m.Foo)` value, a `bootstrapApplication(Foo)` argument, or a
217    /// `bootstrap: [Foo]` NgModule entry. These are render-equivalent entry points
218    /// (Angular instantiates them without a template `<tag>`), so the Angular
219    /// `unrendered-component` detector abstains on a component whose class name is
220    /// in the project-wide union. A plain `declarations: [...]` / `imports: [...]`
221    /// registration is intentionally NOT harvested here (that is the dead case the
222    /// rule catches). Empty for non-Angular files.
223    pub angular_entry_component_refs: Vec<String>,
224    /// `true` when this file dynamically renders an Angular component fallow
225    /// cannot attribute to a literal class reference: a
226    /// `ViewContainerRef.createComponent(...)` / `*.createComponent(<ident>)`
227    /// call, or an `*ngComponentOutlet` template binding. The Angular
228    /// `unrendered-component` detector abstains project-wide when ANY reachable
229    /// module sets this (mirroring `unprovided-inject`'s `has_dynamic_provide`),
230    /// since a component could be rendered by a non-literal class reference.
231    pub has_dynamic_component_render: bool,
232    /// `true` when `defineEmits` was called with an unharvestable argument (a
233    /// type-reference type argument such as `defineEmits<MyEmits>()`, a
234    /// non-literal runtime form, or an unbound `defineEmits([...])`). The
235    /// detector abstains on the whole file so an emit is never falsely flagged.
236    pub has_unharvestable_emits: bool,
237    /// `true` when an `emit(<nonLiteral>)` call was seen (the emitted event name
238    /// cannot be known statically). The detector abstains on the whole file.
239    pub has_dynamic_emit: bool,
240    /// `true` when the `defineEmits` return binding was used as a WHOLE value
241    /// (passed to a function, returned, or spread), which can emit any event
242    /// opaquely. The detector abstains on the whole file.
243    pub has_emit_whole_object_use: bool,
244    /// SvelteKit `load()` return-object keys harvested from a
245    /// `+page.{ts,server.ts,js,server.js}` file's terminal return literal.
246    /// Consumed by the `unused-load-data-key` detector. Empty for every file
247    /// that is not a page-load producer (gated by basename at harvest time).
248    pub load_return_keys: Vec<LoadReturnKey>,
249    /// `true` when this file's `load()` body could not be harvested safely (a
250    /// spread return, a non-object/non-literal return, more than one top-level
251    /// `return`, a computed key, or a wrapped/re-exported `load`). The detector
252    /// abstains on the whole file so a key is never falsely flagged.
253    pub has_unharvestable_load: bool,
254    /// `true` when this file passes the whole `data` object opaquely (script
255    /// `const X = data`, `fn(data)` / `fn(...data)`, or template `data={data}` /
256    /// `{...data}` in a route component), so a child can read arbitrary keys the
257    /// detector cannot see. Name-gated on the `data` binding. Read ONLY by the
258    /// `unused-load-data-key` detector, so capturing it for all files is
259    /// byte-identity-safe. See FP-1 in the plan.
260    pub has_load_data_whole_use: bool,
261    /// `true` when this file uses the whole `page.data` / `$page.data` store
262    /// object opaquely (e.g. `Object.values(page.data)`, `{...$page.data}`), so a
263    /// reflective read could consume any route's key. Drives the
264    /// `unused-load-data-key` detector's project-wide abstain. Derived in
265    /// `release_resolution_payload` from `whole_object_uses` BEFORE that vector is
266    /// released (mirroring `referenced_import_bindings`), so it survives the
267    /// release the detector runs after; it is never cached (recomputed each run
268    /// from the cached `whole_object_uses`). Reassignment forms
269    /// (`const all = $page.data`) are not whole-object-tracked and stay out of
270    /// scope, matching the syntactic analyzer's conservative posture.
271    pub has_page_data_store_whole_use: bool,
272    /// React/JSX component definitions: functions/arrows whose body returns JSX.
273    /// Captured only for `.jsx`/`.tsx` files when a React/Preact dependency is
274    /// plausible. Consumed by the React `unused-component-prop` arm and the
275    /// complexity-fold phase. Empty for non-React files.
276    pub component_functions: Vec<ComponentFunction>,
277    /// React component props (reuses the shared `ComponentProp` struct). For
278    /// React, `used_in_template` is always false and `used_in_script` means
279    /// used-in-body. Empty for non-React files.
280    pub react_props: Vec<ComponentProp>,
281    /// React hook call sites (`useState` / `useEffect` / `useMemo` /
282    /// `useCallback` / custom `use*`). Drives hook-density complexity context.
283    /// Empty for non-React files.
284    pub hook_uses: Vec<HookUse>,
285    /// React render edges: one component rendering another. Captured with the
286    /// child's written name; child-to-`FileId` resolution is deferred to graph
287    /// build. Empty for non-React files.
288    pub render_edges: Vec<RenderEdge>,
289    /// Svelte custom events dispatched via `dispatch('<name>')` where `dispatch`
290    /// is the binding from `const dispatch = createEventDispatcher()`. Consumed
291    /// by the `unused-svelte-event` detector to flag an event dispatched here but
292    /// listened to nowhere project-wide. Each entry carries the literal event
293    /// name and its span. Empty for every non-Svelte file.
294    pub svelte_dispatched_events: Vec<DispatchedEvent>,
295    /// Svelte custom-event listener names harvested from template `on:<name>`
296    /// bindings on COMPONENT tags (PascalCase tag names). Lowercase DOM-element
297    /// `on:click` is a DOM event, not a custom event, and is excluded. Unioned
298    /// project-wide by the `unused-svelte-event` detector to build the liberal
299    /// "listened" set. Empty for every non-Svelte file.
300    pub svelte_listened_events: Vec<String>,
301    /// `true` when a `dispatch(<nonLiteral>)` call was seen (the dispatched event
302    /// name cannot be known statically), or the `dispatch` binding was used as a
303    /// whole value (passed / returned). The `unused-svelte-event` detector
304    /// abstains on the whole component so an event is never falsely flagged.
305    pub has_dynamic_dispatch: bool,
306}
307
308impl ModuleInfo {
309    /// Release extraction payload that resolution has already copied into the graph.
310    ///
311    /// This keeps fields needed by analysis, health, security, LSP, coverage,
312    /// and hash drift checks, while dropping vectors that otherwise duplicate
313    /// data owned by `ResolvedModule` or already credited into the module graph.
314    pub fn release_resolution_payload(&mut self) {
315        // Derive the referenced-binding set BEFORE releasing `unused_import_bindings`:
316        // the analyze-layer `unrendered-component` detector needs "which imports are
317        // actually used" but runs after this release, so capture the compact
318        // complement here. Skip empty local names (side-effect imports).
319        self.referenced_import_bindings = self
320            .imports
321            .iter()
322            .map(|import| import.local_name.clone())
323            .filter(|name| !name.is_empty() && !self.unused_import_bindings.contains(name))
324            .collect();
325        self.referenced_import_bindings.sort_unstable();
326        self.referenced_import_bindings.dedup();
327
328        // Derive the project-wide page-data-store whole-use signal BEFORE
329        // releasing `whole_object_uses`: the `unused-load-data-key` detector runs
330        // after this release and needs to know whether ANY module reflectively
331        // consumes the whole `page.data` / `$page.data` store.
332        self.has_page_data_store_whole_use = self
333            .whole_object_uses
334            .iter()
335            .any(|name| name == "page.data" || name == "$page.data");
336
337        Self::release_vec(&mut self.dynamic_imports);
338        Self::release_vec(&mut self.require_calls);
339        Self::release_vec(&mut self.package_path_references);
340        Self::release_vec(&mut self.whole_object_uses);
341        Self::release_vec(&mut self.unused_import_bindings);
342        Self::release_vec(&mut self.type_referenced_import_bindings);
343        Self::release_vec(&mut self.value_referenced_import_bindings);
344        Self::release_vec(&mut self.namespace_object_aliases);
345        Self::release_vec(&mut self.auto_import_candidates);
346    }
347
348    fn release_vec<T>(values: &mut Vec<T>) {
349        *values = Vec::new();
350    }
351}
352
353/// Defensive control family detected on a source to sink path.
354#[derive(
355    Debug,
356    Clone,
357    Copy,
358    PartialEq,
359    Eq,
360    PartialOrd,
361    Ord,
362    serde::Serialize,
363    serde::Deserialize,
364    bitcode::Encode,
365    bitcode::Decode,
366)]
367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
368#[serde(rename_all = "kebab-case")]
369pub enum SecurityControlKind {
370    /// Sanitization or escaping before a sink.
371    Sanitization,
372    /// Input validation or schema parsing.
373    Validation,
374    /// Authentication check or middleware.
375    Authentication,
376    /// Authorization or permission check.
377    Authorization,
378}
379
380/// A known defensive control call site.
381#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
382pub struct SecurityControlSite {
383    /// Control family.
384    pub kind: SecurityControlKind,
385    /// Flattened callee path or a stable synthetic name for guard-derived
386    /// controls.
387    pub callee_path: String,
388    /// Byte offset of the control span start.
389    pub span_start: u32,
390    /// Byte offset of the control span end.
391    pub span_end: u32,
392}
393
394/// Sanitizer output domain. Kept intentionally narrow so a sanitizer for one
395/// domain cannot suppress a different sink family.
396#[derive(
397    Debug,
398    Clone,
399    Copy,
400    PartialEq,
401    Eq,
402    PartialOrd,
403    Ord,
404    serde::Serialize,
405    serde::Deserialize,
406    bitcode::Encode,
407    bitcode::Decode,
408)]
409pub enum SanitizerScope {
410    /// HTML markup sanitized by DOMPurify-compatible APIs.
411    Html,
412    /// URL or redirect target checked against a literal-backed allowlist.
413    Url,
414    /// Path value checked against a high-confidence containment guard.
415    Path,
416    /// SQL identifier quoted with a helper that doubles embedded identifier quotes.
417    SqlIdentifier,
418}
419
420/// A captured sink argument that is itself a recognized sanitizer call.
421#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
422pub struct SanitizedSinkArg {
423    /// Byte offset of the owning sink span start.
424    pub span_start: u32,
425    /// The positional argument index on the owning sink.
426    pub arg_index: u32,
427    /// The sanitizer output domain for this argument.
428    pub scope: SanitizerScope,
429}
430
431/// A local binding tied to the flattened member-access path it was initialized
432/// from. The analyze layer matches `source_path` against the data-driven source
433/// catalogue; when it matches, `local` is treated as carrying untrusted input.
434///
435/// Captured for two shapes: a direct assignment (`const id = req.query.id` ->
436/// `{ local: "id", source_path: "req.query" }`, the literal-key tail dropped so
437/// the path matches a catalogue prefix) and an object destructure
438/// (`const { id } = req.query` -> `{ local: "id", source_path: "req.query" }`).
439#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
440pub struct TaintedBinding {
441    /// The local binding name introduced by the declarator.
442    pub local: String,
443    /// The flattened object member-access path the binding was sourced from.
444    pub source_path: String,
445    /// Byte offset of the source read (the member-access expression the binding
446    /// was sourced from), so the analyze layer can anchor a taint trace's source
447    /// node at the real read line instead of the module import line. Stored as a
448    /// `u32` (not `Span`) to stay bitcode-encodable for the cache. `0` when no
449    /// concrete read expression is available (synthetic framework-param /
450    /// helper-return bindings), in which case the analyze layer falls back to the
451    /// sink site rather than claiming a spurious line.
452    pub source_span_start: u32,
453}
454
455/// Why a sink-shaped callee could not be flattened into a static catalogue
456/// path.
457#[derive(
458    Debug,
459    Clone,
460    Copy,
461    PartialEq,
462    Eq,
463    PartialOrd,
464    Ord,
465    serde::Serialize,
466    serde::Deserialize,
467    bitcode::Encode,
468    bitcode::Decode,
469)]
470#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
471#[serde(rename_all = "kebab-case")]
472pub enum SkippedSecurityCalleeReason {
473    /// A computed member access such as `client[method](input)`.
474    ComputedMember,
475    /// A dynamic non-member callee such as `(factory())(input)`.
476    DynamicDispatch,
477    /// An assignment target whose object could not be flattened.
478    UnsupportedAssignmentObject,
479}
480
481/// Syntactic expression shape for a skipped security callee.
482#[derive(
483    Debug,
484    Clone,
485    Copy,
486    PartialEq,
487    Eq,
488    PartialOrd,
489    Ord,
490    serde::Serialize,
491    serde::Deserialize,
492    bitcode::Encode,
493    bitcode::Decode,
494)]
495#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
496#[serde(rename_all = "kebab-case")]
497pub enum SkippedSecurityCalleeExpressionKind {
498    /// `obj.prop(...)`.
499    StaticMemberExpression,
500    /// `obj[prop](...)`.
501    ComputedMemberExpression,
502    /// A bare identifier or private identifier callee.
503    Identifier,
504    /// Any other call-like expression that cannot be represented compactly.
505    Other,
506}
507
508/// Span-only diagnostic for a skipped security callee inside one module.
509#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
510pub struct SkippedSecurityCalleeSite {
511    /// Why the callee was skipped.
512    pub reason: SkippedSecurityCalleeReason,
513    /// Compact expression shape of the skipped callee.
514    pub expression_kind: SkippedSecurityCalleeExpressionKind,
515    /// Start byte offset of the skipped callee expression.
516    pub span_start: u32,
517    /// End byte offset of the skipped callee expression.
518    pub span_end: u32,
519}
520
521/// The syntactic shape of a captured security sink site. Category-blind: the
522/// extractor records the shape and the dotted/bare callee path; the analyze
523/// layer matches it against the data-driven catalogue. See
524/// `crates/core/data/security_matchers.toml`.
525#[derive(
526    Debug,
527    Clone,
528    Copy,
529    PartialEq,
530    Eq,
531    serde::Serialize,
532    serde::Deserialize,
533    bitcode::Encode,
534    bitcode::Decode,
535)]
536pub enum SinkShape {
537    /// A call to a bare identifier (e.g. `eval(x)`).
538    Call,
539    /// A call to a dotted member path (e.g. `child_process.exec(x)`).
540    MemberCall,
541    /// An assignment to a member target (e.g. `el.innerHTML = x`).
542    MemberAssign,
543    /// A tagged template expression (e.g. ``sql`...${x}...` ``).
544    TaggedTemplate,
545    /// A JSX attribute value (e.g. `dangerouslySetInnerHTML={x}`).
546    JsxAttr,
547    /// A constructor call (e.g. `new Function("return x")`).
548    NewExpression,
549    /// A static string literal assigned to a secret-shaped identifier or known
550    /// provider credential prefix.
551    SecretLiteral,
552}
553
554/// The shape of the argument captured at a sink site. Category-blind like
555/// [`SinkShape`], but finer-grained: it lets the catalogue matcher require or
556/// exclude specific argument shapes. The discriminator is what distinguishes an
557/// unsafe SQL string concatenation or template-into-`.execute()` from a
558/// safely-parameterized `` sql`${x}` `` tagged template, an object-literal
559/// `.execute({ sql, args })` argument, or a literal-aware sink argument.
560#[derive(
561    Debug,
562    Clone,
563    Copy,
564    PartialEq,
565    Eq,
566    serde::Serialize,
567    serde::Deserialize,
568    bitcode::Encode,
569    bitcode::Decode,
570)]
571pub enum SinkArgKind {
572    /// A template literal with at least one `${...}` substitution (e.g.
573    /// `` `SELECT ${x}` ``). On a `tagged-template` shape this is the tag's
574    /// quasi; on a `call`/`member-call` shape it is the positional argument.
575    TemplateWithSubst,
576    /// A binary `+` string concatenation (e.g. `"SELECT " + x`).
577    Concat,
578    /// An object literal (e.g. `.execute({ sql, args })`, the parameterized form).
579    Object,
580    /// A call expression argument (e.g. `query(buildSql())`).
581    Call,
582    /// A literal argument admitted by a literal-aware security matcher.
583    Literal,
584    /// A zero-argument sink captured because the callee itself is the signal.
585    NoArg,
586    /// Any other non-literal expression (bare identifier, member access, etc.).
587    Other,
588}
589
590/// Static URL construction shape captured for URL-shaped security sinks.
591#[derive(
592    Debug,
593    Clone,
594    Copy,
595    PartialEq,
596    Eq,
597    serde::Serialize,
598    serde::Deserialize,
599    bitcode::Encode,
600    bitcode::Decode,
601)]
602#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
603#[serde(rename_all = "kebab-case")]
604pub enum SecurityUrlShape {
605    /// The sink target has a fixed origin, scheme, or relative root while only
606    /// path or query components are dynamic.
607    FixedOriginDynamicPath,
608    /// The sink target's scheme or origin is dynamic or opaque.
609    DynamicOrigin,
610}
611
612/// Literal values attached to literal-aware security sink captures.
613#[derive(
614    Debug,
615    Clone,
616    PartialEq,
617    Eq,
618    serde::Serialize,
619    serde::Deserialize,
620    bitcode::Encode,
621    bitcode::Decode,
622)]
623pub enum SinkLiteralValue {
624    /// A string literal value.
625    String(String),
626    /// An integer numeric literal value.
627    Integer(i64),
628    /// A boolean literal value.
629    Boolean(bool),
630    /// A null literal value.
631    Null,
632}
633
634/// Static object-literal property metadata attached to a captured sink
635/// argument. Nested object paths are flattened with dot-separated keys.
636#[derive(
637    Debug,
638    Clone,
639    PartialEq,
640    Eq,
641    serde::Serialize,
642    serde::Deserialize,
643    bitcode::Encode,
644    bitcode::Decode,
645)]
646pub struct SinkObjectProperty {
647    /// Static property name. Nested object properties use dot-separated paths.
648    pub key: String,
649    /// Literal property value when statically knowable.
650    pub value: SinkLiteralValue,
651}
652
653/// A captured sink site. The visitor records every existing non-literal call /
654/// member-assign / member-call / tagged-template / jsx-attr sink site, and a
655/// small allowlist of literal-aware sites where the literal value is the signal.
656/// It knows nothing about CWE categories.
657#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
658pub struct SinkSite {
659    /// The syntactic shape of the sink site.
660    pub sink_shape: SinkShape,
661    /// The flattened dotted/bare callee or member path.
662    pub callee_path: String,
663    /// The positional argument index. For zero-argument captures this is 0.
664    pub arg_index: u32,
665    /// Whether the relevant argument is non-literal. Existing non-literal
666    /// catalogue rows require this to remain true.
667    pub arg_is_non_literal: bool,
668    /// The finer-grained shape of the captured argument. Lets the catalogue
669    /// require unsafe shapes (concat / template-with-substitution / literal /
670    /// no-arg) and exclude safe ones (object literal, the parameterized form).
671    /// See [`SinkArgKind`].
672    pub arg_kind: SinkArgKind,
673    /// Literal argument value for literal-aware rows.
674    pub arg_literal: Option<SinkLiteralValue>,
675    /// Risky regex fragment for structural ReDoS candidates.
676    pub regex_pattern: Option<String>,
677    /// Static object-literal properties for option-object rows.
678    pub object_properties: Vec<SinkObjectProperty>,
679    /// Static top-level object-literal keys, including keys whose values are not
680    /// literal. Used by missing-option rows that only need key presence.
681    pub object_property_keys: Vec<String>,
682    /// Whether [`object_property_keys`](Self::object_property_keys) is complete.
683    /// False for non-object arguments and object literals with spread or
684    /// non-static keys, where a missing-key claim would be speculative.
685    pub object_property_keys_complete: bool,
686    /// Identifier names referenced anywhere inside the captured non-literal sink
687    /// argument, or contextual names for zero-argument captures such as a
688    /// token-like `Math.random()` assignment target. Deduped in source order.
689    /// Used by the analyze layer to back-trace the sink argument to a known
690    /// untrusted source or to apply narrow context gates. Intra-module,
691    /// name-based, conservative; it is never a taint proof.
692    pub arg_idents: Vec<String>,
693    /// Flattened static member paths referenced inside the captured non-literal
694    /// sink argument. Includes both the full path and source-object path for
695    /// leaf reads (`process.env.SECRET` records `process.env.SECRET` and
696    /// `process.env`) so direct source expressions can be matched without an
697    /// intermediate local binding.
698    pub arg_source_paths: Vec<String>,
699    /// Byte offset of the sink span start. Stored as `u32` (not `Span`) so the
700    /// struct is bitcode-encodable and can be persisted directly in the cache.
701    pub span_start: u32,
702    /// Byte offset of the sink span end.
703    pub span_end: u32,
704    /// The arg-0 URL string literal of a network-shaped call (`fetch`, `axios.*`,
705    /// `got`, ...), captured so the `secret-to-network` category (#890) can carry
706    /// a destination-host signal on its candidate: `Some(literal)` when the
707    /// destination is a static string literal (almost always intended auth, e.g.
708    /// the credential's own provider), `None` when it is dynamic (the suspicious
709    /// case). `None` for non-call sinks and calls with no arg 0.
710    pub url_arg_literal: Option<String>,
711    /// URL construction shape for URL-like sink arguments when the extractor can
712    /// classify it syntactically. `None` for non-URL sinks and URL expressions
713    /// whose shape is not visible at the sink.
714    pub url_shape: Option<SecurityUrlShape>,
715}
716
717impl SinkSite {
718    /// Reconstruct the source span from the stored byte offsets.
719    #[must_use]
720    pub fn span(&self) -> Span {
721        Span::new(self.span_start, self.span_end)
722    }
723}
724
725/// Env var-name prefixes that frameworks inline into the client bundle by
726/// convention. A read of one of these is normal and safe, so it does NOT count
727/// as a secret source (issue #890). Shared by the extract layer (so public env
728/// vars never become source signals) and the bespoke `client-server-leak` rule.
729pub const PUBLIC_ENV_PREFIXES: &[&str] = &[
730    "NEXT_PUBLIC_",
731    "VITE_",
732    "NUXT_PUBLIC_",
733    "REACT_APP_",
734    "PUBLIC_",
735    "GATSBY_",
736    "EXPO_PUBLIC_",
737    "STORYBOOK_",
738];
739
740/// Exact env var names that are public by convention (no prefix).
741pub const PUBLIC_ENV_EXACT: &[&str] = &["NODE_ENV"];
742
743/// Env var-name tokens that usually describe public build or deployment
744/// metadata rather than secrets. Secret-shaped names win over these tokens.
745pub const PUBLIC_ENV_METADATA_TOKENS: &[&str] =
746    &["BRANCH", "ENVIRONMENT", "MODE", "REF", "SHA", "TAG"];
747
748/// Env var-name tokens that should keep a variable source-backed even when the
749/// name also contains public metadata tokens such as `REF` or `SHA`.
750pub const SECRET_ENV_TOKENS: &[&str] = &[
751    "AUTH",
752    "CREDENTIAL",
753    "CREDENTIALS",
754    "KEY",
755    "PASS",
756    "PASSWORD",
757    "PRIVATE",
758    "SECRET",
759    "TOKEN",
760];
761
762fn env_name_has_token(name: &str, tokens: &[&str]) -> bool {
763    name.split(|ch: char| !ch.is_ascii_alphanumeric())
764        .filter(|part| !part.is_empty())
765        .any(|part| tokens.contains(&part))
766}
767
768/// Whether an env var name is public-by-convention (build-inlined into the
769/// client bundle), and therefore not a secret.
770#[must_use]
771pub fn is_public_env_var(name: &str) -> bool {
772    if PUBLIC_ENV_EXACT.contains(&name) || PUBLIC_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
773        return true;
774    }
775    env_name_has_token(name, PUBLIC_ENV_METADATA_TOKENS)
776        && !env_name_has_token(name, SECRET_ENV_TOKENS)
777}
778
779/// Whether a flattened member path is a PUBLIC env-secret read
780/// (`process.env.NEXT_PUBLIC_X`, `import.meta.env.VITE_Y`), which must not be
781/// recorded as a secret source. Non-env paths (`req.query.id`) are never public.
782#[must_use]
783pub fn is_public_env_path(path: &str) -> bool {
784    for object in ["process.env.", "import.meta.env."] {
785        if let Some(var) = path.strip_prefix(object) {
786            return is_public_env_var(var);
787        }
788    }
789    false
790}
791
792/// One alias entry tying an exported object's dotted property path to a namespace import.
793#[derive(Debug, Clone)]
794pub struct NamespaceObjectAlias {
795    /// Canonical export name.
796    pub via_export_name: String,
797    /// Dotted suffix of the property path relative to the export.
798    pub suffix: String,
799    /// Local name of the namespace import.
800    pub namespace_local: String,
801}
802
803/// Compute a table of line-start byte offsets from source text.
804#[must_use]
805#[expect(
806    clippy::cast_possible_truncation,
807    reason = "source files are practically < 4GB"
808)]
809pub fn compute_line_offsets(source: &str) -> Vec<u32> {
810    let mut offsets = vec![0u32];
811    for (i, byte) in source.bytes().enumerate() {
812        if byte == b'\n' {
813            debug_assert!(
814                u32::try_from(i + 1).is_ok(),
815                "source file exceeds u32::MAX bytes โ€” line offsets would overflow"
816            );
817            offsets.push((i + 1) as u32);
818        }
819    }
820    offsets
821}
822
823/// Convert a byte offset to a 1-based line number and 0-based byte column.
824#[must_use]
825#[expect(
826    clippy::cast_possible_truncation,
827    reason = "line count is bounded by source size"
828)]
829pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
830    let line_idx = match line_offsets.binary_search(&byte_offset) {
831        Ok(idx) => idx,
832        Err(idx) => idx.saturating_sub(1),
833    };
834    let line = line_idx as u32 + 1;
835    let col = byte_offset - line_offsets[line_idx];
836    (line, col)
837}
838
839/// Complexity metrics for a single function/method/arrow.
840#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
841pub struct FunctionComplexity {
842    /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
843    pub name: String,
844    /// 1-based line number where the function starts.
845    pub line: u32,
846    /// 0-based byte column where the function starts.
847    pub col: u32,
848    /// `McCabe` cyclomatic complexity (1 + decision points).
849    pub cyclomatic: u16,
850    /// `SonarSource` cognitive complexity (structural + nesting penalty).
851    pub cognitive: u16,
852    /// Number of lines in the function body.
853    pub line_count: u32,
854    /// Number of parameters (excluding TypeScript's `this` parameter).
855    pub param_count: u8,
856    /// Number of React hook calls (`useState` / `useEffect` / `useMemo` /
857    /// `useCallback` / custom `use*`) made directly in this function's body.
858    /// Non-zero only for React components/hooks; descriptive context surfaced in
859    /// the hotspot drill-down, never a tunable threshold (anti-numerology).
860    pub react_hook_count: u16,
861    /// Maximum JSX element nesting depth reached in this function's body (the
862    /// deepest chain of element-inside-element). `0` when the function renders
863    /// no JSX. Descriptive context surfaced in the hotspot drill-down, never a
864    /// tunable threshold (anti-numerology).
865    pub react_jsx_max_depth: u16,
866    /// Number of props destructured from this component's first parameter (the
867    /// `{ a, b, c }` props object). `0` for non-component functions and for
868    /// components taking a bare `props` identifier (not statically countable).
869    /// Descriptive context surfaced in the hotspot drill-down, never a tunable
870    /// threshold (anti-numerology).
871    pub react_prop_count: u16,
872    /// Content digest of the function's full-span source slice.
873    pub source_hash: Option<String>,
874    /// Per-decision-point breakdown explaining WHICH constructs drove the
875    /// cyclomatic and cognitive scores. One entry per increment event (an `if`
876    /// emits one cyclomatic and one cognitive entry at the same line, because
877    /// the two metrics accrue at different granularities). Always computed and
878    /// cached; surfaced in JSON only behind `health --complexity-breakdown`.
879    pub contributions: Vec<ComplexityContribution>,
880}
881
882/// Structural CSS metrics for a single style rule, computed from the parsed CSS
883/// syntax tree. A rule is recorded only when it crosses a structural floor (an
884/// id selector, a complex selector, a `!important` declaration, or deep
885/// nesting), so the vector stays bounded on normal stylesheets.
886///
887/// Not persisted in the extraction cache: `fallow health` computes these
888/// on demand from the CSS source, so there is no `bitcode` derive.
889#[derive(Debug, Clone, serde::Serialize)]
890#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
891pub struct CssRuleMetric {
892    /// 1-based line of the rule's first selector.
893    pub line: u32,
894    /// 1-based column of the rule's first selector.
895    pub col: u32,
896    /// Specificity component `a` (id selectors), max across the rule's selectors.
897    pub specificity_a: u16,
898    /// Specificity component `b` (class / attribute / pseudo-class selectors).
899    pub specificity_b: u16,
900    /// Specificity component `c` (type / pseudo-element selectors).
901    pub specificity_c: u16,
902    /// Largest selector component count across the rule's selector list.
903    pub complexity: u16,
904    /// Declaration count in the rule (normal plus `!important`).
905    pub declaration_count: u16,
906    /// `!important` declaration count in the rule.
907    pub important_count: u16,
908    /// Style-rule nesting depth (0 = top level).
909    pub nesting_depth: u8,
910}
911
912/// A style rule's declaration-block fingerprint and location, for cross-file
913/// duplicate-block detection. Only rules with a meaningful number of
914/// declarations are recorded (small blocks repeat legitimately). Internal
915/// staging only: this is consumed in-process by the health layer to build the
916/// grouped `duplicate_declaration_blocks` output and is never serialized.
917#[derive(Debug, Clone)]
918pub struct CssDeclarationBlock {
919    /// xxh3 fingerprint over the rule's normalized (sorted, `!important`-tagged)
920    /// declaration set.
921    pub fingerprint: u64,
922    /// 1-based line of the rule's first selector.
923    pub line: u32,
924    /// Declaration count in the rule (normal plus `!important`).
925    pub declaration_count: u16,
926}
927
928/// Stylesheet-level structural CSS analytics, computed from the parsed CSS
929/// syntax tree. Feeds `fallow health` penalty weights and located findings,
930/// never a standalone CSS score.
931#[derive(Debug, Clone, Default, serde::Serialize)]
932#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
933pub struct CssAnalytics {
934    /// Total declarations across every style rule (normal plus `!important`).
935    pub total_declarations: u32,
936    /// Total `!important` declarations across every style rule.
937    pub important_declarations: u32,
938    /// Number of style rules.
939    pub rule_count: u32,
940    /// Number of style rules with no declarations.
941    pub empty_rule_count: u32,
942    /// Deepest style-rule nesting depth observed (0 = no nesting).
943    pub max_nesting_depth: u8,
944    /// Rules that crossed the structural floor, in source order. Bounded; see
945    /// [`Self::notable_truncated`]. The scalar aggregates above always reflect
946    /// the full stylesheet regardless of truncation.
947    pub notable_rules: Vec<CssRuleMetric>,
948    /// `true` when more rules crossed the structural floor than `notable_rules`
949    /// retains (compiled utility CSS can emit thousands of `!important` rules),
950    /// so consumers can note that per-rule findings were capped.
951    pub notable_truncated: bool,
952    /// Distinct color VALUES in the stylesheet, sorted (a palette-size /
953    /// design-token-sprawl signal). The parser canonicalizes notation, so the
954    /// authored format is NOT preserved: `red`, `#f00`, `#ff0000`, and
955    /// `rgb(255,0,0)` all collapse to one entry, and every legacy sRGB notation
956    /// renders as hex. Notation-MIXING (hex vs rgb vs hsl) is therefore not
957    /// detectable from this set; it would need a separate raw-token pass.
958    pub colors: Vec<String>,
959    /// Distinct `font-size` declaration values in the stylesheet, sorted.
960    pub font_sizes: Vec<String>,
961    /// Distinct `z-index` declaration values in the stylesheet, sorted.
962    pub z_indexes: Vec<String>,
963    /// Distinct `box-shadow` declaration values in the stylesheet, sorted. A
964    /// high count signals an uncontrolled shadow scale (design-token sprawl).
965    pub box_shadows: Vec<String>,
966    /// Distinct `border-radius` declaration values in the stylesheet, sorted.
967    pub border_radii: Vec<String>,
968    /// Distinct `line-height` declaration values in the stylesheet, sorted.
969    pub line_heights: Vec<String>,
970    /// Distinct custom properties (`--x`) DEFINED in the stylesheet, sorted.
971    pub defined_custom_properties: Vec<String>,
972    /// Distinct custom properties REFERENCED via `var()` in the stylesheet.
973    pub referenced_custom_properties: Vec<String>,
974    /// Distinct `@keyframes` names DEFINED in the stylesheet, sorted.
975    pub defined_keyframes: Vec<String>,
976    /// Distinct `@keyframes` names REFERENCED via `animation` / `animation-name`.
977    pub referenced_keyframes: Vec<String>,
978    /// Distinct custom properties REGISTERED via an `@property` rule, sorted.
979    pub registered_custom_properties: Vec<String>,
980    /// Distinct cascade layers DECLARED (via `@layer a, b;` statements or named
981    /// `@layer a { }` blocks), sorted.
982    pub declared_layers: Vec<String>,
983    /// Distinct cascade layers POPULATED by a named `@layer a { }` block, sorted.
984    /// A layer declared but never populated (and not imported into) is a
985    /// cleanup candidate.
986    pub populated_layers: Vec<String>,
987    /// Distinct font families DECLARED by an `@font-face` rule in the stylesheet,
988    /// sorted. A declared family referenced by no `font-family` anywhere is a
989    /// dead web-font payload (cleanup candidate).
990    pub defined_font_faces: Vec<String>,
991    /// Distinct font families REFERENCED via `font-family` / `font` in the
992    /// stylesheet, sorted (generic keywords like `serif` excluded).
993    pub referenced_font_families: Vec<String>,
994    /// Per-rule declaration-block fingerprints for rules at or above the minimum
995    /// block size, used to detect duplicate declaration blocks across the
996    /// project. Internal staging consumed by the health layer; never serialized
997    /// (the public output is the grouped `duplicate_declaration_blocks`).
998    #[serde(skip)]
999    #[cfg_attr(feature = "schema", schemars(skip))]
1000    pub declaration_blocks: Vec<CssDeclarationBlock>,
1001}
1002
1003/// Which complexity metric a [`ComplexityContribution`] adds to.
1004#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1005#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1006#[serde(rename_all = "kebab-case")]
1007pub enum ComplexityMetric {
1008    /// `McCabe` cyclomatic complexity (independent execution paths).
1009    Cyclomatic,
1010    /// `SonarSource` cognitive complexity (structural + nesting penalty).
1011    Cognitive,
1012}
1013
1014/// The syntactic construct that produced a single complexity increment.
1015///
1016/// Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
1017/// `Case` means a `case` label carrying a test; a bare `default` adds nothing
1018/// to cyclomatic complexity and so produces no contribution.
1019#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1020#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1021#[serde(rename_all = "kebab-case")]
1022pub enum ComplexityContributionKind {
1023    /// An `if` condition.
1024    If,
1025    /// A bare `else` branch (cognitive only).
1026    Else,
1027    /// An `else if` continuation (both metrics: cyclomatic +1, cognitive flat
1028    /// +1 with no nesting penalty).
1029    ElseIf,
1030    /// A `?:` conditional (ternary) expression.
1031    Ternary,
1032    /// A logical `&&` operator.
1033    LogicalAnd,
1034    /// A logical `||` operator.
1035    LogicalOr,
1036    /// A `??` nullish-coalescing operator.
1037    NullishCoalescing,
1038    /// A logical assignment operator (`&&=`, `||=`, `??=`); cyclomatic only.
1039    LogicalAssignment,
1040    /// An optional-chaining link (`?.`); cyclomatic only.
1041    OptionalChain,
1042    /// A `for` loop.
1043    For,
1044    /// A `for...in` loop.
1045    ForIn,
1046    /// A `for...of` loop.
1047    ForOf,
1048    /// A `while` loop.
1049    While,
1050    /// A `do...while` loop.
1051    DoWhile,
1052    /// A `switch` statement (cognitive only; each `case` adds cyclomatic).
1053    Switch,
1054    /// A `case` label carrying a test (cyclomatic only).
1055    Case,
1056    /// A `catch` clause.
1057    Catch,
1058    /// A labeled `break` (cognitive only).
1059    LabeledBreak,
1060    /// A labeled `continue` (cognitive only).
1061    LabeledContinue,
1062    /// Legacy JSX-depth contribution kind kept for schema compatibility. Current
1063    /// extraction records JSX nesting as descriptive `react_jsx_max_depth`
1064    /// context and does not emit this kind for layout depth.
1065    JsxDepth,
1066    /// React hook density (cognitive only). One contribution per hook call in a
1067    /// component body (`useState` / `useEffect` / `useMemo` / `useCallback` /
1068    /// custom `use*`); a hook-heavy component accrues cognitive load the same way
1069    /// branching does.
1070    HookDensity,
1071    /// React prop count past the comfortable floor (cognitive only). A component
1072    /// destructuring many props is doing many things; the props beyond the floor
1073    /// fold into cognitive so a wide-interface component surfaces as a hotspot.
1074    PropCount,
1075}
1076
1077/// A single complexity increment, located at its source line/column.
1078///
1079/// `weight` is the amount this construct added to `metric`; for nested
1080/// cognitive increments `weight == 1 + nesting`. Consumers that render inline
1081/// (the VS Code editor breakdown) group contributions by `line` and sum the
1082/// weights, deferring the per-kind list to a hover.
1083#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1084#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1085pub struct ComplexityContribution {
1086    /// 1-based line number where the construct begins.
1087    pub line: u32,
1088    /// 0-based byte column where the construct begins.
1089    pub col: u32,
1090    /// Which metric this increment contributes to.
1091    pub metric: ComplexityMetric,
1092    /// The syntactic construct responsible for the increment.
1093    pub kind: ComplexityContributionKind,
1094    /// The amount added to `metric` at this site (`1 + nesting` for nested
1095    /// cognitive increments, otherwise `1`).
1096    pub weight: u16,
1097    /// The nesting depth at the increment site (`0` when not nested). Lets a
1098    /// consumer explain a cognitive `+3` as "+1 base, +2 nesting".
1099    pub nesting: u16,
1100}
1101
1102/// The kind of feature flag pattern detected.
1103#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1104pub enum FlagUseKind {
1105    /// `process.env.FEATURE_X` pattern.
1106    EnvVar,
1107    /// SDK function call like `useFlag('name')`.
1108    SdkCall,
1109    /// Config object access like `config.features.x`.
1110    ConfigObject,
1111}
1112
1113/// A feature flag use site.
1114#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1115pub struct FlagUse {
1116    /// Flag identifier.
1117    pub flag_name: String,
1118    /// Detection kind.
1119    pub kind: FlagUseKind,
1120    /// 1-based line number.
1121    pub line: u32,
1122    /// 0-based byte column offset.
1123    pub col: u32,
1124    /// Start byte offset of the guarded block.
1125    pub guard_span_start: Option<u32>,
1126    /// End byte offset of the guarded block.
1127    pub guard_span_end: Option<u32>,
1128    /// SDK/provider name.
1129    pub sdk_name: Option<String>,
1130}
1131
1132const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);
1133
1134/// A dynamic import with a partially resolved pattern.
1135#[derive(Debug, Clone)]
1136pub struct DynamicImportPattern {
1137    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
1138    pub prefix: String,
1139    /// Static suffix of the import path (e.g., ".json"), if any.
1140    pub suffix: Option<String>,
1141    /// Source span in the original file.
1142    pub span: Span,
1143}
1144
1145/// Visibility tag from JSDoc/TSDoc comments that suppresses unused-export detection.
1146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
1147#[serde(rename_all = "lowercase")]
1148#[repr(u8)]
1149pub enum VisibilityTag {
1150    /// No visibility tag present.
1151    #[default]
1152    None = 0,
1153    /// `@public` or `@api public` -- part of the public API surface.
1154    Public = 1,
1155    /// `@internal` -- exported for internal use (sister packages, build tools).
1156    Internal = 2,
1157    /// `@beta` -- public but unstable, may change without notice.
1158    Beta = 3,
1159    /// `@alpha` -- early preview, may change drastically without notice.
1160    Alpha = 4,
1161    /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
1162    ExpectedUnused = 5,
1163}
1164
1165impl VisibilityTag {
1166    /// Whether this tag permanently suppresses unused-export detection.
1167    /// `ExpectedUnused` is handled separately (conditionally suppresses,
1168    /// reports stale when the export becomes used).
1169    pub const fn suppresses_unused(self) -> bool {
1170        matches!(
1171            self,
1172            Self::Public | Self::Internal | Self::Beta | Self::Alpha
1173        )
1174    }
1175
1176    /// For serde `skip_serializing_if`.
1177    pub fn is_none(&self) -> bool {
1178        matches!(self, Self::None)
1179    }
1180}
1181
1182/// An export declaration.
1183#[derive(Debug, Clone, serde::Serialize)]
1184pub struct ExportInfo {
1185    /// The exported name (named or default).
1186    pub name: ExportName,
1187    /// The local binding name, if different from the exported name.
1188    pub local_name: Option<String>,
1189    /// Whether this is a type-only export (`export type`).
1190    pub is_type_only: bool,
1191    /// Whether this export is registered through a runtime side effect at module load time.
1192    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1193    pub is_side_effect_used: bool,
1194    /// Visibility tag from JSDoc/TSDoc comment.
1195    #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
1196    pub visibility: VisibilityTag,
1197    /// Human-authored reason on `@expected-unused -- <reason>`, when present.
1198    #[serde(default, skip_serializing_if = "Option::is_none")]
1199    pub expected_unused_reason: Option<String>,
1200    /// Source span of the export declaration.
1201    #[serde(serialize_with = "serialize_span")]
1202    pub span: Span,
1203    /// Members of this export (for enums, classes, and namespaces).
1204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1205    pub members: Vec<MemberInfo>,
1206    /// The local name of the parent class from `extends` clause, if any.
1207    #[serde(default, skip_serializing_if = "Option::is_none")]
1208    pub super_class: Option<String>,
1209}
1210
1211/// Additional heritage metadata for an exported class.
1212#[derive(
1213    Debug,
1214    Clone,
1215    serde::Serialize,
1216    serde::Deserialize,
1217    bitcode::Encode,
1218    bitcode::Decode,
1219    PartialEq,
1220    Eq,
1221)]
1222pub struct ClassHeritageInfo {
1223    /// Export name (`default` for default-exported classes).
1224    pub export_name: String,
1225    /// Parent class name from the `extends` clause, if any.
1226    pub super_class: Option<String>,
1227    /// Interface names from the class `implements` clause.
1228    pub implements: Vec<String>,
1229    /// Typed instance bindings used to resolve member-access chains in external templates.
1230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1231    pub instance_bindings: Vec<(String, String)>,
1232}
1233
1234/// A module-scope declaration that can be used as a TypeScript type.
1235#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1236pub struct LocalTypeDeclaration {
1237    /// Local declaration name.
1238    pub name: String,
1239    /// Declaration identifier span.
1240    #[serde(serialize_with = "serialize_span")]
1241    pub span: Span,
1242}
1243
1244/// A reference from an exported symbol's public signature to a type name.
1245#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1246pub struct PublicSignatureTypeReference {
1247    /// Exported symbol whose signature contains the reference.
1248    pub export_name: String,
1249    /// Referenced type name. Qualified names are reduced to their root identifier.
1250    pub type_name: String,
1251    /// Reference span.
1252    #[serde(serialize_with = "serialize_span")]
1253    pub span: Span,
1254}
1255
1256/// A member of an enum, class, or namespace.
1257#[derive(Debug, Clone, serde::Serialize)]
1258pub struct MemberInfo {
1259    /// Member name.
1260    pub name: String,
1261    /// The kind of member (enum, class method/property, or namespace member).
1262    pub kind: MemberKind,
1263    /// Source span of the member declaration.
1264    #[serde(serialize_with = "serialize_span")]
1265    pub span: Span,
1266    /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
1267    /// Decorated members are used by frameworks at runtime and should not be
1268    /// flagged as unused class members, unless every decorator on the member
1269    /// is opted out via `FallowConfig.ignore_decorators`.
1270    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1271    pub has_decorator: bool,
1272    /// Full dotted path of each decorator on this member, in source order.
1273    /// `@step("x")` stores `"step"`; `@ns.foo` stores `"ns.foo"`. Empty for
1274    /// undecorated members, Angular signal-initializer properties (which set
1275    /// `has_decorator` without a literal decorator AST node), and decorators
1276    /// whose expression is not an identifier ladder (the entry is the empty
1277    /// string in that case, treated as never-matching by the predicate).
1278    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1279    pub decorator_names: Vec<String>,
1280    /// True when this is a static class method that returns a fresh instance
1281    /// of the same class: either via `return new this()` / `return new
1282    /// <SameClassName>()` in the body's last statement, or via a declared
1283    /// return type matching the class name. Consumers calling such a static
1284    /// method receive an instance, so the call result's member accesses are
1285    /// credited against the class. See issues #346, #387.
1286    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1287    pub is_instance_returning_static: bool,
1288    /// True when this is an instance class method whose call result is an
1289    /// instance of the same class. Qualifies when the declared return type
1290    /// matches the class name (`setX(): EventBuilder { ... }`) or when the
1291    /// body's last statement is `return this`. The analyze layer walks fluent
1292    /// chains (`Class.factory().setX().setY()`) only through methods carrying
1293    /// this flag, so the chain stops at a non-self-returning method like
1294    /// `.build()`. See issue #387.
1295    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1296    pub is_self_returning: bool,
1297}
1298
1299/// The kind of member.
1300#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1301#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1302#[serde(rename_all = "snake_case")]
1303pub enum MemberKind {
1304    /// A TypeScript enum member.
1305    EnumMember,
1306    /// A class method.
1307    ClassMethod,
1308    /// A class property.
1309    ClassProperty,
1310    /// A member exported from a TypeScript namespace.
1311    NamespaceMember,
1312    /// A member declared by a store object (Pinia `state` / `getters` /
1313    /// `actions` key, or a setup-store returned key). Cross-graph dead-member
1314    /// detection: a store member never accessed by any consumer project-wide.
1315    StoreMember,
1316}
1317
1318/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
1319#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
1320pub struct MemberAccess {
1321    /// The identifier being accessed (the import name).
1322    pub object: String,
1323    /// The member being accessed.
1324    pub member: String,
1325}
1326
1327/// A statically flattenable callee path invoked in a module (e.g. `execSync`,
1328/// `child_process.exec`, `console.log`). One entry per unique `callee_path`
1329/// per module; the span anchors the first occurrence. Consumed by the
1330/// `boundaries.calls.forbidden` detector.
1331#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1332pub struct CalleeUse {
1333    /// The dotted or bare callee path as written at the call site.
1334    pub callee_path: String,
1335    /// Start byte offset of the first call site using this path.
1336    pub span_start: u32,
1337}
1338
1339/// A `"use client"` / `"use server"` directive string written as an expression
1340/// statement in `program.body` (NOT the leading prologue), so the RSC bundler
1341/// silently ignores it. One entry per offending occurrence. Consumed by the
1342/// `misplaced-directive` detector.
1343#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1344pub struct MisplacedDirectiveSite {
1345    /// `true` for `"use server"`, `false` for `"use client"`.
1346    pub is_server: bool,
1347    /// Start byte offset of the misplaced directive statement.
1348    pub span_start: u32,
1349}
1350
1351/// Which side of a dependency-injection link a call site represents.
1352#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1353pub enum DiRole {
1354    /// `provide(KEY, value)` / `app.provide(KEY, value)` / `setContext(KEY, value)`.
1355    Provide,
1356    /// `inject(KEY)` / `getContext(KEY)`.
1357    Inject,
1358}
1359
1360/// Which framework's DI API a call site came from (drives the finding message).
1361#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1362pub enum DiFramework {
1363    /// Vue `provide` / `inject` (from `vue` / `@vue/runtime-core`).
1364    Vue,
1365    /// Svelte `setContext` / `getContext` (from `svelte`).
1366    Svelte,
1367    /// Angular `inject(TOKEN)` / `@Inject(TOKEN)` (from `@angular/core`),
1368    /// matched against `{ provide: TOKEN, ... }` provider objects.
1369    Angular,
1370}
1371
1372/// A Vue `provide`/`inject` or Svelte `setContext`/`getContext` call site keyed
1373/// by an identifier symbol. The `key_local` is resolved at analyze time through
1374/// the consuming module's import/export tables to a canonical defining-site
1375/// export key, so a provide and an inject of the same shared symbol unify even
1376/// across barrel re-exports. Consumed by the `unprovided-inject` detector.
1377#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1378pub struct DiKeySite {
1379    /// The key identifier as written at the call site.
1380    pub key_local: String,
1381    /// Whether this is a provide or an inject.
1382    pub role: DiRole,
1383    /// Which framework's API this came from.
1384    pub framework: DiFramework,
1385    /// Start byte offset of the call expression (anchors the finding).
1386    pub span_start: u32,
1387}
1388
1389/// A Vue `<script setup>` `defineProps` declared prop, harvested from the
1390/// runtime object form (`defineProps({ foo: {...} })`) or the inline TS literal
1391/// form (`defineProps<{ foo: T }>()`). `used_in_script` / `used_in_template`
1392/// are set during extraction; the `unused-component-prop` detector flags a prop
1393/// where neither is true. See `harvest_define_props` in `sfc.rs`.
1394#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1395pub struct ComponentProp {
1396    /// The declared prop name.
1397    pub name: String,
1398    /// The template/script-visible local binding name: the destructure alias for
1399    /// `const { name: alias } = defineProps()`, otherwise the prop name itself.
1400    /// A renamed prop is read through this local, so usage must be checked against
1401    /// it, not the declared name.
1402    pub local: String,
1403    /// Start byte offset of the prop declaration (anchors the finding).
1404    pub span_start: u32,
1405    /// Whether this prop is referenced in the component's `<script>` (a
1406    /// destructured local binding with a resolved reference, or a `props.<name>`
1407    /// member access). For React, this is set-in-body: a resolved reference to the
1408    /// destructured local anywhere in the component function body.
1409    pub used_in_script: bool,
1410    /// Whether this prop name is referenced in the component's `<template>`.
1411    /// Set by `apply_template_usage` when the template scanner credits the name.
1412    /// Always false for React (no template; React uses `used_in_script`).
1413    pub used_in_template: bool,
1414    /// The enclosing component name. Empty for Vue SFCs (one component per file,
1415    /// the file stem is the component, set by the detector). For React this is the
1416    /// component function/arrow name a prop was declared on, so the detector can
1417    /// emit the right `component_name` and apply the per-component abstain ladder
1418    /// (a file can declare several React components).
1419    pub component: String,
1420    /// React-only: `true` when the destructured prop local is referenced at least
1421    /// once OUTSIDE a child-JSX attribute value expression (a substantive
1422    /// consumption: a hook arg, a host-element child, a non-JSX-attr read). When
1423    /// `used_in_script` is true but this is false, the prop is referenced ONLY as
1424    /// the root of forwarded child attribute values, i.e. a pure pass-through.
1425    /// Always `false` for Vue (no forward-vs-consume distinction is computed).
1426    pub used_outside_forward: bool,
1427}
1428
1429/// A Vue `<script setup>` `defineEmits` declared event, harvested from the type
1430/// tuple-call form (`defineEmits<{ (e: 'foo'): void }>()`), the type object form
1431/// (`defineEmits<{ foo: [x: string] }>()`), or the runtime array form
1432/// (`defineEmits(['foo'])`). `used` is set during extraction when the bound emit
1433/// name is called as `emit('<name>')`. The `unused-component-emit` detector flags
1434/// an event where `used` is false. See `harvest_define_emits` in `sfc_props.rs`.
1435#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1436pub struct ComponentEmit {
1437    /// The declared emit event name.
1438    pub name: String,
1439    /// Start byte offset of the emit declaration (anchors the finding).
1440    pub span_start: u32,
1441    /// Whether this event is emitted via `emit('<name>')` somewhere in the
1442    /// component's `<script>`.
1443    pub used: bool,
1444}
1445
1446/// A Svelte custom event dispatched via `dispatch('<name>')`, where `dispatch`
1447/// is the binding from a `const dispatch = createEventDispatcher()` call. Only
1448/// literal-first-arg dispatches are recorded; a `dispatch(<nonLiteral>)` sets
1449/// `ModuleInfo::has_dynamic_dispatch` instead. Consumed by the
1450/// `unused-svelte-event` detector, which flags an event dispatched here but
1451/// listened to nowhere project-wide (the cross-file dead-output direction). The
1452/// span is a byte offset (not an `oxc_span::Span`) so the type round-trips
1453/// through the bitcode cache directly, mirroring `ComponentEmit::span_start`.
1454#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1455pub struct DispatchedEvent {
1456    /// The dispatched event name (the literal first argument).
1457    pub name: String,
1458    /// Start byte offset of the `dispatch(...)` call (anchors the finding).
1459    pub span_start: u32,
1460}
1461
1462/// A declared Angular component/directive input, harvested from an `@Input()`
1463/// decorator or a signal `input()` / `input.required()` / `model()` initializer
1464/// on an Angular-decorated class. Consumed by the `unused-component-input`
1465/// detector, which flags an input read nowhere in its own component (neither the
1466/// template nor the class body). The span is stored as a byte offset (not an
1467/// `oxc_span::Span`) so the type is cheap to mirror onto the cache, matching
1468/// `ComponentEmit::span_start`. `ModuleInfo` is not serialized, so no serde
1469/// attrs are derived here. `bitcode` derives let the type be mirrored directly
1470/// onto `CachedModule` (the same pattern as `ComponentEmit`).
1471#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1472pub struct AngularInputMember {
1473    /// The declared input name (the property key).
1474    pub name: String,
1475    /// Start byte offset of the property key (anchors the finding).
1476    pub span_start: u32,
1477}
1478
1479/// A declared Angular component/directive output, harvested from an `@Output()`
1480/// decorator or a signal `output()` / `outputFromObservable()` initializer on an
1481/// Angular-decorated class. Consumed by the `unused-component-output` detector,
1482/// which flags an output emitted nowhere in its own component. A `model()` is an
1483/// input and a framework-driven output, so it is recorded ONLY as an input and
1484/// never appears here (the implicit `update:` emit is framework-managed). The
1485/// span is a byte offset for the same reason as `AngularInputMember`.
1486#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1487pub struct AngularOutputMember {
1488    /// The declared output name (the property key).
1489    pub name: String,
1490    /// Start byte offset of the property key (anchors the finding).
1491    pub span_start: u32,
1492}
1493
1494/// A declared Angular `@Component` and its `selector` value(s), harvested from a
1495/// `@Component({ selector: '...' })` decorator. Consumed by the Angular arm of
1496/// the `unrendered-component` detector, which flags a component whose every
1497/// element selector is used in NO template project-wide (and that is not
1498/// referenced by class name anywhere, e.g. routed / bootstrapped / dynamically
1499/// rendered). A multi-selector string (`'app-foo, [appBar]'`) is split into the
1500/// `selectors` list. The span is stored as a byte offset (not an
1501/// `oxc_span::Span`) so the type round-trips through the bitcode cache directly,
1502/// mirroring `AngularInputMember::span_start`. `@Directive` is intentionally NOT
1503/// harvested here (directives have no template render). `ModuleInfo` is not
1504/// serialized, so no serde attrs are derived.
1505#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1506pub struct AngularComponentSelector {
1507    /// The declared selector strings for this component, split on `,`. A purely
1508    /// element-selector component has only `app-foo`-shaped entries; attribute
1509    /// (`[appFoo]`) and class (`.foo`) selectors are retained verbatim so the
1510    /// detector can abstain when ANY non-element selector is present.
1511    pub selectors: Vec<String>,
1512    /// Start byte offset of the component class declaration (anchors the
1513    /// finding).
1514    pub span_start: u32,
1515    /// The component class name (used to credit routed / bootstrapped / dynamic
1516    /// class-name references project-wide).
1517    pub class_name: String,
1518}
1519
1520/// A key returned from a SvelteKit route `load()` function's terminal return
1521/// object literal. Harvested from `+page.{ts,server.ts,js,server.js}` files
1522/// exporting a `load` function. Consumed by the `unused-load-data-key` detector,
1523/// which flags a key read by no consumer. The span is stored as byte offsets
1524/// (not an `oxc_span::Span`) so the type round-trips through the bitcode cache
1525/// directly, mirroring `DiKeySite::span_start` / `ComponentEmit::span_start`.
1526#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1527pub struct LoadReturnKey {
1528    /// The returned-object property key name.
1529    pub name: String,
1530    /// Start byte offset of the key (anchors the finding).
1531    pub span_start: u32,
1532    /// End byte offset of the key.
1533    pub span_end: u32,
1534}
1535
1536/// The syntactic shape of an identified React component definition. Drives the
1537/// abstain ladder later phases apply: a `forwardRef` / `memo` wrapper whose
1538/// props come from an imported interface fallow cannot resolve must abstain
1539/// (ADR-001), not guess.
1540#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1541pub enum ComponentFunctionKind {
1542    /// A `function Foo() { return <.../> }` declaration.
1543    FnDecl,
1544    /// A `const Foo = () => <.../>` arrow (or function-expression) binding.
1545    Arrow,
1546    /// A `const Foo = forwardRef((props, ref) => <.../>)` wrapper.
1547    ForwardRefWrapper,
1548    /// A `const Foo = memo((props) => <.../>)` wrapper.
1549    MemoWrapper,
1550}
1551
1552/// An identified React component: a function/arrow whose body returns JSX.
1553/// Captured by `visit_jsx_element`'s enclosing-component tracking. The
1554/// `unused-component-prop` (React arm) and complexity-fold phases consume this;
1555/// the abstain flags keep zero-FP on the cases ADR-001 cannot resolve.
1556#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1557pub struct ComponentFunction {
1558    /// The component name (the binding or declaration identifier).
1559    pub name: String,
1560    /// Start byte offset of the component definition (anchors findings).
1561    pub span_start: u32,
1562    /// The syntactic shape of the definition.
1563    pub kind: ComponentFunctionKind,
1564    /// Whether the component is exported from its module (a named export, a
1565    /// `export default`, or re-exported in the same module). Public-API
1566    /// components abstain in the prop phase.
1567    pub is_exported: bool,
1568    /// `true` when the component's props are not statically harvestable: a
1569    /// rest/spread in the signature (`{ ...rest }`), props passed wholesale to a
1570    /// hook/helper, or a `forwardRef` / `memo` wrapper whose props come from an
1571    /// imported interface generic fallow cannot resolve (ADR-001). The prop
1572    /// phase abstains on the whole component when set.
1573    pub has_unharvestable_props: bool,
1574    /// `true` when the component body calls `cloneElement` / `React.cloneElement`.
1575    /// `cloneElement` injects props by reflection, so the static forward-set is
1576    /// incomplete; the prop-drilling phase abstains on any chain through this
1577    /// component (ADR-001, zero-FP).
1578    pub uses_clone_element: bool,
1579    /// `true` when the component renders a `*.Provider` member-expression tag
1580    /// (`<FooContext.Provider>`). A context provider in the subtree means the
1581    /// drilling may be a deliberate non-context choice (or the prop is about to
1582    /// be provided); the prop-drilling phase downgrades/abstains.
1583    pub renders_provider: bool,
1584    /// `true` when the component passes a function as a child render value
1585    /// (render-props / children-as-function: `<Foo>{() => ...}</Foo>` or
1586    /// `<Foo render={() => ...}/>`). The forwarded shape is dynamic; the
1587    /// prop-drilling phase abstains on chains through this component.
1588    pub has_children_as_function: bool,
1589    /// `true` when the component body is pure structural indirection: a single
1590    /// statement returning exactly one capitalized/member-expression JSX element
1591    /// (no host wrapper, no extra children, optionally a fragment wrapping a
1592    /// single element) that forwards props via a bare spread of the component's
1593    /// own props binding / rest local (`<Child {...props}/>`), with NO named
1594    /// attributes alongside the spread and NO self-render. The cross-component
1595    /// `thin-wrapper` phase joins this with hook-density / cyclomatic checks and
1596    /// the resolved single render edge to flag a component that is a candidate
1597    /// for inlining. Computed from the component's own AST only, so it caches
1598    /// byte-identity-safe (ADR-001).
1599    pub is_pure_passthrough: bool,
1600}
1601
1602/// The kind of a React hook call. `Custom` covers any `use*`-named call that is
1603/// not one of the built-in hooks.
1604#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1605pub enum HookUseKind {
1606    /// `useState(...)`.
1607    UseState,
1608    /// `useEffect(...)`.
1609    UseEffect,
1610    /// `useMemo(...)`.
1611    UseMemo,
1612    /// `useCallback(...)`.
1613    UseCallback,
1614    /// Any other `use*`-named call (a custom hook).
1615    Custom,
1616}
1617
1618/// A React hook call site inside a component. Consumed by the complexity-fold
1619/// phase (hook density) and surfaced as descriptive hotspot context.
1620#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1621pub struct HookUse {
1622    /// The hook kind.
1623    pub kind: HookUseKind,
1624    /// The dependency-array arity, recorded ONLY when a literal array is present
1625    /// at the dependency-array position (`[a, b]` -> `Some(2)`, `[]` ->
1626    /// `Some(0)`). `None` when the call has no dependency array argument or the
1627    /// argument is not a literal array (ADR-001: do not guess).
1628    pub dep_array_arity: Option<u32>,
1629    /// Start byte offset of the hook call (anchors findings).
1630    pub span_start: u32,
1631}
1632
1633/// A render edge: one component rendering another (a capitalized or
1634/// member-expression JSX tag). Captured at extraction time with the child's
1635/// written name; resolution of `child_component_name` to a `FileId`/export is
1636/// deferred to graph build via the existing import map.
1637#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1638pub struct RenderEdge {
1639    /// The name of the component that renders the child (the enclosing
1640    /// component). Empty when the JSX is not inside an identified component (a
1641    /// top-level render expression).
1642    pub parent_component: String,
1643    /// The rendered child component name as written (`Foo` or the full
1644    /// member-expression path `Foo.Bar`).
1645    pub child_component_name: String,
1646    /// The attribute (prop) names passed at the render site, in source order.
1647    pub attr_names: Vec<String>,
1648    /// `true` when the render site contains a JSX spread (`{...x}`), so the
1649    /// passed-prop set is not statically complete.
1650    pub has_spread: bool,
1651    /// The forwarded attributes at this render site: each pairs the child
1652    /// attribute NAME with the identifier ROOT of its value expression
1653    /// (`userName={user.name}` -> `{ attr: "userName", root: "user" }`;
1654    /// `value={x}` -> `{ attr: "value", root: "x" }`). ONLY plain identifier or
1655    /// member-root access values are recorded (`{x}`, `{x.y}`, `{x.y.z}`); a value
1656    /// that is a call, an arrow/function, a conditional, a JSX element, or any
1657    /// other complex expression is NOT recorded here (its root would not be a pure
1658    /// forward) and sets `has_complex_forward` instead. The prop-drilling chain
1659    /// walk uses this pairing to map "this component forwards prop P" to "the
1660    /// child receives it as attribute A".
1661    pub forward_attrs: Vec<ForwardAttr>,
1662    /// `true` when at least one attribute value at this render site is a complex
1663    /// expression (a call, an arrow/function render-prop, a conditional, a JSX
1664    /// element-as-prop, a template literal, etc.) whose identifier root was NOT
1665    /// recorded in `forward_attrs`. The prop-drilling phase abstains on a chain
1666    /// whose forwarded prop flows through such a value (ADR-001, zero-FP).
1667    pub has_complex_forward: bool,
1668}
1669
1670/// One forwarded JSX attribute: the child attribute name plus the identifier
1671/// root of its value expression. See [`RenderEdge::forward_attrs`].
1672#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1673pub struct ForwardAttr {
1674    /// The child attribute (prop) name as written (`userName`).
1675    pub attr: String,
1676    /// The identifier root of the attribute value expression (`user` for
1677    /// `userName={user.name}`).
1678    pub root: String,
1679}
1680
1681#[expect(
1682    clippy::trivially_copy_pass_by_ref,
1683    reason = "serde serialize_with requires &T"
1684)]
1685fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
1686    use serde::ser::SerializeMap;
1687    let mut map = serializer.serialize_map(Some(2))?;
1688    map.serialize_entry("start", &span.start)?;
1689    map.serialize_entry("end", &span.end)?;
1690    map.end()
1691}
1692
1693/// Export identifier.
1694#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
1695pub enum ExportName {
1696    /// A named export (e.g., `export const foo`).
1697    Named(String),
1698    /// The default export.
1699    Default,
1700}
1701
1702impl ExportName {
1703    /// Compare against a string without allocating (avoids `to_string()`).
1704    #[must_use]
1705    pub fn matches_str(&self, s: &str) -> bool {
1706        match self {
1707            Self::Named(n) => n == s,
1708            Self::Default => s == "default",
1709        }
1710    }
1711}
1712
1713impl std::fmt::Display for ExportName {
1714    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1715        match self {
1716            Self::Named(n) => write!(f, "{n}"),
1717            Self::Default => write!(f, "default"),
1718        }
1719    }
1720}
1721
1722/// An import declaration.
1723#[derive(Debug, Clone)]
1724pub struct ImportInfo {
1725    /// The import specifier (e.g., `./utils` or `react`).
1726    pub source: String,
1727    /// How the symbol is imported (named, default, namespace, or side-effect).
1728    pub imported_name: ImportedName,
1729    /// The local binding name in the importing module.
1730    pub local_name: String,
1731    /// Whether this is a type-only import (`import type`).
1732    pub is_type_only: bool,
1733    /// Whether this import originated from a CSS-context.
1734    pub from_style: bool,
1735    /// Source span of the import declaration.
1736    pub span: Span,
1737    /// Span of the source string literal used by the LSP to highlight the specifier.
1738    pub source_span: Span,
1739}
1740
1741/// How a symbol is imported.
1742#[derive(Debug, Clone, PartialEq, Eq)]
1743pub enum ImportedName {
1744    /// A named import (e.g., `import { foo }`).
1745    Named(String),
1746    /// A default import (e.g., `import React`).
1747    Default,
1748    /// A namespace import (e.g., `import * as utils`).
1749    Namespace,
1750    /// A side-effect import (e.g., `import './styles.css'`).
1751    SideEffect,
1752}
1753
1754#[cfg(target_pointer_width = "64")]
1755const _: () = assert!(std::mem::size_of::<ExportInfo>() == 136);
1756#[cfg(target_pointer_width = "64")]
1757const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
1758#[cfg(target_pointer_width = "64")]
1759const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
1760#[cfg(target_pointer_width = "64")]
1761const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
1762#[cfg(target_pointer_width = "64")]
1763const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
1764#[cfg(target_pointer_width = "64")]
1765const _: () = assert!(std::mem::size_of::<SinkSite>() == 216);
1766#[cfg(target_pointer_width = "64")]
1767const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 1256);
1768
1769/// A re-export declaration.
1770#[derive(Debug, Clone)]
1771pub struct ReExportInfo {
1772    /// The module being re-exported from.
1773    pub source: String,
1774    /// The name imported from the source module (or `*` for star re-exports).
1775    pub imported_name: String,
1776    /// The name exported from this module.
1777    pub exported_name: String,
1778    /// Whether this is a type-only re-export.
1779    pub is_type_only: bool,
1780    /// Source span of the re-export declaration on this module.
1781    pub span: oxc_span::Span,
1782}
1783
1784/// A dynamic `import()` call.
1785#[derive(Debug, Clone)]
1786pub struct DynamicImportInfo {
1787    /// The import specifier.
1788    pub source: String,
1789    /// Source span of the `import()` expression.
1790    pub span: Span,
1791    /// Names destructured from the dynamic import result.
1792    /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
1793    /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
1794    pub destructured_names: Vec<String>,
1795    /// The local variable name for `const x = await import(...)`.
1796    /// Used for namespace import narrowing via member access tracking.
1797    pub local_name: Option<String>,
1798    /// True when this dynamic import was synthesised by fallow rather than appearing in user source.
1799    pub is_speculative: bool,
1800}
1801
1802/// A `require()` call.
1803#[derive(Debug, Clone)]
1804pub struct RequireCallInfo {
1805    /// The require specifier.
1806    pub source: String,
1807    /// Source span of the `require()` call.
1808    pub span: Span,
1809    /// Source span of the specifier string-literal argument (including its
1810    /// quotes), e.g. the `'./x'` in `require('./x')`. Used to anchor an
1811    /// `unresolved-import` diagnostic squiggly under the specifier rather than
1812    /// the `require` keyword. `Span::default()` when the argument is not a
1813    /// plain string literal.
1814    pub source_span: Span,
1815    /// Names destructured from the `require()` result.
1816    pub destructured_names: Vec<String>,
1817    /// The local variable name for `const x = require(...)`.
1818    pub local_name: Option<String>,
1819}
1820
1821/// Result of parsing all files, including incremental cache statistics.
1822pub struct ParseResult {
1823    /// Extracted module information for all successfully parsed files.
1824    pub modules: Vec<ModuleInfo>,
1825    /// Number of files whose parse results were loaded from cache (unchanged).
1826    pub cache_hits: usize,
1827    /// Number of files that required a full parse (new or changed).
1828    pub cache_misses: usize,
1829    /// Summed wall-clock time of the actual AST parses across all rayon workers.
1830    pub parse_cpu_ms: f64,
1831}
1832
1833#[cfg(test)]
1834mod tests {
1835    use super::*;
1836
1837    fn span() -> Span {
1838        Span::new(0, 1)
1839    }
1840
1841    macro_rules! assert_released {
1842        ($values:expr) => {{
1843            assert!($values.is_empty());
1844            assert_eq!($values.capacity(), 0);
1845        }};
1846    }
1847
1848    #[test]
1849    fn public_env_var_includes_public_ci_metadata() {
1850        for name in ["TAG_REF", "GITHUB_SHA", "CI_COMMIT_BRANCH", "APP_MODE"] {
1851            assert!(is_public_env_var(name), "{name} should be public metadata");
1852        }
1853    }
1854
1855    #[test]
1856    fn public_env_var_keeps_secret_shaped_names_source_backed() {
1857        for name in ["GITHUB_TOKEN", "REFRESH_TOKEN", "API_KEY", "SECRET_SHA"] {
1858            assert!(
1859                !is_public_env_var(name),
1860                "{name} should remain secret-shaped"
1861            );
1862        }
1863    }
1864
1865    #[test]
1866    fn line_offsets_empty_string() {
1867        assert_eq!(compute_line_offsets(""), vec![0]);
1868    }
1869
1870    #[test]
1871    #[expect(
1872        clippy::too_many_lines,
1873        reason = "exhaustive field-by-field construction + release assertions for every ModuleInfo field"
1874    )]
1875    fn release_resolution_payload_drops_copied_vectors_only() {
1876        let mut module = ModuleInfo {
1877            file_id: FileId(7),
1878            exports: vec![ExportInfo {
1879                name: ExportName::Named("kept".to_string()),
1880                local_name: None,
1881                is_type_only: false,
1882                is_side_effect_used: false,
1883                visibility: VisibilityTag::None,
1884                expected_unused_reason: None,
1885                span: span(),
1886                members: Vec::new(),
1887                super_class: None,
1888            }],
1889            imports: vec![ImportInfo {
1890                source: "node:child_process".to_string(),
1891                imported_name: ImportedName::Default,
1892                local_name: "childProcess".to_string(),
1893                is_type_only: false,
1894                from_style: false,
1895                span: span(),
1896                source_span: span(),
1897            }],
1898            re_exports: vec![ReExportInfo {
1899                source: "./kept".to_string(),
1900                imported_name: "kept".to_string(),
1901                exported_name: "kept".to_string(),
1902                is_type_only: false,
1903                span: span(),
1904            }],
1905            dynamic_imports: vec![DynamicImportInfo {
1906                source: "./dynamic".to_string(),
1907                span: span(),
1908                destructured_names: vec!["value".to_string()],
1909                local_name: None,
1910                is_speculative: false,
1911            }],
1912            dynamic_import_patterns: vec![DynamicImportPattern {
1913                prefix: "./pages/".to_string(),
1914                suffix: Some(".tsx".to_string()),
1915                span: span(),
1916            }],
1917            require_calls: vec![RequireCallInfo {
1918                source: "./required".to_string(),
1919                span: span(),
1920                source_span: span(),
1921                destructured_names: Vec::new(),
1922                local_name: Some("required".to_string()),
1923            }],
1924            package_path_references: vec!["react".to_string()],
1925            member_accesses: vec![MemberAccess {
1926                object: "Status".to_string(),
1927                member: "Active".to_string(),
1928            }],
1929            whole_object_uses: vec!["Status".to_string()],
1930            has_cjs_exports: true,
1931            has_angular_component_template_url: true,
1932            content_hash: 42,
1933            suppressions: Vec::new(),
1934            unknown_suppression_kinds: Vec::new(),
1935            unused_import_bindings: vec!["unused".to_string()],
1936            type_referenced_import_bindings: vec!["TypeOnly".to_string()],
1937            value_referenced_import_bindings: vec!["Value".to_string()],
1938            line_offsets: vec![0, 8],
1939            complexity: vec![FunctionComplexity {
1940                name: "work".to_string(),
1941                line: 1,
1942                col: 0,
1943                cyclomatic: 2,
1944                cognitive: 3,
1945                line_count: 4,
1946                param_count: 1,
1947                react_hook_count: 0,
1948                react_jsx_max_depth: 0,
1949                react_prop_count: 0,
1950                source_hash: Some("hash".to_string()),
1951                contributions: Vec::new(),
1952            }],
1953            flag_uses: vec![FlagUse {
1954                flag_name: "FEATURE_X".to_string(),
1955                kind: FlagUseKind::EnvVar,
1956                line: 1,
1957                col: 0,
1958                guard_span_start: None,
1959                guard_span_end: None,
1960                sdk_name: None,
1961            }],
1962            class_heritage: vec![ClassHeritageInfo {
1963                export_name: "Child".to_string(),
1964                super_class: Some("Parent".to_string()),
1965                implements: vec!["Contract".to_string()],
1966                instance_bindings: Vec::new(),
1967            }],
1968            injection_tokens: vec![("TOKEN".to_string(), "Contract".to_string())],
1969            local_type_declarations: vec![LocalTypeDeclaration {
1970                name: "Contract".to_string(),
1971                span: span(),
1972            }],
1973            public_signature_type_references: vec![PublicSignatureTypeReference {
1974                export_name: "kept".to_string(),
1975                type_name: "Contract".to_string(),
1976                span: span(),
1977            }],
1978            namespace_object_aliases: vec![NamespaceObjectAlias {
1979                via_export_name: "api".to_string(),
1980                suffix: "read".to_string(),
1981                namespace_local: "ns".to_string(),
1982            }],
1983            iconify_prefixes: vec!["hero".to_string()],
1984            iconify_icon_names: vec!["hero-home".to_string()],
1985            auto_import_candidates: vec!["useState".to_string()],
1986            directives: vec!["use client".to_string()],
1987            client_only_dynamic_import_spans: Vec::new(),
1988            security_sinks: Vec::new(),
1989            security_sinks_skipped: 1,
1990            security_unresolved_callee_sites: Vec::new(),
1991            tainted_bindings: Vec::new(),
1992            sanitized_sink_args: Vec::new(),
1993            security_control_sites: Vec::new(),
1994            callee_uses: Vec::new(),
1995            misplaced_directives: Vec::new(),
1996            inline_server_action_exports: Vec::new(),
1997            di_key_sites: Vec::new(),
1998            has_dynamic_provide: false,
1999            referenced_import_bindings: Vec::new(),
2000            component_props: Vec::new(),
2001            has_props_attrs_fallthrough: false,
2002            has_define_expose: false,
2003            has_define_model: false,
2004            has_unharvestable_props: false,
2005            component_emits: Vec::new(),
2006            angular_inputs: Vec::new(),
2007            angular_outputs: Vec::new(),
2008            angular_component_selectors: Vec::new(),
2009            angular_used_selectors: Vec::new(),
2010            angular_entry_component_refs: Vec::new(),
2011            has_dynamic_component_render: false,
2012            has_unharvestable_emits: false,
2013            has_dynamic_emit: false,
2014            has_emit_whole_object_use: false,
2015            load_return_keys: Vec::new(),
2016            has_unharvestable_load: false,
2017            has_load_data_whole_use: false,
2018            has_page_data_store_whole_use: false,
2019            component_functions: Vec::new(),
2020            react_props: Vec::new(),
2021            hook_uses: Vec::new(),
2022            render_edges: Vec::new(),
2023            svelte_dispatched_events: Vec::new(),
2024            svelte_listened_events: Vec::new(),
2025            has_dynamic_dispatch: false,
2026        };
2027
2028        module.release_resolution_payload();
2029
2030        assert_eq!(module.file_id, FileId(7));
2031        assert_eq!(module.content_hash, 42);
2032        assert_eq!(module.line_offsets, vec![0, 8]);
2033        assert_eq!(module.imports.len(), 1);
2034        assert_eq!(module.exports.len(), 1);
2035        assert_eq!(module.re_exports.len(), 1);
2036        assert_eq!(module.dynamic_import_patterns.len(), 1);
2037        assert_eq!(module.member_accesses.len(), 1);
2038        assert_eq!(module.complexity.len(), 1);
2039        assert_eq!(module.flag_uses.len(), 1);
2040        assert_eq!(module.class_heritage.len(), 1);
2041        assert_eq!(module.injection_tokens.len(), 1);
2042        assert_eq!(module.local_type_declarations.len(), 1);
2043        assert_eq!(module.public_signature_type_references.len(), 1);
2044        assert_eq!(module.iconify_prefixes.len(), 1);
2045        assert_eq!(module.iconify_icon_names.len(), 1);
2046        assert_eq!(module.directives.len(), 1);
2047        assert_eq!(module.security_sinks_skipped, 1);
2048        assert_released!(module.dynamic_imports);
2049        assert_released!(module.require_calls);
2050        assert_released!(module.package_path_references);
2051        assert_released!(module.whole_object_uses);
2052        assert_released!(module.unused_import_bindings);
2053        assert_released!(module.type_referenced_import_bindings);
2054        assert_released!(module.value_referenced_import_bindings);
2055        assert_released!(module.namespace_object_aliases);
2056        assert_released!(module.auto_import_candidates);
2057        assert_eq!(
2058            module.referenced_import_bindings,
2059            vec!["childProcess".to_string()]
2060        );
2061    }
2062
2063    #[test]
2064    fn sink_shape_bitcode_roundtrip() {
2065        for shape in [
2066            SinkShape::Call,
2067            SinkShape::MemberCall,
2068            SinkShape::MemberAssign,
2069            SinkShape::TaggedTemplate,
2070            SinkShape::JsxAttr,
2071            SinkShape::NewExpression,
2072            SinkShape::SecretLiteral,
2073        ] {
2074            let encoded = bitcode::encode(&shape);
2075            let decoded: SinkShape = bitcode::decode(&encoded).expect("decode sink shape");
2076            assert_eq!(shape, decoded);
2077        }
2078    }
2079
2080    #[test]
2081    fn sink_arg_kind_bitcode_roundtrip() {
2082        for kind in [
2083            SinkArgKind::TemplateWithSubst,
2084            SinkArgKind::Concat,
2085            SinkArgKind::Object,
2086            SinkArgKind::Call,
2087            SinkArgKind::Literal,
2088            SinkArgKind::NoArg,
2089            SinkArgKind::Other,
2090        ] {
2091            let encoded = bitcode::encode(&kind);
2092            let decoded: SinkArgKind = bitcode::decode(&encoded).expect("decode sink arg kind");
2093            assert_eq!(kind, decoded);
2094        }
2095    }
2096
2097    #[test]
2098    fn security_url_shape_bitcode_roundtrip() {
2099        for shape in [
2100            SecurityUrlShape::FixedOriginDynamicPath,
2101            SecurityUrlShape::DynamicOrigin,
2102        ] {
2103            let encoded = bitcode::encode(&shape);
2104            let decoded: SecurityUrlShape =
2105                bitcode::decode(&encoded).expect("decode security url shape");
2106            assert_eq!(shape, decoded);
2107        }
2108    }
2109
2110    #[test]
2111    fn sink_site_bitcode_roundtrip() {
2112        let site = SinkSite {
2113            sink_shape: SinkShape::MemberAssign,
2114            callee_path: "el.innerHTML".to_string(),
2115            arg_index: 0,
2116            arg_is_non_literal: true,
2117            arg_kind: SinkArgKind::Other,
2118            arg_literal: Some(SinkLiteralValue::Integer(511)),
2119            regex_pattern: None,
2120            object_properties: vec![SinkObjectProperty {
2121                key: "origin".to_string(),
2122                value: SinkLiteralValue::String("*".to_string()),
2123            }],
2124            object_property_keys: vec!["origin".to_string()],
2125            object_property_keys_complete: true,
2126            arg_idents: vec!["userInput".to_string()],
2127            arg_source_paths: vec!["req.body.email".to_string(), "req.body".to_string()],
2128            span_start: 10,
2129            span_end: 20,
2130            url_arg_literal: Some("https://api.example.com".to_string()),
2131            url_shape: Some(SecurityUrlShape::FixedOriginDynamicPath),
2132        };
2133        let encoded = bitcode::encode(&site);
2134        let decoded: SinkSite = bitcode::decode(&encoded).expect("decode sink site");
2135        assert_eq!(decoded.sink_shape, site.sink_shape);
2136        assert_eq!(decoded.callee_path, site.callee_path);
2137        assert_eq!(decoded.arg_index, site.arg_index);
2138        assert_eq!(decoded.arg_is_non_literal, site.arg_is_non_literal);
2139        assert_eq!(decoded.arg_kind, site.arg_kind);
2140        assert_eq!(decoded.arg_literal, site.arg_literal);
2141        assert_eq!(decoded.object_properties, site.object_properties);
2142        assert_eq!(decoded.object_property_keys, site.object_property_keys);
2143        assert_eq!(
2144            decoded.object_property_keys_complete,
2145            site.object_property_keys_complete
2146        );
2147        assert_eq!(decoded.arg_idents, site.arg_idents);
2148        assert_eq!(decoded.arg_source_paths, site.arg_source_paths);
2149        assert_eq!(decoded.url_shape, site.url_shape);
2150        assert_eq!(decoded.span(), site.span());
2151    }
2152
2153    #[test]
2154    fn line_offsets_single_line_no_newline() {
2155        assert_eq!(compute_line_offsets("hello"), vec![0]);
2156    }
2157
2158    #[test]
2159    fn line_offsets_single_line_with_newline() {
2160        assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
2161    }
2162
2163    #[test]
2164    fn line_offsets_multiple_lines() {
2165        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
2166    }
2167
2168    #[test]
2169    fn line_offsets_trailing_newline() {
2170        assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
2171    }
2172
2173    #[test]
2174    fn line_offsets_consecutive_newlines() {
2175        assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
2176    }
2177
2178    #[test]
2179    fn line_offsets_multibyte_utf8() {
2180        assert_eq!(compute_line_offsets("รก\n"), vec![0, 3]);
2181    }
2182
2183    #[test]
2184    fn line_col_offset_zero() {
2185        let offsets = compute_line_offsets("abc\ndef\nghi");
2186        let (line, col) = byte_offset_to_line_col(&offsets, 0);
2187        assert_eq!((line, col), (1, 0));
2188    }
2189
2190    #[test]
2191    fn line_col_middle_of_first_line() {
2192        let offsets = compute_line_offsets("abc\ndef\nghi");
2193        let (line, col) = byte_offset_to_line_col(&offsets, 2);
2194        assert_eq!((line, col), (1, 2));
2195    }
2196
2197    #[test]
2198    fn line_col_start_of_second_line() {
2199        let offsets = compute_line_offsets("abc\ndef\nghi");
2200        let (line, col) = byte_offset_to_line_col(&offsets, 4);
2201        assert_eq!((line, col), (2, 0));
2202    }
2203
2204    #[test]
2205    fn line_col_middle_of_second_line() {
2206        let offsets = compute_line_offsets("abc\ndef\nghi");
2207        let (line, col) = byte_offset_to_line_col(&offsets, 5);
2208        assert_eq!((line, col), (2, 1));
2209    }
2210
2211    #[test]
2212    fn line_col_start_of_third_line() {
2213        let offsets = compute_line_offsets("abc\ndef\nghi");
2214        let (line, col) = byte_offset_to_line_col(&offsets, 8);
2215        assert_eq!((line, col), (3, 0));
2216    }
2217
2218    #[test]
2219    fn line_col_end_of_file() {
2220        let offsets = compute_line_offsets("abc\ndef\nghi");
2221        let (line, col) = byte_offset_to_line_col(&offsets, 10);
2222        assert_eq!((line, col), (3, 2));
2223    }
2224
2225    #[test]
2226    fn line_col_single_line() {
2227        let offsets = compute_line_offsets("hello");
2228        let (line, col) = byte_offset_to_line_col(&offsets, 3);
2229        assert_eq!((line, col), (1, 3));
2230    }
2231
2232    #[test]
2233    fn line_col_at_newline_byte() {
2234        let offsets = compute_line_offsets("abc\ndef");
2235        let (line, col) = byte_offset_to_line_col(&offsets, 3);
2236        assert_eq!((line, col), (1, 3));
2237    }
2238
2239    #[test]
2240    fn export_name_matches_str_named() {
2241        let name = ExportName::Named("foo".to_string());
2242        assert!(name.matches_str("foo"));
2243        assert!(!name.matches_str("bar"));
2244        assert!(!name.matches_str("default"));
2245    }
2246
2247    #[test]
2248    fn export_name_matches_str_default() {
2249        let name = ExportName::Default;
2250        assert!(name.matches_str("default"));
2251        assert!(!name.matches_str("foo"));
2252    }
2253
2254    #[test]
2255    fn export_name_display_named() {
2256        let name = ExportName::Named("myExport".to_string());
2257        assert_eq!(name.to_string(), "myExport");
2258    }
2259
2260    #[test]
2261    fn export_name_display_default() {
2262        let name = ExportName::Default;
2263        assert_eq!(name.to_string(), "default");
2264    }
2265
2266    #[test]
2267    fn export_name_equality_named() {
2268        let a = ExportName::Named("foo".to_string());
2269        let b = ExportName::Named("foo".to_string());
2270        let c = ExportName::Named("bar".to_string());
2271        assert_eq!(a, b);
2272        assert_ne!(a, c);
2273    }
2274
2275    #[test]
2276    fn export_name_equality_default() {
2277        let a = ExportName::Default;
2278        let b = ExportName::Default;
2279        assert_eq!(a, b);
2280    }
2281
2282    #[test]
2283    fn export_name_named_not_equal_to_default() {
2284        let named = ExportName::Named("default".to_string());
2285        let default = ExportName::Default;
2286        assert_ne!(named, default);
2287    }
2288
2289    #[test]
2290    fn export_name_hash_consistency() {
2291        use std::collections::hash_map::DefaultHasher;
2292        use std::hash::{Hash, Hasher};
2293
2294        let mut h1 = DefaultHasher::new();
2295        let mut h2 = DefaultHasher::new();
2296        ExportName::Named("foo".to_string()).hash(&mut h1);
2297        ExportName::Named("foo".to_string()).hash(&mut h2);
2298        assert_eq!(h1.finish(), h2.finish());
2299    }
2300
2301    #[test]
2302    fn export_name_matches_str_empty_string() {
2303        let name = ExportName::Named(String::new());
2304        assert!(name.matches_str(""));
2305        assert!(!name.matches_str("foo"));
2306    }
2307
2308    #[test]
2309    fn export_name_default_does_not_match_empty() {
2310        let name = ExportName::Default;
2311        assert!(!name.matches_str(""));
2312    }
2313
2314    #[test]
2315    fn imported_name_equality() {
2316        assert_eq!(
2317            ImportedName::Named("foo".to_string()),
2318            ImportedName::Named("foo".to_string())
2319        );
2320        assert_ne!(
2321            ImportedName::Named("foo".to_string()),
2322            ImportedName::Named("bar".to_string())
2323        );
2324        assert_eq!(ImportedName::Default, ImportedName::Default);
2325        assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
2326        assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
2327        assert_ne!(ImportedName::Default, ImportedName::Namespace);
2328        assert_ne!(
2329            ImportedName::Named("default".to_string()),
2330            ImportedName::Default
2331        );
2332    }
2333
2334    #[test]
2335    fn member_kind_equality() {
2336        assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
2337        assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
2338        assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
2339        assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
2340        assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
2341        assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
2342        assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
2343    }
2344
2345    #[test]
2346    fn member_kind_bitcode_roundtrip() {
2347        let kinds = [
2348            MemberKind::EnumMember,
2349            MemberKind::ClassMethod,
2350            MemberKind::ClassProperty,
2351            MemberKind::NamespaceMember,
2352        ];
2353        for kind in &kinds {
2354            let bytes = bitcode::encode(kind);
2355            let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
2356            assert_eq!(&decoded, kind);
2357        }
2358    }
2359
2360    #[test]
2361    fn member_access_bitcode_roundtrip() {
2362        let access = MemberAccess {
2363            object: "Status".to_string(),
2364            member: "Active".to_string(),
2365        };
2366        let bytes = bitcode::encode(&access);
2367        let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
2368        assert_eq!(decoded.object, "Status");
2369        assert_eq!(decoded.member, "Active");
2370    }
2371
2372    #[test]
2373    fn line_offsets_crlf_only_counts_lf() {
2374        let offsets = compute_line_offsets("ab\r\ncd");
2375        assert_eq!(offsets, vec![0, 4]);
2376    }
2377
2378    #[test]
2379    fn line_col_empty_file_offset_zero() {
2380        let offsets = compute_line_offsets("");
2381        let (line, col) = byte_offset_to_line_col(&offsets, 0);
2382        assert_eq!((line, col), (1, 0));
2383    }
2384
2385    #[test]
2386    fn function_complexity_bitcode_roundtrip() {
2387        let fc = FunctionComplexity {
2388            name: "processData".to_string(),
2389            line: 42,
2390            col: 4,
2391            cyclomatic: 15,
2392            cognitive: 25,
2393            line_count: 80,
2394            param_count: 3,
2395            react_hook_count: 0,
2396            react_jsx_max_depth: 0,
2397            react_prop_count: 0,
2398            source_hash: Some("0123456789abcdef".to_string()),
2399            contributions: vec![
2400                ComplexityContribution {
2401                    line: 43,
2402                    col: 8,
2403                    metric: ComplexityMetric::Cyclomatic,
2404                    kind: ComplexityContributionKind::If,
2405                    weight: 1,
2406                    nesting: 0,
2407                },
2408                ComplexityContribution {
2409                    line: 45,
2410                    col: 12,
2411                    metric: ComplexityMetric::Cognitive,
2412                    kind: ComplexityContributionKind::ElseIf,
2413                    weight: 3,
2414                    nesting: 2,
2415                },
2416            ],
2417        };
2418        let bytes = bitcode::encode(&fc);
2419        let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
2420        assert_eq!(decoded.name, "processData");
2421        assert_eq!(decoded.line, 42);
2422        assert_eq!(decoded.col, 4);
2423        assert_eq!(decoded.cyclomatic, 15);
2424        assert_eq!(decoded.cognitive, 25);
2425        assert_eq!(decoded.line_count, 80);
2426        assert_eq!(decoded.source_hash.as_deref(), Some("0123456789abcdef"));
2427        assert_eq!(decoded.contributions.len(), 2);
2428        assert_eq!(
2429            decoded.contributions[1].kind,
2430            ComplexityContributionKind::ElseIf
2431        );
2432        assert_eq!(decoded.contributions[1].weight, 3);
2433        assert_eq!(decoded.contributions[1].nesting, 2);
2434        assert_eq!(decoded.contributions[1].metric, ComplexityMetric::Cognitive);
2435    }
2436}