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` and Svelte 5 `$props()` declared
169    /// props. Consumed by the `unused-component-prop` detector to flag a prop
170    /// referenced nowhere in its own SFC. Each entry carries `used_in_script` /
171    /// `used_in_template`.
172    pub component_props: Vec<ComponentProp>,
173    /// `true` when the template spreads the whole props/attrs object
174    /// (`v-bind="$attrs"` / `v-bind="$props"` / `v-bind="props"`) or the props
175    /// return is destructured with a rest element. Either form can consume a prop
176    /// indirectly, so the detector abstains on the whole file.
177    pub has_props_attrs_fallthrough: bool,
178    /// `true` when the SFC calls `defineExpose(...)`. A prop may be re-exposed,
179    /// so the detector conservatively abstains on the whole file.
180    pub has_define_expose: bool,
181    /// `true` when the SFC calls `defineModel(...)`. Two-way model props are out
182    /// of scope for v1, so the detector abstains on the whole file.
183    pub has_define_model: bool,
184    /// `true` when props were declared through an unharvestable shape, such as a
185    /// Vue type-reference argument or an opaque Svelte `$props()` destructure.
186    /// The detector abstains on the whole file so a prop is never falsely
187    /// flagged.
188    pub has_unharvestable_props: bool,
189    /// Vue `<script setup>` `defineEmits` declared events. Consumed by the
190    /// `unused-component-emit` detector to flag an event emitted nowhere in its
191    /// own SFC. Each entry carries `used`.
192    pub component_emits: Vec<ComponentEmit>,
193    /// Angular component/directive inputs declared via `@Input()` decorators or
194    /// signal `input()` / `input.required()` / `model()` initializers. Consumed
195    /// by the `unused-component-input` detector to flag an input read nowhere in
196    /// its own component. Empty for every non-Angular class.
197    pub angular_inputs: Vec<AngularInputMember>,
198    /// Angular component/directive outputs declared via `@Output()` decorators or
199    /// signal `output()` / `outputFromObservable()` initializers. Consumed by the
200    /// `unused-component-output` detector to flag an output emitted nowhere in its
201    /// own component. A `model()` is recorded as an input only (see
202    /// `AngularOutputMember`). Empty for every non-Angular class.
203    pub angular_outputs: Vec<AngularOutputMember>,
204    /// Angular `@Component` declarations with their `selector` value(s), harvested
205    /// from `@Component({ selector: '...' })` decorators. Consumed by the Angular
206    /// arm of the `unrendered-component` detector. Empty for every non-Angular
207    /// class and for `@Directive`. See `AngularComponentSelector`.
208    pub angular_component_selectors: Vec<AngularComponentSelector>,
209    /// Lit / web-component custom elements REGISTERED in this file via
210    /// `@customElement('x-foo')` or `customElements.define('x-foo', C)`. Consumed
211    /// by the Lit arm of the `unrendered-component` detector, which flags a
212    /// registered element whose tag is rendered in NO `html` template
213    /// project-wide. Empty for non-Lit / non-web-component files. See
214    /// `RegisteredCustomElement`.
215    pub registered_custom_elements: Vec<RegisteredCustomElement>,
216    /// Custom-element tag names USED (rendered) in this file's `html` tagged
217    /// templates, e.g. `` html`<x-foo></x-foo>` `` -> `x-foo`. Only hyphenated
218    /// (custom-element) tags are recorded; native HTML tags are excluded by the
219    /// hyphen requirement. The detector unions these project-wide into the
220    /// rendered-tag set. Empty for files with no `html` templates.
221    pub used_custom_element_tags: Vec<String>,
222    /// Custom element selector tag names referenced in this file's Angular
223    /// templates (inline `@Component({ template })` and the linked external
224    /// `templateUrl` `.html` module), e.g. `<app-foo>` -> `app-foo`. Native HTML
225    /// tag names are excluded at harvest. The detector unions these project-wide
226    /// into the used-selector set. Empty for non-Angular files.
227    pub angular_used_selectors: Vec<String>,
228    /// Angular component class names referenced as a route entry or bootstrap
229    /// target: a route `component: Foo` / `loadComponent: () => import().then(m =>
230    /// m.Foo)` value, a `bootstrapApplication(Foo)` argument, or a
231    /// `bootstrap: [Foo]` NgModule entry. These are render-equivalent entry points
232    /// (Angular instantiates them without a template `<tag>`), so the Angular
233    /// `unrendered-component` detector abstains on a component whose class name is
234    /// in the project-wide union. A plain `declarations: [...]` / `imports: [...]`
235    /// registration is intentionally NOT harvested here (that is the dead case the
236    /// rule catches). Empty for non-Angular files.
237    pub angular_entry_component_refs: Vec<String>,
238    /// `true` when this file dynamically renders an Angular component fallow
239    /// cannot attribute to a literal class reference: a
240    /// `ViewContainerRef.createComponent(...)` / `*.createComponent(<ident>)`
241    /// call, or an `*ngComponentOutlet` template binding. The Angular
242    /// `unrendered-component` detector abstains project-wide when ANY reachable
243    /// module sets this (mirroring `unprovided-inject`'s `has_dynamic_provide`),
244    /// since a component could be rendered by a non-literal class reference.
245    pub has_dynamic_component_render: bool,
246    /// `true` when `defineEmits` was called with an unharvestable argument (a
247    /// type-reference type argument such as `defineEmits<MyEmits>()`, a
248    /// non-literal runtime form, or an unbound `defineEmits([...])`). The
249    /// detector abstains on the whole file so an emit is never falsely flagged.
250    pub has_unharvestable_emits: bool,
251    /// `true` when an `emit(<nonLiteral>)` call was seen (the emitted event name
252    /// cannot be known statically). The detector abstains on the whole file.
253    pub has_dynamic_emit: bool,
254    /// `true` when the `defineEmits` return binding was used as a WHOLE value
255    /// (passed to a function, returned, or spread), which can emit any event
256    /// opaquely. The detector abstains on the whole file.
257    pub has_emit_whole_object_use: bool,
258    /// SvelteKit `load()` return-object keys harvested from a
259    /// `+page.{ts,server.ts,js,server.js}` file's terminal return literal.
260    /// Consumed by the `unused-load-data-key` detector. Empty for every file
261    /// that is not a page-load producer (gated by basename at harvest time).
262    pub load_return_keys: Vec<LoadReturnKey>,
263    /// `true` when this file's `load()` body could not be harvested safely (a
264    /// spread return, a non-object/non-literal return, more than one top-level
265    /// `return`, a computed key, or a wrapped/re-exported `load`). The detector
266    /// abstains on the whole file so a key is never falsely flagged.
267    pub has_unharvestable_load: bool,
268    /// `true` when this file passes the whole `data` object opaquely (script
269    /// `const X = data`, `fn(data)` / `fn(...data)`, or template `data={data}` /
270    /// `{...data}` in a route component), so a child can read arbitrary keys the
271    /// detector cannot see. Name-gated on the `data` binding. Read ONLY by the
272    /// `unused-load-data-key` detector, so capturing it for all files is
273    /// byte-identity-safe. See FP-1 in the plan.
274    pub has_load_data_whole_use: bool,
275    /// `true` when this file uses the whole `page.data` / `$page.data` store
276    /// object opaquely (e.g. `Object.values(page.data)`, `{...$page.data}`), so a
277    /// reflective read could consume any route's key. Drives the
278    /// `unused-load-data-key` detector's project-wide abstain. Derived in
279    /// `release_resolution_payload` from `whole_object_uses` BEFORE that vector is
280    /// released (mirroring `referenced_import_bindings`), so it survives the
281    /// release the detector runs after; it is never cached (recomputed each run
282    /// from the cached `whole_object_uses`). Reassignment forms
283    /// (`const all = $page.data`) are not whole-object-tracked and stay out of
284    /// scope, matching the syntactic analyzer's conservative posture.
285    pub has_page_data_store_whole_use: bool,
286    /// React/JSX component definitions: functions/arrows whose body returns JSX.
287    /// Captured only for `.jsx`/`.tsx` files when a React/Preact dependency is
288    /// plausible. Consumed by the React `unused-component-prop` arm and the
289    /// complexity-fold phase. Empty for non-React files.
290    pub component_functions: Vec<ComponentFunction>,
291    /// React component props (reuses the shared `ComponentProp` struct). For
292    /// React, `used_in_template` is always false and `used_in_script` means
293    /// used-in-body. Empty for non-React files.
294    pub react_props: Vec<ComponentProp>,
295    /// React hook call sites (`useState` / `useEffect` / `useMemo` /
296    /// `useCallback` / custom `use*`). Drives hook-density complexity context.
297    /// Empty for non-React files.
298    pub hook_uses: Vec<HookUse>,
299    /// React render edges: one component rendering another. Captured with the
300    /// child's written name; child-to-`FileId` resolution is deferred to graph
301    /// build. Empty for non-React files.
302    pub render_edges: Vec<RenderEdge>,
303    /// Svelte custom events dispatched via `dispatch('<name>')` where `dispatch`
304    /// is the binding from `const dispatch = createEventDispatcher()`. Consumed
305    /// by the `unused-svelte-event` detector to flag an event dispatched here but
306    /// listened to nowhere project-wide. Each entry carries the literal event
307    /// name and its span. Empty for every non-Svelte file.
308    pub svelte_dispatched_events: Vec<DispatchedEvent>,
309    /// Svelte custom-event listener names harvested from template `on:<name>`
310    /// bindings on COMPONENT tags (PascalCase tag names). Lowercase DOM-element
311    /// `on:click` is a DOM event, not a custom event, and is excluded. Unioned
312    /// project-wide by the `unused-svelte-event` detector to build the liberal
313    /// "listened" set. Empty for every non-Svelte file.
314    pub svelte_listened_events: Vec<String>,
315    /// `true` when a `dispatch(<nonLiteral>)` call was seen (the dispatched event
316    /// name cannot be known statically), or the `dispatch` binding was used as a
317    /// whole value (passed / returned). The `unused-svelte-event` detector
318    /// abstains on the whole component so an event is never falsely flagged.
319    pub has_dynamic_dispatch: bool,
320}
321
322impl ModuleInfo {
323    /// Release extraction payload that resolution has already copied into the graph.
324    ///
325    /// This keeps fields needed by analysis, health, security, LSP, coverage,
326    /// and hash drift checks, while dropping vectors that otherwise duplicate
327    /// data owned by `ResolvedModule` or already credited into the module graph.
328    pub fn release_resolution_payload(&mut self) {
329        // Derive the referenced-binding set BEFORE releasing `unused_import_bindings`:
330        // the analyze-layer `unrendered-component` detector needs "which imports are
331        // actually used" but runs after this release, so capture the compact
332        // complement here. Skip empty local names (side-effect imports).
333        self.referenced_import_bindings = self
334            .imports
335            .iter()
336            .map(|import| import.local_name.clone())
337            .filter(|name| !name.is_empty() && !self.unused_import_bindings.contains(name))
338            .collect();
339        self.referenced_import_bindings.sort_unstable();
340        self.referenced_import_bindings.dedup();
341
342        // Derive the project-wide page-data-store whole-use signal BEFORE
343        // releasing `whole_object_uses`: the `unused-load-data-key` detector runs
344        // after this release and needs to know whether ANY module reflectively
345        // consumes the whole `page.data` / `$page.data` store.
346        self.has_page_data_store_whole_use = self
347            .whole_object_uses
348            .iter()
349            .any(|name| name == "page.data" || name == "$page.data");
350
351        Self::release_vec(&mut self.dynamic_imports);
352        Self::release_vec(&mut self.require_calls);
353        Self::release_vec(&mut self.package_path_references);
354        Self::release_vec(&mut self.whole_object_uses);
355        Self::release_vec(&mut self.unused_import_bindings);
356        Self::release_vec(&mut self.type_referenced_import_bindings);
357        Self::release_vec(&mut self.value_referenced_import_bindings);
358        Self::release_vec(&mut self.namespace_object_aliases);
359        Self::release_vec(&mut self.auto_import_candidates);
360    }
361
362    fn release_vec<T>(values: &mut Vec<T>) {
363        *values = Vec::new();
364    }
365}
366
367/// Defensive control family detected on a source to sink path.
368#[derive(
369    Debug,
370    Clone,
371    Copy,
372    PartialEq,
373    Eq,
374    PartialOrd,
375    Ord,
376    serde::Serialize,
377    serde::Deserialize,
378    bitcode::Encode,
379    bitcode::Decode,
380)]
381#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
382#[serde(rename_all = "kebab-case")]
383pub enum SecurityControlKind {
384    /// Sanitization or escaping before a sink.
385    Sanitization,
386    /// Input validation or schema parsing.
387    Validation,
388    /// Authentication check or middleware.
389    Authentication,
390    /// Authorization or permission check.
391    Authorization,
392}
393
394/// A known defensive control call site.
395#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
396pub struct SecurityControlSite {
397    /// Control family.
398    pub kind: SecurityControlKind,
399    /// Flattened callee path or a stable synthetic name for guard-derived
400    /// controls.
401    pub callee_path: String,
402    /// Byte offset of the control span start.
403    pub span_start: u32,
404    /// Byte offset of the control span end.
405    pub span_end: u32,
406}
407
408/// Sanitizer output domain. Kept intentionally narrow so a sanitizer for one
409/// domain cannot suppress a different sink family.
410#[derive(
411    Debug,
412    Clone,
413    Copy,
414    PartialEq,
415    Eq,
416    PartialOrd,
417    Ord,
418    serde::Serialize,
419    serde::Deserialize,
420    bitcode::Encode,
421    bitcode::Decode,
422)]
423pub enum SanitizerScope {
424    /// HTML markup sanitized by DOMPurify-compatible APIs.
425    Html,
426    /// URL or redirect target checked against a literal-backed allowlist.
427    Url,
428    /// Path value checked against a high-confidence containment guard.
429    Path,
430    /// SQL identifier quoted with a helper that doubles embedded identifier quotes.
431    SqlIdentifier,
432}
433
434/// A captured sink argument that is itself a recognized sanitizer call.
435#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
436pub struct SanitizedSinkArg {
437    /// Byte offset of the owning sink span start.
438    pub span_start: u32,
439    /// The positional argument index on the owning sink.
440    pub arg_index: u32,
441    /// The sanitizer output domain for this argument.
442    pub scope: SanitizerScope,
443}
444
445/// A local binding tied to the flattened member-access path it was initialized
446/// from. The analyze layer matches `source_path` against the data-driven source
447/// catalogue; when it matches, `local` is treated as carrying untrusted input.
448///
449/// Captured for two shapes: a direct assignment (`const id = req.query.id` ->
450/// `{ local: "id", source_path: "req.query" }`, the literal-key tail dropped so
451/// the path matches a catalogue prefix) and an object destructure
452/// (`const { id } = req.query` -> `{ local: "id", source_path: "req.query" }`).
453#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
454pub struct TaintedBinding {
455    /// The local binding name introduced by the declarator.
456    pub local: String,
457    /// The flattened object member-access path the binding was sourced from.
458    pub source_path: String,
459    /// Byte offset of the source read (the member-access expression the binding
460    /// was sourced from), so the analyze layer can anchor a taint trace's source
461    /// node at the real read line instead of the module import line. Stored as a
462    /// `u32` (not `Span`) to stay bitcode-encodable for the cache. `0` when no
463    /// concrete read expression is available (synthetic framework-param /
464    /// helper-return bindings), in which case the analyze layer falls back to the
465    /// sink site rather than claiming a spurious line.
466    pub source_span_start: u32,
467}
468
469/// Why a sink-shaped callee could not be flattened into a static catalogue
470/// path.
471#[derive(
472    Debug,
473    Clone,
474    Copy,
475    PartialEq,
476    Eq,
477    PartialOrd,
478    Ord,
479    serde::Serialize,
480    serde::Deserialize,
481    bitcode::Encode,
482    bitcode::Decode,
483)]
484#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
485#[serde(rename_all = "kebab-case")]
486pub enum SkippedSecurityCalleeReason {
487    /// A computed member access such as `client[method](input)`.
488    ComputedMember,
489    /// A dynamic non-member callee such as `(factory())(input)`.
490    DynamicDispatch,
491    /// An assignment target whose object could not be flattened.
492    UnsupportedAssignmentObject,
493}
494
495/// Syntactic expression shape for a skipped security callee.
496#[derive(
497    Debug,
498    Clone,
499    Copy,
500    PartialEq,
501    Eq,
502    PartialOrd,
503    Ord,
504    serde::Serialize,
505    serde::Deserialize,
506    bitcode::Encode,
507    bitcode::Decode,
508)]
509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
510#[serde(rename_all = "kebab-case")]
511pub enum SkippedSecurityCalleeExpressionKind {
512    /// `obj.prop(...)`.
513    StaticMemberExpression,
514    /// `obj[prop](...)`.
515    ComputedMemberExpression,
516    /// A bare identifier or private identifier callee.
517    Identifier,
518    /// Any other call-like expression that cannot be represented compactly.
519    Other,
520}
521
522/// Span-only diagnostic for a skipped security callee inside one module.
523#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
524pub struct SkippedSecurityCalleeSite {
525    /// Why the callee was skipped.
526    pub reason: SkippedSecurityCalleeReason,
527    /// Compact expression shape of the skipped callee.
528    pub expression_kind: SkippedSecurityCalleeExpressionKind,
529    /// Start byte offset of the skipped callee expression.
530    pub span_start: u32,
531    /// End byte offset of the skipped callee expression.
532    pub span_end: u32,
533}
534
535/// The syntactic shape of a captured security sink site. Category-blind: the
536/// extractor records the shape and the dotted/bare callee path; the analyze
537/// layer matches it against the data-driven catalogue. See
538/// `crates/core/data/security_matchers.toml`.
539#[derive(
540    Debug,
541    Clone,
542    Copy,
543    PartialEq,
544    Eq,
545    serde::Serialize,
546    serde::Deserialize,
547    bitcode::Encode,
548    bitcode::Decode,
549)]
550pub enum SinkShape {
551    /// A call to a bare identifier (e.g. `eval(x)`).
552    Call,
553    /// A call to a dotted member path (e.g. `child_process.exec(x)`).
554    MemberCall,
555    /// An assignment to a member target (e.g. `el.innerHTML = x`).
556    MemberAssign,
557    /// A tagged template expression (e.g. ``sql`...${x}...` ``).
558    TaggedTemplate,
559    /// A JSX attribute value (e.g. `dangerouslySetInnerHTML={x}`).
560    JsxAttr,
561    /// A constructor call (e.g. `new Function("return x")`).
562    NewExpression,
563    /// A static string literal assigned to a secret-shaped identifier or known
564    /// provider credential prefix.
565    SecretLiteral,
566}
567
568/// The shape of the argument captured at a sink site. Category-blind like
569/// [`SinkShape`], but finer-grained: it lets the catalogue matcher require or
570/// exclude specific argument shapes. The discriminator is what distinguishes an
571/// unsafe SQL string concatenation or template-into-`.execute()` from a
572/// safely-parameterized `` sql`${x}` `` tagged template, an object-literal
573/// `.execute({ sql, args })` argument, or a literal-aware sink argument.
574#[derive(
575    Debug,
576    Clone,
577    Copy,
578    PartialEq,
579    Eq,
580    serde::Serialize,
581    serde::Deserialize,
582    bitcode::Encode,
583    bitcode::Decode,
584)]
585pub enum SinkArgKind {
586    /// A template literal with at least one `${...}` substitution (e.g.
587    /// `` `SELECT ${x}` ``). On a `tagged-template` shape this is the tag's
588    /// quasi; on a `call`/`member-call` shape it is the positional argument.
589    TemplateWithSubst,
590    /// A binary `+` string concatenation (e.g. `"SELECT " + x`).
591    Concat,
592    /// An object literal (e.g. `.execute({ sql, args })`, the parameterized form).
593    Object,
594    /// A call expression argument (e.g. `query(buildSql())`).
595    Call,
596    /// A literal argument admitted by a literal-aware security matcher.
597    Literal,
598    /// A zero-argument sink captured because the callee itself is the signal.
599    NoArg,
600    /// Any other non-literal expression (bare identifier, member access, etc.).
601    Other,
602}
603
604/// Static URL construction shape captured for URL-shaped security sinks.
605#[derive(
606    Debug,
607    Clone,
608    Copy,
609    PartialEq,
610    Eq,
611    serde::Serialize,
612    serde::Deserialize,
613    bitcode::Encode,
614    bitcode::Decode,
615)]
616#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
617#[serde(rename_all = "kebab-case")]
618pub enum SecurityUrlShape {
619    /// The sink target has a fixed origin, scheme, or relative root while only
620    /// path or query components are dynamic.
621    FixedOriginDynamicPath,
622    /// The sink target's scheme or origin is dynamic or opaque.
623    DynamicOrigin,
624}
625
626/// Literal values attached to literal-aware security sink captures.
627#[derive(
628    Debug,
629    Clone,
630    PartialEq,
631    Eq,
632    serde::Serialize,
633    serde::Deserialize,
634    bitcode::Encode,
635    bitcode::Decode,
636)]
637pub enum SinkLiteralValue {
638    /// A string literal value.
639    String(String),
640    /// An integer numeric literal value.
641    Integer(i64),
642    /// A boolean literal value.
643    Boolean(bool),
644    /// A null literal value.
645    Null,
646}
647
648/// Static object-literal property metadata attached to a captured sink
649/// argument. Nested object paths are flattened with dot-separated keys.
650#[derive(
651    Debug,
652    Clone,
653    PartialEq,
654    Eq,
655    serde::Serialize,
656    serde::Deserialize,
657    bitcode::Encode,
658    bitcode::Decode,
659)]
660pub struct SinkObjectProperty {
661    /// Static property name. Nested object properties use dot-separated paths.
662    pub key: String,
663    /// Literal property value when statically knowable.
664    pub value: SinkLiteralValue,
665}
666
667/// A captured sink site. The visitor records every existing non-literal call /
668/// member-assign / member-call / tagged-template / jsx-attr sink site, and a
669/// small allowlist of literal-aware sites where the literal value is the signal.
670/// It knows nothing about CWE categories.
671#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
672pub struct SinkSite {
673    /// The syntactic shape of the sink site.
674    pub sink_shape: SinkShape,
675    /// The flattened dotted/bare callee or member path.
676    pub callee_path: String,
677    /// The positional argument index. For zero-argument captures this is 0.
678    pub arg_index: u32,
679    /// Whether the relevant argument is non-literal. Existing non-literal
680    /// catalogue rows require this to remain true.
681    pub arg_is_non_literal: bool,
682    /// The finer-grained shape of the captured argument. Lets the catalogue
683    /// require unsafe shapes (concat / template-with-substitution / literal /
684    /// no-arg) and exclude safe ones (object literal, the parameterized form).
685    /// See [`SinkArgKind`].
686    pub arg_kind: SinkArgKind,
687    /// Literal argument value for literal-aware rows.
688    pub arg_literal: Option<SinkLiteralValue>,
689    /// Risky regex fragment for structural ReDoS candidates.
690    pub regex_pattern: Option<String>,
691    /// Static object-literal properties for option-object rows.
692    pub object_properties: Vec<SinkObjectProperty>,
693    /// Static top-level object-literal keys, including keys whose values are not
694    /// literal. Used by missing-option rows that only need key presence.
695    pub object_property_keys: Vec<String>,
696    /// Whether [`object_property_keys`](Self::object_property_keys) is complete.
697    /// False for non-object arguments and object literals with spread or
698    /// non-static keys, where a missing-key claim would be speculative.
699    pub object_property_keys_complete: bool,
700    /// Identifier names referenced anywhere inside the captured non-literal sink
701    /// argument, or contextual names for zero-argument captures such as a
702    /// token-like `Math.random()` assignment target. Deduped in source order.
703    /// Used by the analyze layer to back-trace the sink argument to a known
704    /// untrusted source or to apply narrow context gates. Intra-module,
705    /// name-based, conservative; it is never a taint proof.
706    pub arg_idents: Vec<String>,
707    /// Flattened static member paths referenced inside the captured non-literal
708    /// sink argument. Includes both the full path and source-object path for
709    /// leaf reads (`process.env.SECRET` records `process.env.SECRET` and
710    /// `process.env`) so direct source expressions can be matched without an
711    /// intermediate local binding.
712    pub arg_source_paths: Vec<String>,
713    /// Byte offset of the sink span start. Stored as `u32` (not `Span`) so the
714    /// struct is bitcode-encodable and can be persisted directly in the cache.
715    pub span_start: u32,
716    /// Byte offset of the sink span end.
717    pub span_end: u32,
718    /// The arg-0 URL string literal of a network-shaped call (`fetch`, `axios.*`,
719    /// `got`, ...), captured so the `secret-to-network` category (#890) can carry
720    /// a destination-host signal on its candidate: `Some(literal)` when the
721    /// destination is a static string literal (almost always intended auth, e.g.
722    /// the credential's own provider), `None` when it is dynamic (the suspicious
723    /// case). `None` for non-call sinks and calls with no arg 0.
724    pub url_arg_literal: Option<String>,
725    /// URL construction shape for URL-like sink arguments when the extractor can
726    /// classify it syntactically. `None` for non-URL sinks and URL expressions
727    /// whose shape is not visible at the sink.
728    pub url_shape: Option<SecurityUrlShape>,
729}
730
731impl SinkSite {
732    /// Reconstruct the source span from the stored byte offsets.
733    #[must_use]
734    pub fn span(&self) -> Span {
735        Span::new(self.span_start, self.span_end)
736    }
737}
738
739/// Env var-name prefixes that frameworks inline into the client bundle by
740/// convention. A read of one of these is normal and safe, so it does NOT count
741/// as a secret source (issue #890). Shared by the extract layer (so public env
742/// vars never become source signals) and the bespoke `client-server-leak` rule.
743pub const PUBLIC_ENV_PREFIXES: &[&str] = &[
744    "NEXT_PUBLIC_",
745    "VITE_",
746    "NUXT_PUBLIC_",
747    "REACT_APP_",
748    "PUBLIC_",
749    "GATSBY_",
750    "EXPO_PUBLIC_",
751    "STORYBOOK_",
752];
753
754/// Exact env var names that are public by convention (no prefix).
755pub const PUBLIC_ENV_EXACT: &[&str] = &["NODE_ENV"];
756
757/// Env var-name tokens that usually describe public build or deployment
758/// metadata rather than secrets. Secret-shaped names win over these tokens.
759pub const PUBLIC_ENV_METADATA_TOKENS: &[&str] =
760    &["BRANCH", "ENVIRONMENT", "MODE", "REF", "SHA", "TAG"];
761
762/// Env var-name tokens that should keep a variable source-backed even when the
763/// name also contains public metadata tokens such as `REF` or `SHA`.
764pub const SECRET_ENV_TOKENS: &[&str] = &[
765    "AUTH",
766    "CREDENTIAL",
767    "CREDENTIALS",
768    "KEY",
769    "PASS",
770    "PASSWORD",
771    "PRIVATE",
772    "SECRET",
773    "TOKEN",
774];
775
776fn env_name_has_token(name: &str, tokens: &[&str]) -> bool {
777    name.split(|ch: char| !ch.is_ascii_alphanumeric())
778        .filter(|part| !part.is_empty())
779        .any(|part| tokens.contains(&part))
780}
781
782/// Whether an env var name is public-by-convention (build-inlined into the
783/// client bundle), and therefore not a secret.
784#[must_use]
785pub fn is_public_env_var(name: &str) -> bool {
786    if PUBLIC_ENV_EXACT.contains(&name) || PUBLIC_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
787        return true;
788    }
789    env_name_has_token(name, PUBLIC_ENV_METADATA_TOKENS)
790        && !env_name_has_token(name, SECRET_ENV_TOKENS)
791}
792
793/// Whether a flattened member path is a PUBLIC env-secret read
794/// (`process.env.NEXT_PUBLIC_X`, `import.meta.env.VITE_Y`), which must not be
795/// recorded as a secret source. Non-env paths (`req.query.id`) are never public.
796#[must_use]
797pub fn is_public_env_path(path: &str) -> bool {
798    for object in ["process.env.", "import.meta.env."] {
799        if let Some(var) = path.strip_prefix(object) {
800            return is_public_env_var(var);
801        }
802    }
803    false
804}
805
806/// One alias entry tying an exported object's dotted property path to a namespace import.
807#[derive(Debug, Clone)]
808pub struct NamespaceObjectAlias {
809    /// Canonical export name.
810    pub via_export_name: String,
811    /// Dotted suffix of the property path relative to the export.
812    pub suffix: String,
813    /// Local name of the namespace import.
814    pub namespace_local: String,
815}
816
817/// Compute a table of line-start byte offsets from source text.
818#[must_use]
819#[expect(
820    clippy::cast_possible_truncation,
821    reason = "source files are practically < 4GB"
822)]
823pub fn compute_line_offsets(source: &str) -> Vec<u32> {
824    let mut offsets = vec![0u32];
825    for (i, byte) in source.bytes().enumerate() {
826        if byte == b'\n' {
827            debug_assert!(
828                u32::try_from(i + 1).is_ok(),
829                "source file exceeds u32::MAX bytes — line offsets would overflow"
830            );
831            offsets.push((i + 1) as u32);
832        }
833    }
834    offsets
835}
836
837/// Convert a byte offset to a 1-based line number and 0-based byte column.
838#[must_use]
839#[expect(
840    clippy::cast_possible_truncation,
841    reason = "line count is bounded by source size"
842)]
843pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
844    let line_idx = match line_offsets.binary_search(&byte_offset) {
845        Ok(idx) => idx,
846        Err(idx) => idx.saturating_sub(1),
847    };
848    let line = line_idx as u32 + 1;
849    let col = byte_offset - line_offsets[line_idx];
850    (line, col)
851}
852
853/// Complexity metrics for a single function/method/arrow.
854#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
855pub struct FunctionComplexity {
856    /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
857    pub name: String,
858    /// 1-based line number where the function starts.
859    pub line: u32,
860    /// 0-based byte column where the function starts.
861    pub col: u32,
862    /// `McCabe` cyclomatic complexity (1 + decision points).
863    pub cyclomatic: u16,
864    /// `SonarSource` cognitive complexity (structural + nesting penalty).
865    pub cognitive: u16,
866    /// Number of lines in the function body.
867    pub line_count: u32,
868    /// Number of parameters (excluding TypeScript's `this` parameter).
869    pub param_count: u8,
870    /// Number of React hook calls (`useState` / `useEffect` / `useMemo` /
871    /// `useCallback` / custom `use*`) made directly in this function's body.
872    /// Non-zero only for React components/hooks; descriptive context surfaced in
873    /// the hotspot drill-down, never a tunable threshold (anti-numerology).
874    pub react_hook_count: u16,
875    /// Maximum JSX element nesting depth reached in this function's body (the
876    /// deepest chain of element-inside-element). `0` when the function renders
877    /// no JSX. Descriptive context surfaced in the hotspot drill-down, never a
878    /// tunable threshold (anti-numerology).
879    pub react_jsx_max_depth: u16,
880    /// Number of props destructured from this component's first parameter (the
881    /// `{ a, b, c }` props object). `0` for non-component functions and for
882    /// components taking a bare `props` identifier (not statically countable).
883    /// Descriptive context surfaced in the hotspot drill-down, never a tunable
884    /// threshold (anti-numerology).
885    pub react_prop_count: u16,
886    /// Content digest of the function's full-span source slice.
887    pub source_hash: Option<String>,
888    /// Per-decision-point breakdown explaining WHICH constructs drove the
889    /// cyclomatic and cognitive scores. One entry per increment event (an `if`
890    /// emits one cyclomatic and one cognitive entry at the same line, because
891    /// the two metrics accrue at different granularities). Always computed and
892    /// cached; surfaced in JSON only behind `health --complexity-breakdown`.
893    pub contributions: Vec<ComplexityContribution>,
894}
895
896/// Structural CSS metrics for a single style rule, computed from the parsed CSS
897/// syntax tree. A rule is recorded only when it crosses a structural floor (an
898/// id selector, a complex selector, a `!important` declaration, or deep
899/// nesting), so the vector stays bounded on normal stylesheets.
900///
901/// Not persisted in the extraction cache: `fallow health` computes these
902/// on demand from the CSS source, so there is no `bitcode` derive.
903#[derive(Debug, Clone, serde::Serialize)]
904#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
905pub struct CssRuleMetric {
906    /// 1-based line of the rule's first selector.
907    pub line: u32,
908    /// 1-based column of the rule's first selector.
909    pub col: u32,
910    /// Specificity component `a` (id selectors), max across the rule's selectors.
911    pub specificity_a: u16,
912    /// Specificity component `b` (class / attribute / pseudo-class selectors).
913    pub specificity_b: u16,
914    /// Specificity component `c` (type / pseudo-element selectors).
915    pub specificity_c: u16,
916    /// Largest selector component count across the rule's selector list.
917    pub complexity: u16,
918    /// Declaration count in the rule (normal plus `!important`).
919    pub declaration_count: u16,
920    /// `!important` declaration count in the rule.
921    pub important_count: u16,
922    /// Style-rule nesting depth (0 = top level).
923    pub nesting_depth: u8,
924}
925
926/// A style rule's declaration-block fingerprint and location, for cross-file
927/// duplicate-block detection. Only rules with a meaningful number of
928/// declarations are recorded (small blocks repeat legitimately). Internal
929/// staging only: this is consumed in-process by the health layer to build the
930/// grouped `duplicate_declaration_blocks` output and is never serialized.
931#[derive(Debug, Clone)]
932pub struct CssDeclarationBlock {
933    /// xxh3 fingerprint over the rule's normalized (sorted, `!important`-tagged)
934    /// declaration set.
935    pub fingerprint: u64,
936    /// 1-based line of the rule's first selector.
937    pub line: u32,
938    /// Declaration count in the rule (normal plus `!important`).
939    pub declaration_count: u16,
940}
941
942/// Stylesheet-level structural CSS analytics, computed from the parsed CSS
943/// syntax tree. Feeds `fallow health` penalty weights and located findings,
944/// never a standalone CSS score.
945#[derive(Debug, Clone, Default, serde::Serialize)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947pub struct CssAnalytics {
948    /// Total declarations across every style rule (normal plus `!important`).
949    pub total_declarations: u32,
950    /// Total `!important` declarations across every style rule.
951    pub important_declarations: u32,
952    /// Number of style rules.
953    pub rule_count: u32,
954    /// Number of style rules with no declarations.
955    pub empty_rule_count: u32,
956    /// Deepest style-rule nesting depth observed (0 = no nesting).
957    pub max_nesting_depth: u8,
958    /// Rules that crossed the structural floor, in source order. Bounded; see
959    /// [`Self::notable_truncated`]. The scalar aggregates above always reflect
960    /// the full stylesheet regardless of truncation.
961    pub notable_rules: Vec<CssRuleMetric>,
962    /// `true` when more rules crossed the structural floor than `notable_rules`
963    /// retains (compiled utility CSS can emit thousands of `!important` rules),
964    /// so consumers can note that per-rule findings were capped.
965    pub notable_truncated: bool,
966    /// Distinct color VALUES in the stylesheet, sorted (a palette-size /
967    /// design-token-sprawl signal). The parser canonicalizes notation, so the
968    /// authored format is NOT preserved: `red`, `#f00`, `#ff0000`, and
969    /// `rgb(255,0,0)` all collapse to one entry, and every legacy sRGB notation
970    /// renders as hex. Notation-MIXING (hex vs rgb vs hsl) is therefore not
971    /// detectable from this set; it would need a separate raw-token pass.
972    pub colors: Vec<String>,
973    /// Distinct `font-size` declaration values in the stylesheet, sorted.
974    pub font_sizes: Vec<String>,
975    /// Distinct `z-index` declaration values in the stylesheet, sorted.
976    pub z_indexes: Vec<String>,
977    /// Distinct `box-shadow` declaration values in the stylesheet, sorted. A
978    /// high count signals an uncontrolled shadow scale (design-token sprawl).
979    pub box_shadows: Vec<String>,
980    /// Distinct `border-radius` declaration values in the stylesheet, sorted.
981    pub border_radii: Vec<String>,
982    /// Distinct `line-height` declaration values in the stylesheet, sorted.
983    pub line_heights: Vec<String>,
984    /// Distinct custom properties (`--x`) DEFINED in the stylesheet, sorted.
985    pub defined_custom_properties: Vec<String>,
986    /// Distinct custom properties REFERENCED via `var()` in the stylesheet.
987    pub referenced_custom_properties: Vec<String>,
988    /// Distinct `@keyframes` names DEFINED in the stylesheet, sorted.
989    pub defined_keyframes: Vec<String>,
990    /// Distinct `@keyframes` names REFERENCED via `animation` / `animation-name`.
991    pub referenced_keyframes: Vec<String>,
992    /// Distinct custom properties REGISTERED via an `@property` rule, sorted.
993    pub registered_custom_properties: Vec<String>,
994    /// Distinct cascade layers DECLARED (via `@layer a, b;` statements or named
995    /// `@layer a { }` blocks), sorted.
996    pub declared_layers: Vec<String>,
997    /// Distinct cascade layers POPULATED by a named `@layer a { }` block, sorted.
998    /// A layer declared but never populated (and not imported into) is a
999    /// cleanup candidate.
1000    pub populated_layers: Vec<String>,
1001    /// Distinct font families DECLARED by an `@font-face` rule in the stylesheet,
1002    /// sorted. A declared family referenced by no `font-family` anywhere is a
1003    /// dead web-font payload (cleanup candidate).
1004    pub defined_font_faces: Vec<String>,
1005    /// Distinct font families REFERENCED via `font-family` / `font` in the
1006    /// stylesheet, sorted (generic keywords like `serif` excluded).
1007    pub referenced_font_families: Vec<String>,
1008    /// Per-rule declaration-block fingerprints for rules at or above the minimum
1009    /// block size, used to detect duplicate declaration blocks across the
1010    /// project. Internal staging consumed by the health layer; never serialized
1011    /// (the public output is the grouped `duplicate_declaration_blocks`).
1012    #[serde(skip)]
1013    #[cfg_attr(feature = "schema", schemars(skip))]
1014    pub declaration_blocks: Vec<CssDeclarationBlock>,
1015}
1016
1017/// Which complexity metric a [`ComplexityContribution`] adds to.
1018#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1019#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1020#[serde(rename_all = "kebab-case")]
1021pub enum ComplexityMetric {
1022    /// `McCabe` cyclomatic complexity (independent execution paths).
1023    Cyclomatic,
1024    /// `SonarSource` cognitive complexity (structural + nesting penalty).
1025    Cognitive,
1026}
1027
1028/// The syntactic construct that produced a single complexity increment.
1029///
1030/// Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
1031/// `Case` means a `case` label carrying a test; a bare `default` adds nothing
1032/// to cyclomatic complexity and so produces no contribution.
1033#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1034#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1035#[serde(rename_all = "kebab-case")]
1036pub enum ComplexityContributionKind {
1037    /// An `if` condition.
1038    If,
1039    /// A bare `else` branch (cognitive only).
1040    Else,
1041    /// An `else if` continuation (both metrics: cyclomatic +1, cognitive flat
1042    /// +1 with no nesting penalty).
1043    ElseIf,
1044    /// A `?:` conditional (ternary) expression.
1045    Ternary,
1046    /// A logical `&&` operator.
1047    LogicalAnd,
1048    /// A logical `||` operator.
1049    LogicalOr,
1050    /// A `??` nullish-coalescing operator.
1051    NullishCoalescing,
1052    /// A logical assignment operator (`&&=`, `||=`, `??=`); cyclomatic only.
1053    LogicalAssignment,
1054    /// An optional-chaining link (`?.`); cyclomatic only.
1055    OptionalChain,
1056    /// A `for` loop.
1057    For,
1058    /// A `for...in` loop.
1059    ForIn,
1060    /// A `for...of` loop.
1061    ForOf,
1062    /// A `while` loop.
1063    While,
1064    /// A `do...while` loop.
1065    DoWhile,
1066    /// A `switch` statement (cognitive only; each `case` adds cyclomatic).
1067    Switch,
1068    /// A `case` label carrying a test (cyclomatic only).
1069    Case,
1070    /// A `catch` clause.
1071    Catch,
1072    /// A labeled `break` (cognitive only).
1073    LabeledBreak,
1074    /// A labeled `continue` (cognitive only).
1075    LabeledContinue,
1076    /// Legacy JSX-depth contribution kind kept for schema compatibility. Current
1077    /// extraction records JSX nesting as descriptive `react_jsx_max_depth`
1078    /// context and does not emit this kind for layout depth.
1079    JsxDepth,
1080    /// React hook density (cognitive only). One contribution per hook call in a
1081    /// component body (`useState` / `useEffect` / `useMemo` / `useCallback` /
1082    /// custom `use*`); a hook-heavy component accrues cognitive load the same way
1083    /// branching does.
1084    HookDensity,
1085    /// React prop count past the comfortable floor (cognitive only). A component
1086    /// destructuring many props is doing many things; the props beyond the floor
1087    /// fold into cognitive so a wide-interface component surfaces as a hotspot.
1088    PropCount,
1089}
1090
1091/// A single complexity increment, located at its source line/column.
1092///
1093/// `weight` is the amount this construct added to `metric`; for nested
1094/// cognitive increments `weight == 1 + nesting`. Consumers that render inline
1095/// (the VS Code editor breakdown) group contributions by `line` and sum the
1096/// weights, deferring the per-kind list to a hover.
1097#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1098#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1099pub struct ComplexityContribution {
1100    /// 1-based line number where the construct begins.
1101    pub line: u32,
1102    /// 0-based byte column where the construct begins.
1103    pub col: u32,
1104    /// Which metric this increment contributes to.
1105    pub metric: ComplexityMetric,
1106    /// The syntactic construct responsible for the increment.
1107    pub kind: ComplexityContributionKind,
1108    /// The amount added to `metric` at this site (`1 + nesting` for nested
1109    /// cognitive increments, otherwise `1`).
1110    pub weight: u16,
1111    /// The nesting depth at the increment site (`0` when not nested). Lets a
1112    /// consumer explain a cognitive `+3` as "+1 base, +2 nesting".
1113    pub nesting: u16,
1114}
1115
1116/// The kind of feature flag pattern detected.
1117#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1118pub enum FlagUseKind {
1119    /// `process.env.FEATURE_X` pattern.
1120    EnvVar,
1121    /// SDK function call like `useFlag('name')`.
1122    SdkCall,
1123    /// Config object access like `config.features.x`.
1124    ConfigObject,
1125}
1126
1127/// A feature flag use site.
1128#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1129pub struct FlagUse {
1130    /// Flag identifier.
1131    pub flag_name: String,
1132    /// Detection kind.
1133    pub kind: FlagUseKind,
1134    /// 1-based line number.
1135    pub line: u32,
1136    /// 0-based byte column offset.
1137    pub col: u32,
1138    /// Start byte offset of the guarded block.
1139    pub guard_span_start: Option<u32>,
1140    /// End byte offset of the guarded block.
1141    pub guard_span_end: Option<u32>,
1142    /// SDK/provider name.
1143    pub sdk_name: Option<String>,
1144}
1145
1146const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);
1147
1148/// A dynamic import with a partially resolved pattern.
1149#[derive(Debug, Clone)]
1150pub struct DynamicImportPattern {
1151    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
1152    pub prefix: String,
1153    /// Static suffix of the import path (e.g., ".json"), if any.
1154    pub suffix: Option<String>,
1155    /// Source span in the original file.
1156    pub span: Span,
1157}
1158
1159/// Visibility tag from JSDoc/TSDoc comments that suppresses unused-export detection.
1160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
1161#[serde(rename_all = "lowercase")]
1162#[repr(u8)]
1163pub enum VisibilityTag {
1164    /// No visibility tag present.
1165    #[default]
1166    None = 0,
1167    /// `@public` or `@api public` -- part of the public API surface.
1168    Public = 1,
1169    /// `@internal` -- exported for internal use (sister packages, build tools).
1170    Internal = 2,
1171    /// `@beta` -- public but unstable, may change without notice.
1172    Beta = 3,
1173    /// `@alpha` -- early preview, may change drastically without notice.
1174    Alpha = 4,
1175    /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
1176    ExpectedUnused = 5,
1177}
1178
1179impl VisibilityTag {
1180    /// Whether this tag permanently suppresses unused-export detection.
1181    /// `ExpectedUnused` is handled separately (conditionally suppresses,
1182    /// reports stale when the export becomes used).
1183    pub const fn suppresses_unused(self) -> bool {
1184        matches!(
1185            self,
1186            Self::Public | Self::Internal | Self::Beta | Self::Alpha
1187        )
1188    }
1189
1190    /// For serde `skip_serializing_if`.
1191    pub fn is_none(&self) -> bool {
1192        matches!(self, Self::None)
1193    }
1194}
1195
1196/// An export declaration.
1197#[derive(Debug, Clone, serde::Serialize)]
1198pub struct ExportInfo {
1199    /// The exported name (named or default).
1200    pub name: ExportName,
1201    /// The local binding name, if different from the exported name.
1202    pub local_name: Option<String>,
1203    /// Whether this is a type-only export (`export type`).
1204    pub is_type_only: bool,
1205    /// Whether this export is registered through a runtime side effect at module load time.
1206    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1207    pub is_side_effect_used: bool,
1208    /// Visibility tag from JSDoc/TSDoc comment.
1209    #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
1210    pub visibility: VisibilityTag,
1211    /// Human-authored reason on `@expected-unused -- <reason>`, when present.
1212    #[serde(default, skip_serializing_if = "Option::is_none")]
1213    pub expected_unused_reason: Option<String>,
1214    /// Source span of the export declaration.
1215    #[serde(serialize_with = "serialize_span")]
1216    pub span: Span,
1217    /// Members of this export (for enums, classes, and namespaces).
1218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1219    pub members: Vec<MemberInfo>,
1220    /// The local name of the parent class from `extends` clause, if any.
1221    #[serde(default, skip_serializing_if = "Option::is_none")]
1222    pub super_class: Option<String>,
1223}
1224
1225/// Additional heritage metadata for an exported class.
1226#[derive(
1227    Debug,
1228    Clone,
1229    serde::Serialize,
1230    serde::Deserialize,
1231    bitcode::Encode,
1232    bitcode::Decode,
1233    PartialEq,
1234    Eq,
1235)]
1236pub struct ClassHeritageInfo {
1237    /// Export name (`default` for default-exported classes).
1238    pub export_name: String,
1239    /// Parent class name from the `extends` clause, if any.
1240    pub super_class: Option<String>,
1241    /// Interface names from the class `implements` clause.
1242    pub implements: Vec<String>,
1243    /// Typed instance bindings used to resolve member-access chains in external templates.
1244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1245    pub instance_bindings: Vec<(String, String)>,
1246}
1247
1248/// A module-scope declaration that can be used as a TypeScript type.
1249#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1250pub struct LocalTypeDeclaration {
1251    /// Local declaration name.
1252    pub name: String,
1253    /// Declaration identifier span.
1254    #[serde(serialize_with = "serialize_span")]
1255    pub span: Span,
1256}
1257
1258/// A reference from an exported symbol's public signature to a type name.
1259#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1260pub struct PublicSignatureTypeReference {
1261    /// Exported symbol whose signature contains the reference.
1262    pub export_name: String,
1263    /// Referenced type name. Qualified names are reduced to their root identifier.
1264    pub type_name: String,
1265    /// Reference span.
1266    #[serde(serialize_with = "serialize_span")]
1267    pub span: Span,
1268}
1269
1270/// A member of an enum, class, or namespace.
1271#[derive(Debug, Clone, serde::Serialize)]
1272pub struct MemberInfo {
1273    /// Member name.
1274    pub name: String,
1275    /// The kind of member (enum, class method/property, or namespace member).
1276    pub kind: MemberKind,
1277    /// Source span of the member declaration.
1278    #[serde(serialize_with = "serialize_span")]
1279    pub span: Span,
1280    /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
1281    /// Decorated members are used by frameworks at runtime and should not be
1282    /// flagged as unused class members, unless every decorator on the member
1283    /// is opted out via `FallowConfig.ignore_decorators`.
1284    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1285    pub has_decorator: bool,
1286    /// Full dotted path of each decorator on this member, in source order.
1287    /// `@step("x")` stores `"step"`; `@ns.foo` stores `"ns.foo"`. Empty for
1288    /// undecorated members, Angular signal-initializer properties (which set
1289    /// `has_decorator` without a literal decorator AST node), and decorators
1290    /// whose expression is not an identifier ladder (the entry is the empty
1291    /// string in that case, treated as never-matching by the predicate).
1292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1293    pub decorator_names: Vec<String>,
1294    /// True when this is a static class method that returns a fresh instance
1295    /// of the same class: either via `return new this()` / `return new
1296    /// <SameClassName>()` in the body's last statement, or via a declared
1297    /// return type matching the class name. Consumers calling such a static
1298    /// method receive an instance, so the call result's member accesses are
1299    /// credited against the class. See issues #346, #387.
1300    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1301    pub is_instance_returning_static: bool,
1302    /// True when this is an instance class method whose call result is an
1303    /// instance of the same class. Qualifies when the declared return type
1304    /// matches the class name (`setX(): EventBuilder { ... }`) or when the
1305    /// body's last statement is `return this`. The analyze layer walks fluent
1306    /// chains (`Class.factory().setX().setY()`) only through methods carrying
1307    /// this flag, so the chain stops at a non-self-returning method like
1308    /// `.build()`. See issue #387.
1309    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1310    pub is_self_returning: bool,
1311}
1312
1313/// The kind of member.
1314#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1315#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1316#[serde(rename_all = "snake_case")]
1317pub enum MemberKind {
1318    /// A TypeScript enum member.
1319    EnumMember,
1320    /// A class method.
1321    ClassMethod,
1322    /// A class property.
1323    ClassProperty,
1324    /// A member exported from a TypeScript namespace.
1325    NamespaceMember,
1326    /// A member declared by a store object (Pinia `state` / `getters` /
1327    /// `actions` key, or a setup-store returned key). Cross-graph dead-member
1328    /// detection: a store member never accessed by any consumer project-wide.
1329    StoreMember,
1330}
1331
1332/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
1333#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
1334pub struct MemberAccess {
1335    /// The identifier being accessed (the import name).
1336    pub object: String,
1337    /// The member being accessed.
1338    pub member: String,
1339}
1340
1341/// A statically flattenable callee path invoked in a module (e.g. `execSync`,
1342/// `child_process.exec`, `console.log`). One entry per unique `callee_path`
1343/// per module; the span anchors the first occurrence. Consumed by the
1344/// `boundaries.calls.forbidden` detector.
1345#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1346pub struct CalleeUse {
1347    /// The dotted or bare callee path as written at the call site.
1348    pub callee_path: String,
1349    /// Start byte offset of the first call site using this path.
1350    pub span_start: u32,
1351}
1352
1353/// A `"use client"` / `"use server"` directive string written as an expression
1354/// statement in `program.body` (NOT the leading prologue), so the RSC bundler
1355/// silently ignores it. One entry per offending occurrence. Consumed by the
1356/// `misplaced-directive` detector.
1357#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1358pub struct MisplacedDirectiveSite {
1359    /// `true` for `"use server"`, `false` for `"use client"`.
1360    pub is_server: bool,
1361    /// Start byte offset of the misplaced directive statement.
1362    pub span_start: u32,
1363}
1364
1365/// Which side of a dependency-injection link a call site represents.
1366#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1367pub enum DiRole {
1368    /// `provide(KEY, value)` / `app.provide(KEY, value)` / `setContext(KEY, value)`.
1369    Provide,
1370    /// `inject(KEY)` / `getContext(KEY)`.
1371    Inject,
1372}
1373
1374/// Which framework's DI API a call site came from (drives the finding message).
1375#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1376pub enum DiFramework {
1377    /// Vue `provide` / `inject` (from `vue` / `@vue/runtime-core`).
1378    Vue,
1379    /// Svelte `setContext` / `getContext` (from `svelte`).
1380    Svelte,
1381    /// Angular `inject(TOKEN)` / `@Inject(TOKEN)` (from `@angular/core`),
1382    /// matched against `{ provide: TOKEN, ... }` provider objects.
1383    Angular,
1384}
1385
1386/// A Vue `provide`/`inject` or Svelte `setContext`/`getContext` call site keyed
1387/// by an identifier symbol. The `key_local` is resolved at analyze time through
1388/// the consuming module's import/export tables to a canonical defining-site
1389/// export key, so a provide and an inject of the same shared symbol unify even
1390/// across barrel re-exports. Consumed by the `unprovided-inject` detector.
1391#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1392pub struct DiKeySite {
1393    /// The key identifier as written at the call site.
1394    pub key_local: String,
1395    /// Whether this is a provide or an inject.
1396    pub role: DiRole,
1397    /// Which framework's API this came from.
1398    pub framework: DiFramework,
1399    /// Start byte offset of the call expression (anchors the finding).
1400    pub span_start: u32,
1401}
1402
1403/// A component prop declared by Vue `<script setup>` `defineProps` or Svelte 5
1404/// `$props()`. `used_in_script` / `used_in_template` are set during extraction;
1405/// the `unused-component-prop` detector flags a prop where neither is true. See
1406/// `harvest_define_props` and `harvest_svelte_props` in `sfc_props.rs`.
1407#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1408pub struct ComponentProp {
1409    /// The declared prop name.
1410    pub name: String,
1411    /// The template/script-visible local binding name: the destructure alias for
1412    /// `const { name: alias } = defineProps()` or
1413    /// `let { name: alias } = $props()`, otherwise the prop name itself. A
1414    /// renamed prop is read through this local, so usage must be checked against
1415    /// it, not the declared name.
1416    pub local: String,
1417    /// Start byte offset of the prop declaration (anchors the finding).
1418    pub span_start: u32,
1419    /// Whether this prop is referenced in the component's `<script>` (a
1420    /// destructured local binding with a resolved reference, or a `props.<name>`
1421    /// member access). For React, this is set-in-body: a resolved reference to the
1422    /// destructured local anywhere in the component function body.
1423    pub used_in_script: bool,
1424    /// Whether this prop name is referenced in the component's `<template>`.
1425    /// Set by `apply_template_usage` when the template scanner credits the name.
1426    /// Always false for React (no template; React uses `used_in_script`).
1427    pub used_in_template: bool,
1428    /// The enclosing component name. Empty for Vue SFCs (one component per file,
1429    /// the file stem is the component, set by the detector). For React this is the
1430    /// component function/arrow name a prop was declared on, so the detector can
1431    /// emit the right `component_name` and apply the per-component abstain ladder
1432    /// (a file can declare several React components).
1433    pub component: String,
1434    /// React-only: `true` when the destructured prop local is referenced at least
1435    /// once OUTSIDE a child-JSX attribute value expression (a substantive
1436    /// consumption: a hook arg, a host-element child, a non-JSX-attr read). When
1437    /// `used_in_script` is true but this is false, the prop is referenced ONLY as
1438    /// the root of forwarded child attribute values, i.e. a pure pass-through.
1439    /// Always `false` for Vue (no forward-vs-consume distinction is computed).
1440    pub used_outside_forward: bool,
1441}
1442
1443/// A Vue `<script setup>` `defineEmits` declared event, harvested from the type
1444/// tuple-call form (`defineEmits<{ (e: 'foo'): void }>()`), the type object form
1445/// (`defineEmits<{ foo: [x: string] }>()`), or the runtime array form
1446/// (`defineEmits(['foo'])`). `used` is set during extraction when the bound emit
1447/// name is called as `emit('<name>')`. The `unused-component-emit` detector flags
1448/// an event where `used` is false. See `harvest_define_emits` in `sfc_props.rs`.
1449#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1450pub struct ComponentEmit {
1451    /// The declared emit event name.
1452    pub name: String,
1453    /// Start byte offset of the emit declaration (anchors the finding).
1454    pub span_start: u32,
1455    /// Whether this event is emitted via `emit('<name>')` somewhere in the
1456    /// component's `<script>`.
1457    pub used: bool,
1458}
1459
1460/// A Svelte custom event dispatched via `dispatch('<name>')`, where `dispatch`
1461/// is the binding from a `const dispatch = createEventDispatcher()` call. Only
1462/// literal-first-arg dispatches are recorded; a `dispatch(<nonLiteral>)` sets
1463/// `ModuleInfo::has_dynamic_dispatch` instead. Consumed by the
1464/// `unused-svelte-event` detector, which flags an event dispatched here but
1465/// listened to nowhere project-wide (the cross-file dead-output direction). The
1466/// span is a byte offset (not an `oxc_span::Span`) so the type round-trips
1467/// through the bitcode cache directly, mirroring `ComponentEmit::span_start`.
1468#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1469pub struct DispatchedEvent {
1470    /// The dispatched event name (the literal first argument).
1471    pub name: String,
1472    /// Start byte offset of the `dispatch(...)` call (anchors the finding).
1473    pub span_start: u32,
1474}
1475
1476/// A declared Angular component/directive input, harvested from an `@Input()`
1477/// decorator or a signal `input()` / `input.required()` / `model()` initializer
1478/// on an Angular-decorated class. Consumed by the `unused-component-input`
1479/// detector, which flags an input read nowhere in its own component (neither the
1480/// template nor the class body). The span is stored as a byte offset (not an
1481/// `oxc_span::Span`) so the type is cheap to mirror onto the cache, matching
1482/// `ComponentEmit::span_start`. `ModuleInfo` is not serialized, so no serde
1483/// attrs are derived here. `bitcode` derives let the type be mirrored directly
1484/// onto `CachedModule` (the same pattern as `ComponentEmit`).
1485#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1486pub struct AngularInputMember {
1487    /// The declared input name (the property key).
1488    pub name: String,
1489    /// Start byte offset of the property key (anchors the finding).
1490    pub span_start: u32,
1491}
1492
1493/// A declared Angular component/directive output, harvested from an `@Output()`
1494/// decorator or a signal `output()` / `outputFromObservable()` initializer on an
1495/// Angular-decorated class. Consumed by the `unused-component-output` detector,
1496/// which flags an output emitted nowhere in its own component. A `model()` is an
1497/// input and a framework-driven output, so it is recorded ONLY as an input and
1498/// never appears here (the implicit `update:` emit is framework-managed). The
1499/// span is a byte offset for the same reason as `AngularInputMember`.
1500#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1501pub struct AngularOutputMember {
1502    /// The declared output name (the property key).
1503    pub name: String,
1504    /// Start byte offset of the property key (anchors the finding).
1505    pub span_start: u32,
1506}
1507
1508/// A declared Angular `@Component` and its `selector` value(s), harvested from a
1509/// `@Component({ selector: '...' })` decorator. Consumed by the Angular arm of
1510/// the `unrendered-component` detector, which flags a component whose every
1511/// element selector is used in NO template project-wide (and that is not
1512/// referenced by class name anywhere, e.g. routed / bootstrapped / dynamically
1513/// rendered). A multi-selector string (`'app-foo, [appBar]'`) is split into the
1514/// `selectors` list. The span is stored as a byte offset (not an
1515/// `oxc_span::Span`) so the type round-trips through the bitcode cache directly,
1516/// mirroring `AngularInputMember::span_start`. `@Directive` is intentionally NOT
1517/// harvested here (directives have no template render). `ModuleInfo` is not
1518/// serialized, so no serde attrs are derived.
1519#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1520pub struct AngularComponentSelector {
1521    /// The declared selector strings for this component, split on `,`. A purely
1522    /// element-selector component has only `app-foo`-shaped entries; attribute
1523    /// (`[appFoo]`) and class (`.foo`) selectors are retained verbatim so the
1524    /// detector can abstain when ANY non-element selector is present.
1525    pub selectors: Vec<String>,
1526    /// Start byte offset of the component class declaration (anchors the
1527    /// finding).
1528    pub span_start: u32,
1529    /// The component class name (used to credit routed / bootstrapped / dynamic
1530    /// class-name references project-wide).
1531    pub class_name: String,
1532}
1533
1534/// Sentinel pushed into `ModuleInfo.used_custom_element_tags` when an `html`
1535/// template renders a DYNAMIC tag (`` html`<${tag}>` ``, the lit `static-html`
1536/// form). It contains `<`/`>` so it can never collide with a real (hyphenated,
1537/// no-angle-bracket) custom-element tag. When present project-wide, the Lit
1538/// `unrendered-component` arm abstains on EVERY element (a dynamic render could
1539/// instantiate any of them), mirroring `unprovided-inject`'s `has_dynamic_provide`.
1540pub const DYNAMIC_CUSTOM_ELEMENT_TAG: &str = "<dynamic>";
1541
1542/// A Lit / web-component custom element registered in a module via
1543/// `@customElement('x-foo')` or `customElements.define('x-foo', C)`. Consumed by
1544/// the Lit arm of the `unrendered-component` detector. The span is stored as a
1545/// byte offset (not an `oxc_span::Span`) so the type round-trips through the
1546/// bitcode cache directly, mirroring `AngularComponentSelector::span_start`.
1547#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1548pub struct RegisteredCustomElement {
1549    /// The registered custom-element tag name (`x-foo`).
1550    pub tag: String,
1551    /// The registering class's local name, used for the public-API / export
1552    /// abstain (an exported / published element is rendered by a downstream
1553    /// consumer the scan cannot see). Empty for an anonymous
1554    /// `export default @customElement('x-foo') class extends LitElement {}`.
1555    pub class_local_name: String,
1556    /// Start byte offset of the registering class declaration (anchors the
1557    /// finding at the element, NOT line 1, since a `.ts` file can register
1558    /// several custom elements).
1559    pub span_start: u32,
1560}
1561
1562/// A key returned from a SvelteKit route `load()` function's terminal return
1563/// object literal. Harvested from `+page.{ts,server.ts,js,server.js}` files
1564/// exporting a `load` function. Consumed by the `unused-load-data-key` detector,
1565/// which flags a key read by no consumer. The span is stored as byte offsets
1566/// (not an `oxc_span::Span`) so the type round-trips through the bitcode cache
1567/// directly, mirroring `DiKeySite::span_start` / `ComponentEmit::span_start`.
1568#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1569pub struct LoadReturnKey {
1570    /// The returned-object property key name.
1571    pub name: String,
1572    /// Start byte offset of the key (anchors the finding).
1573    pub span_start: u32,
1574    /// End byte offset of the key.
1575    pub span_end: u32,
1576}
1577
1578/// The syntactic shape of an identified React component definition. Drives the
1579/// abstain ladder later phases apply: a `forwardRef` / `memo` wrapper whose
1580/// props come from an imported interface fallow cannot resolve must abstain
1581/// (ADR-001), not guess.
1582#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1583pub enum ComponentFunctionKind {
1584    /// A `function Foo() { return <.../> }` declaration.
1585    FnDecl,
1586    /// A `const Foo = () => <.../>` arrow (or function-expression) binding.
1587    Arrow,
1588    /// A `const Foo = forwardRef((props, ref) => <.../>)` wrapper.
1589    ForwardRefWrapper,
1590    /// A `const Foo = memo((props) => <.../>)` wrapper.
1591    MemoWrapper,
1592}
1593
1594/// An identified React component: a function/arrow whose body returns JSX.
1595/// Captured by `visit_jsx_element`'s enclosing-component tracking. The
1596/// `unused-component-prop` (React arm) and complexity-fold phases consume this;
1597/// the abstain flags keep zero-FP on the cases ADR-001 cannot resolve.
1598#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1599pub struct ComponentFunction {
1600    /// The component name (the binding or declaration identifier).
1601    pub name: String,
1602    /// Start byte offset of the component definition (anchors findings).
1603    pub span_start: u32,
1604    /// The syntactic shape of the definition.
1605    pub kind: ComponentFunctionKind,
1606    /// Whether the component is exported from its module (a named export, a
1607    /// `export default`, or re-exported in the same module). Public-API
1608    /// components abstain in the prop phase.
1609    pub is_exported: bool,
1610    /// `true` when the component's props are not statically harvestable: a
1611    /// rest/spread in the signature (`{ ...rest }`), props passed wholesale to a
1612    /// hook/helper, or a `forwardRef` / `memo` wrapper whose props come from an
1613    /// imported interface generic fallow cannot resolve (ADR-001). The prop
1614    /// phase abstains on the whole component when set.
1615    pub has_unharvestable_props: bool,
1616    /// `true` when the component body calls `cloneElement` / `React.cloneElement`.
1617    /// `cloneElement` injects props by reflection, so the static forward-set is
1618    /// incomplete; the prop-drilling phase abstains on any chain through this
1619    /// component (ADR-001, zero-FP).
1620    pub uses_clone_element: bool,
1621    /// `true` when the component renders a `*.Provider` member-expression tag
1622    /// (`<FooContext.Provider>`). A context provider in the subtree means the
1623    /// drilling may be a deliberate non-context choice (or the prop is about to
1624    /// be provided); the prop-drilling phase downgrades/abstains.
1625    pub renders_provider: bool,
1626    /// `true` when the component passes a function as a child render value
1627    /// (render-props / children-as-function: `<Foo>{() => ...}</Foo>` or
1628    /// `<Foo render={() => ...}/>`). The forwarded shape is dynamic; the
1629    /// prop-drilling phase abstains on chains through this component.
1630    pub has_children_as_function: bool,
1631    /// `true` when the component body is pure structural indirection: a single
1632    /// statement returning exactly one capitalized/member-expression JSX element
1633    /// (no host wrapper, no extra children, optionally a fragment wrapping a
1634    /// single element) that forwards props via a bare spread of the component's
1635    /// own props binding / rest local (`<Child {...props}/>`), with NO named
1636    /// attributes alongside the spread and NO self-render. The cross-component
1637    /// `thin-wrapper` phase joins this with hook-density / cyclomatic checks and
1638    /// the resolved single render edge to flag a component that is a candidate
1639    /// for inlining. Computed from the component's own AST only, so it caches
1640    /// byte-identity-safe (ADR-001).
1641    pub is_pure_passthrough: bool,
1642}
1643
1644/// The kind of a React hook call. `Custom` covers any `use*`-named call that is
1645/// not one of the built-in hooks.
1646#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1647pub enum HookUseKind {
1648    /// `useState(...)`.
1649    UseState,
1650    /// `useEffect(...)`.
1651    UseEffect,
1652    /// `useMemo(...)`.
1653    UseMemo,
1654    /// `useCallback(...)`.
1655    UseCallback,
1656    /// Any other `use*`-named call (a custom hook).
1657    Custom,
1658}
1659
1660/// A React hook call site inside a component. Consumed by the complexity-fold
1661/// phase (hook density) and surfaced as descriptive hotspot context.
1662#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1663pub struct HookUse {
1664    /// The hook kind.
1665    pub kind: HookUseKind,
1666    /// The dependency-array arity, recorded ONLY when a literal array is present
1667    /// at the dependency-array position (`[a, b]` -> `Some(2)`, `[]` ->
1668    /// `Some(0)`). `None` when the call has no dependency array argument or the
1669    /// argument is not a literal array (ADR-001: do not guess).
1670    pub dep_array_arity: Option<u32>,
1671    /// Start byte offset of the hook call (anchors findings).
1672    pub span_start: u32,
1673    /// The enclosing component name (the top of the visitor's component stack
1674    /// when the hook call was recorded). Lets the descriptive per-component hook
1675    /// summary attribute hooks exactly even when a file declares several
1676    /// components. A hook recorded outside any component carries an empty string
1677    /// (the visitor only records hooks inside a component, so this is the
1678    /// rare top-level / unattributed case).
1679    pub component: String,
1680}
1681
1682/// A render edge: one component rendering another (a capitalized or
1683/// member-expression JSX tag). Captured at extraction time with the child's
1684/// written name; resolution of `child_component_name` to a `FileId`/export is
1685/// deferred to graph build via the existing import map.
1686#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1687pub struct RenderEdge {
1688    /// The name of the component that renders the child (the enclosing
1689    /// component). Empty when the JSX is not inside an identified component (a
1690    /// top-level render expression).
1691    pub parent_component: String,
1692    /// The rendered child component name as written (`Foo` or the full
1693    /// member-expression path `Foo.Bar`).
1694    pub child_component_name: String,
1695    /// The attribute (prop) names passed at the render site, in source order.
1696    pub attr_names: Vec<String>,
1697    /// `true` when the render site contains a JSX spread (`{...x}`), so the
1698    /// passed-prop set is not statically complete.
1699    pub has_spread: bool,
1700    /// The forwarded attributes at this render site: each pairs the child
1701    /// attribute NAME with the identifier ROOT of its value expression
1702    /// (`userName={user.name}` -> `{ attr: "userName", root: "user" }`;
1703    /// `value={x}` -> `{ attr: "value", root: "x" }`). ONLY plain identifier or
1704    /// member-root access values are recorded (`{x}`, `{x.y}`, `{x.y.z}`); a value
1705    /// that is a call, an arrow/function, a conditional, a JSX element, or any
1706    /// other complex expression is NOT recorded here (its root would not be a pure
1707    /// forward) and sets `has_complex_forward` instead. The prop-drilling chain
1708    /// walk uses this pairing to map "this component forwards prop P" to "the
1709    /// child receives it as attribute A".
1710    pub forward_attrs: Vec<ForwardAttr>,
1711    /// `true` when at least one attribute value at this render site is a complex
1712    /// expression (a call, an arrow/function render-prop, a conditional, a JSX
1713    /// element-as-prop, a template literal, etc.) whose identifier root was NOT
1714    /// recorded in `forward_attrs`. The prop-drilling phase abstains on a chain
1715    /// whose forwarded prop flows through such a value (ADR-001, zero-FP).
1716    pub has_complex_forward: bool,
1717}
1718
1719/// One forwarded JSX attribute: the child attribute name plus the identifier
1720/// root of its value expression. See [`RenderEdge::forward_attrs`].
1721#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1722pub struct ForwardAttr {
1723    /// The child attribute (prop) name as written (`userName`).
1724    pub attr: String,
1725    /// The identifier root of the attribute value expression (`user` for
1726    /// `userName={user.name}`).
1727    pub root: String,
1728}
1729
1730#[expect(
1731    clippy::trivially_copy_pass_by_ref,
1732    reason = "serde serialize_with requires &T"
1733)]
1734fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
1735    use serde::ser::SerializeMap;
1736    let mut map = serializer.serialize_map(Some(2))?;
1737    map.serialize_entry("start", &span.start)?;
1738    map.serialize_entry("end", &span.end)?;
1739    map.end()
1740}
1741
1742/// Export identifier.
1743#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
1744pub enum ExportName {
1745    /// A named export (e.g., `export const foo`).
1746    Named(String),
1747    /// The default export.
1748    Default,
1749}
1750
1751impl ExportName {
1752    /// Compare against a string without allocating (avoids `to_string()`).
1753    #[must_use]
1754    pub fn matches_str(&self, s: &str) -> bool {
1755        match self {
1756            Self::Named(n) => n == s,
1757            Self::Default => s == "default",
1758        }
1759    }
1760}
1761
1762impl std::fmt::Display for ExportName {
1763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1764        match self {
1765            Self::Named(n) => write!(f, "{n}"),
1766            Self::Default => write!(f, "default"),
1767        }
1768    }
1769}
1770
1771/// An import declaration.
1772#[derive(Debug, Clone)]
1773pub struct ImportInfo {
1774    /// The import specifier (e.g., `./utils` or `react`).
1775    pub source: String,
1776    /// How the symbol is imported (named, default, namespace, or side-effect).
1777    pub imported_name: ImportedName,
1778    /// The local binding name in the importing module.
1779    pub local_name: String,
1780    /// Whether this is a type-only import (`import type`).
1781    pub is_type_only: bool,
1782    /// Whether this import originated from a CSS-context.
1783    pub from_style: bool,
1784    /// Source span of the import declaration.
1785    pub span: Span,
1786    /// Span of the source string literal used by the LSP to highlight the specifier.
1787    pub source_span: Span,
1788}
1789
1790/// How a symbol is imported.
1791#[derive(Debug, Clone, PartialEq, Eq)]
1792pub enum ImportedName {
1793    /// A named import (e.g., `import { foo }`).
1794    Named(String),
1795    /// A default import (e.g., `import React`).
1796    Default,
1797    /// A namespace import (e.g., `import * as utils`).
1798    Namespace,
1799    /// A side-effect import (e.g., `import './styles.css'`).
1800    SideEffect,
1801}
1802
1803#[cfg(target_pointer_width = "64")]
1804const _: () = assert!(std::mem::size_of::<ExportInfo>() == 136);
1805#[cfg(target_pointer_width = "64")]
1806const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
1807#[cfg(target_pointer_width = "64")]
1808const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
1809#[cfg(target_pointer_width = "64")]
1810const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
1811#[cfg(target_pointer_width = "64")]
1812const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
1813#[cfg(target_pointer_width = "64")]
1814const _: () = assert!(std::mem::size_of::<SinkSite>() == 216);
1815#[cfg(target_pointer_width = "64")]
1816const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 1304);
1817
1818/// A re-export declaration.
1819#[derive(Debug, Clone)]
1820pub struct ReExportInfo {
1821    /// The module being re-exported from.
1822    pub source: String,
1823    /// The name imported from the source module (or `*` for star re-exports).
1824    pub imported_name: String,
1825    /// The name exported from this module.
1826    pub exported_name: String,
1827    /// Whether this is a type-only re-export.
1828    pub is_type_only: bool,
1829    /// Source span of the re-export declaration on this module.
1830    pub span: oxc_span::Span,
1831}
1832
1833/// A dynamic `import()` call.
1834#[derive(Debug, Clone)]
1835pub struct DynamicImportInfo {
1836    /// The import specifier.
1837    pub source: String,
1838    /// Source span of the `import()` expression.
1839    pub span: Span,
1840    /// Names destructured from the dynamic import result.
1841    /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
1842    /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
1843    pub destructured_names: Vec<String>,
1844    /// The local variable name for `const x = await import(...)`.
1845    /// Used for namespace import narrowing via member access tracking.
1846    pub local_name: Option<String>,
1847    /// True when this dynamic import was synthesised by fallow rather than appearing in user source.
1848    pub is_speculative: bool,
1849}
1850
1851/// A `require()` call.
1852#[derive(Debug, Clone)]
1853pub struct RequireCallInfo {
1854    /// The require specifier.
1855    pub source: String,
1856    /// Source span of the `require()` call.
1857    pub span: Span,
1858    /// Source span of the specifier string-literal argument (including its
1859    /// quotes), e.g. the `'./x'` in `require('./x')`. Used to anchor an
1860    /// `unresolved-import` diagnostic squiggly under the specifier rather than
1861    /// the `require` keyword. `Span::default()` when the argument is not a
1862    /// plain string literal.
1863    pub source_span: Span,
1864    /// Names destructured from the `require()` result.
1865    pub destructured_names: Vec<String>,
1866    /// The local variable name for `const x = require(...)`.
1867    pub local_name: Option<String>,
1868}
1869
1870/// Result of parsing all files, including incremental cache statistics.
1871pub struct ParseResult {
1872    /// Extracted module information for all successfully parsed files.
1873    pub modules: Vec<ModuleInfo>,
1874    /// Number of files whose parse results were loaded from cache (unchanged).
1875    pub cache_hits: usize,
1876    /// Number of files that required a full parse (new or changed).
1877    pub cache_misses: usize,
1878    /// Summed wall-clock time of the actual AST parses across all rayon workers.
1879    pub parse_cpu_ms: f64,
1880}
1881
1882#[cfg(test)]
1883mod tests {
1884    use super::*;
1885
1886    fn span() -> Span {
1887        Span::new(0, 1)
1888    }
1889
1890    macro_rules! assert_released {
1891        ($values:expr) => {{
1892            assert!($values.is_empty());
1893            assert_eq!($values.capacity(), 0);
1894        }};
1895    }
1896
1897    #[test]
1898    fn public_env_var_includes_public_ci_metadata() {
1899        for name in ["TAG_REF", "GITHUB_SHA", "CI_COMMIT_BRANCH", "APP_MODE"] {
1900            assert!(is_public_env_var(name), "{name} should be public metadata");
1901        }
1902    }
1903
1904    #[test]
1905    fn public_env_var_keeps_secret_shaped_names_source_backed() {
1906        for name in ["GITHUB_TOKEN", "REFRESH_TOKEN", "API_KEY", "SECRET_SHA"] {
1907            assert!(
1908                !is_public_env_var(name),
1909                "{name} should remain secret-shaped"
1910            );
1911        }
1912    }
1913
1914    #[test]
1915    fn line_offsets_empty_string() {
1916        assert_eq!(compute_line_offsets(""), vec![0]);
1917    }
1918
1919    #[test]
1920    #[expect(
1921        clippy::too_many_lines,
1922        reason = "exhaustive field-by-field construction + release assertions for every ModuleInfo field"
1923    )]
1924    fn release_resolution_payload_drops_copied_vectors_only() {
1925        let mut module = ModuleInfo {
1926            file_id: FileId(7),
1927            exports: vec![ExportInfo {
1928                name: ExportName::Named("kept".to_string()),
1929                local_name: None,
1930                is_type_only: false,
1931                is_side_effect_used: false,
1932                visibility: VisibilityTag::None,
1933                expected_unused_reason: None,
1934                span: span(),
1935                members: Vec::new(),
1936                super_class: None,
1937            }],
1938            imports: vec![ImportInfo {
1939                source: "node:child_process".to_string(),
1940                imported_name: ImportedName::Default,
1941                local_name: "childProcess".to_string(),
1942                is_type_only: false,
1943                from_style: false,
1944                span: span(),
1945                source_span: span(),
1946            }],
1947            re_exports: vec![ReExportInfo {
1948                source: "./kept".to_string(),
1949                imported_name: "kept".to_string(),
1950                exported_name: "kept".to_string(),
1951                is_type_only: false,
1952                span: span(),
1953            }],
1954            dynamic_imports: vec![DynamicImportInfo {
1955                source: "./dynamic".to_string(),
1956                span: span(),
1957                destructured_names: vec!["value".to_string()],
1958                local_name: None,
1959                is_speculative: false,
1960            }],
1961            dynamic_import_patterns: vec![DynamicImportPattern {
1962                prefix: "./pages/".to_string(),
1963                suffix: Some(".tsx".to_string()),
1964                span: span(),
1965            }],
1966            require_calls: vec![RequireCallInfo {
1967                source: "./required".to_string(),
1968                span: span(),
1969                source_span: span(),
1970                destructured_names: Vec::new(),
1971                local_name: Some("required".to_string()),
1972            }],
1973            package_path_references: vec!["react".to_string()],
1974            member_accesses: vec![MemberAccess {
1975                object: "Status".to_string(),
1976                member: "Active".to_string(),
1977            }],
1978            whole_object_uses: vec!["Status".to_string()],
1979            has_cjs_exports: true,
1980            has_angular_component_template_url: true,
1981            content_hash: 42,
1982            suppressions: Vec::new(),
1983            unknown_suppression_kinds: Vec::new(),
1984            unused_import_bindings: vec!["unused".to_string()],
1985            type_referenced_import_bindings: vec!["TypeOnly".to_string()],
1986            value_referenced_import_bindings: vec!["Value".to_string()],
1987            line_offsets: vec![0, 8],
1988            complexity: vec![FunctionComplexity {
1989                name: "work".to_string(),
1990                line: 1,
1991                col: 0,
1992                cyclomatic: 2,
1993                cognitive: 3,
1994                line_count: 4,
1995                param_count: 1,
1996                react_hook_count: 0,
1997                react_jsx_max_depth: 0,
1998                react_prop_count: 0,
1999                source_hash: Some("hash".to_string()),
2000                contributions: Vec::new(),
2001            }],
2002            flag_uses: vec![FlagUse {
2003                flag_name: "FEATURE_X".to_string(),
2004                kind: FlagUseKind::EnvVar,
2005                line: 1,
2006                col: 0,
2007                guard_span_start: None,
2008                guard_span_end: None,
2009                sdk_name: None,
2010            }],
2011            class_heritage: vec![ClassHeritageInfo {
2012                export_name: "Child".to_string(),
2013                super_class: Some("Parent".to_string()),
2014                implements: vec!["Contract".to_string()],
2015                instance_bindings: Vec::new(),
2016            }],
2017            injection_tokens: vec![("TOKEN".to_string(), "Contract".to_string())],
2018            local_type_declarations: vec![LocalTypeDeclaration {
2019                name: "Contract".to_string(),
2020                span: span(),
2021            }],
2022            public_signature_type_references: vec![PublicSignatureTypeReference {
2023                export_name: "kept".to_string(),
2024                type_name: "Contract".to_string(),
2025                span: span(),
2026            }],
2027            namespace_object_aliases: vec![NamespaceObjectAlias {
2028                via_export_name: "api".to_string(),
2029                suffix: "read".to_string(),
2030                namespace_local: "ns".to_string(),
2031            }],
2032            iconify_prefixes: vec!["hero".to_string()],
2033            iconify_icon_names: vec!["hero-home".to_string()],
2034            auto_import_candidates: vec!["useState".to_string()],
2035            directives: vec!["use client".to_string()],
2036            client_only_dynamic_import_spans: Vec::new(),
2037            security_sinks: Vec::new(),
2038            security_sinks_skipped: 1,
2039            security_unresolved_callee_sites: Vec::new(),
2040            tainted_bindings: Vec::new(),
2041            sanitized_sink_args: Vec::new(),
2042            security_control_sites: Vec::new(),
2043            callee_uses: Vec::new(),
2044            misplaced_directives: Vec::new(),
2045            inline_server_action_exports: Vec::new(),
2046            di_key_sites: Vec::new(),
2047            has_dynamic_provide: false,
2048            referenced_import_bindings: Vec::new(),
2049            component_props: Vec::new(),
2050            has_props_attrs_fallthrough: false,
2051            has_define_expose: false,
2052            has_define_model: false,
2053            has_unharvestable_props: false,
2054            component_emits: Vec::new(),
2055            angular_inputs: Vec::new(),
2056            angular_outputs: Vec::new(),
2057            angular_component_selectors: Vec::new(),
2058            registered_custom_elements: Vec::new(),
2059            used_custom_element_tags: Vec::new(),
2060            angular_used_selectors: Vec::new(),
2061            angular_entry_component_refs: Vec::new(),
2062            has_dynamic_component_render: false,
2063            has_unharvestable_emits: false,
2064            has_dynamic_emit: false,
2065            has_emit_whole_object_use: false,
2066            load_return_keys: Vec::new(),
2067            has_unharvestable_load: false,
2068            has_load_data_whole_use: false,
2069            has_page_data_store_whole_use: false,
2070            component_functions: Vec::new(),
2071            react_props: Vec::new(),
2072            hook_uses: Vec::new(),
2073            render_edges: Vec::new(),
2074            svelte_dispatched_events: Vec::new(),
2075            svelte_listened_events: Vec::new(),
2076            has_dynamic_dispatch: false,
2077        };
2078
2079        module.release_resolution_payload();
2080
2081        assert_eq!(module.file_id, FileId(7));
2082        assert_eq!(module.content_hash, 42);
2083        assert_eq!(module.line_offsets, vec![0, 8]);
2084        assert_eq!(module.imports.len(), 1);
2085        assert_eq!(module.exports.len(), 1);
2086        assert_eq!(module.re_exports.len(), 1);
2087        assert_eq!(module.dynamic_import_patterns.len(), 1);
2088        assert_eq!(module.member_accesses.len(), 1);
2089        assert_eq!(module.complexity.len(), 1);
2090        assert_eq!(module.flag_uses.len(), 1);
2091        assert_eq!(module.class_heritage.len(), 1);
2092        assert_eq!(module.injection_tokens.len(), 1);
2093        assert_eq!(module.local_type_declarations.len(), 1);
2094        assert_eq!(module.public_signature_type_references.len(), 1);
2095        assert_eq!(module.iconify_prefixes.len(), 1);
2096        assert_eq!(module.iconify_icon_names.len(), 1);
2097        assert_eq!(module.directives.len(), 1);
2098        assert_eq!(module.security_sinks_skipped, 1);
2099        assert_released!(module.dynamic_imports);
2100        assert_released!(module.require_calls);
2101        assert_released!(module.package_path_references);
2102        assert_released!(module.whole_object_uses);
2103        assert_released!(module.unused_import_bindings);
2104        assert_released!(module.type_referenced_import_bindings);
2105        assert_released!(module.value_referenced_import_bindings);
2106        assert_released!(module.namespace_object_aliases);
2107        assert_released!(module.auto_import_candidates);
2108        assert_eq!(
2109            module.referenced_import_bindings,
2110            vec!["childProcess".to_string()]
2111        );
2112    }
2113
2114    #[test]
2115    fn sink_shape_bitcode_roundtrip() {
2116        for shape in [
2117            SinkShape::Call,
2118            SinkShape::MemberCall,
2119            SinkShape::MemberAssign,
2120            SinkShape::TaggedTemplate,
2121            SinkShape::JsxAttr,
2122            SinkShape::NewExpression,
2123            SinkShape::SecretLiteral,
2124        ] {
2125            let encoded = bitcode::encode(&shape);
2126            let decoded: SinkShape = bitcode::decode(&encoded).expect("decode sink shape");
2127            assert_eq!(shape, decoded);
2128        }
2129    }
2130
2131    #[test]
2132    fn sink_arg_kind_bitcode_roundtrip() {
2133        for kind in [
2134            SinkArgKind::TemplateWithSubst,
2135            SinkArgKind::Concat,
2136            SinkArgKind::Object,
2137            SinkArgKind::Call,
2138            SinkArgKind::Literal,
2139            SinkArgKind::NoArg,
2140            SinkArgKind::Other,
2141        ] {
2142            let encoded = bitcode::encode(&kind);
2143            let decoded: SinkArgKind = bitcode::decode(&encoded).expect("decode sink arg kind");
2144            assert_eq!(kind, decoded);
2145        }
2146    }
2147
2148    #[test]
2149    fn security_url_shape_bitcode_roundtrip() {
2150        for shape in [
2151            SecurityUrlShape::FixedOriginDynamicPath,
2152            SecurityUrlShape::DynamicOrigin,
2153        ] {
2154            let encoded = bitcode::encode(&shape);
2155            let decoded: SecurityUrlShape =
2156                bitcode::decode(&encoded).expect("decode security url shape");
2157            assert_eq!(shape, decoded);
2158        }
2159    }
2160
2161    #[test]
2162    fn sink_site_bitcode_roundtrip() {
2163        let site = SinkSite {
2164            sink_shape: SinkShape::MemberAssign,
2165            callee_path: "el.innerHTML".to_string(),
2166            arg_index: 0,
2167            arg_is_non_literal: true,
2168            arg_kind: SinkArgKind::Other,
2169            arg_literal: Some(SinkLiteralValue::Integer(511)),
2170            regex_pattern: None,
2171            object_properties: vec![SinkObjectProperty {
2172                key: "origin".to_string(),
2173                value: SinkLiteralValue::String("*".to_string()),
2174            }],
2175            object_property_keys: vec!["origin".to_string()],
2176            object_property_keys_complete: true,
2177            arg_idents: vec!["userInput".to_string()],
2178            arg_source_paths: vec!["req.body.email".to_string(), "req.body".to_string()],
2179            span_start: 10,
2180            span_end: 20,
2181            url_arg_literal: Some("https://api.example.com".to_string()),
2182            url_shape: Some(SecurityUrlShape::FixedOriginDynamicPath),
2183        };
2184        let encoded = bitcode::encode(&site);
2185        let decoded: SinkSite = bitcode::decode(&encoded).expect("decode sink site");
2186        assert_eq!(decoded.sink_shape, site.sink_shape);
2187        assert_eq!(decoded.callee_path, site.callee_path);
2188        assert_eq!(decoded.arg_index, site.arg_index);
2189        assert_eq!(decoded.arg_is_non_literal, site.arg_is_non_literal);
2190        assert_eq!(decoded.arg_kind, site.arg_kind);
2191        assert_eq!(decoded.arg_literal, site.arg_literal);
2192        assert_eq!(decoded.object_properties, site.object_properties);
2193        assert_eq!(decoded.object_property_keys, site.object_property_keys);
2194        assert_eq!(
2195            decoded.object_property_keys_complete,
2196            site.object_property_keys_complete
2197        );
2198        assert_eq!(decoded.arg_idents, site.arg_idents);
2199        assert_eq!(decoded.arg_source_paths, site.arg_source_paths);
2200        assert_eq!(decoded.url_shape, site.url_shape);
2201        assert_eq!(decoded.span(), site.span());
2202    }
2203
2204    #[test]
2205    fn line_offsets_single_line_no_newline() {
2206        assert_eq!(compute_line_offsets("hello"), vec![0]);
2207    }
2208
2209    #[test]
2210    fn line_offsets_single_line_with_newline() {
2211        assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
2212    }
2213
2214    #[test]
2215    fn line_offsets_multiple_lines() {
2216        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
2217    }
2218
2219    #[test]
2220    fn line_offsets_trailing_newline() {
2221        assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
2222    }
2223
2224    #[test]
2225    fn line_offsets_consecutive_newlines() {
2226        assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
2227    }
2228
2229    #[test]
2230    fn line_offsets_multibyte_utf8() {
2231        assert_eq!(compute_line_offsets("á\n"), vec![0, 3]);
2232    }
2233
2234    #[test]
2235    fn line_col_offset_zero() {
2236        let offsets = compute_line_offsets("abc\ndef\nghi");
2237        let (line, col) = byte_offset_to_line_col(&offsets, 0);
2238        assert_eq!((line, col), (1, 0));
2239    }
2240
2241    #[test]
2242    fn line_col_middle_of_first_line() {
2243        let offsets = compute_line_offsets("abc\ndef\nghi");
2244        let (line, col) = byte_offset_to_line_col(&offsets, 2);
2245        assert_eq!((line, col), (1, 2));
2246    }
2247
2248    #[test]
2249    fn line_col_start_of_second_line() {
2250        let offsets = compute_line_offsets("abc\ndef\nghi");
2251        let (line, col) = byte_offset_to_line_col(&offsets, 4);
2252        assert_eq!((line, col), (2, 0));
2253    }
2254
2255    #[test]
2256    fn line_col_middle_of_second_line() {
2257        let offsets = compute_line_offsets("abc\ndef\nghi");
2258        let (line, col) = byte_offset_to_line_col(&offsets, 5);
2259        assert_eq!((line, col), (2, 1));
2260    }
2261
2262    #[test]
2263    fn line_col_start_of_third_line() {
2264        let offsets = compute_line_offsets("abc\ndef\nghi");
2265        let (line, col) = byte_offset_to_line_col(&offsets, 8);
2266        assert_eq!((line, col), (3, 0));
2267    }
2268
2269    #[test]
2270    fn line_col_end_of_file() {
2271        let offsets = compute_line_offsets("abc\ndef\nghi");
2272        let (line, col) = byte_offset_to_line_col(&offsets, 10);
2273        assert_eq!((line, col), (3, 2));
2274    }
2275
2276    #[test]
2277    fn line_col_single_line() {
2278        let offsets = compute_line_offsets("hello");
2279        let (line, col) = byte_offset_to_line_col(&offsets, 3);
2280        assert_eq!((line, col), (1, 3));
2281    }
2282
2283    #[test]
2284    fn line_col_at_newline_byte() {
2285        let offsets = compute_line_offsets("abc\ndef");
2286        let (line, col) = byte_offset_to_line_col(&offsets, 3);
2287        assert_eq!((line, col), (1, 3));
2288    }
2289
2290    #[test]
2291    fn export_name_matches_str_named() {
2292        let name = ExportName::Named("foo".to_string());
2293        assert!(name.matches_str("foo"));
2294        assert!(!name.matches_str("bar"));
2295        assert!(!name.matches_str("default"));
2296    }
2297
2298    #[test]
2299    fn export_name_matches_str_default() {
2300        let name = ExportName::Default;
2301        assert!(name.matches_str("default"));
2302        assert!(!name.matches_str("foo"));
2303    }
2304
2305    #[test]
2306    fn export_name_display_named() {
2307        let name = ExportName::Named("myExport".to_string());
2308        assert_eq!(name.to_string(), "myExport");
2309    }
2310
2311    #[test]
2312    fn export_name_display_default() {
2313        let name = ExportName::Default;
2314        assert_eq!(name.to_string(), "default");
2315    }
2316
2317    #[test]
2318    fn export_name_equality_named() {
2319        let a = ExportName::Named("foo".to_string());
2320        let b = ExportName::Named("foo".to_string());
2321        let c = ExportName::Named("bar".to_string());
2322        assert_eq!(a, b);
2323        assert_ne!(a, c);
2324    }
2325
2326    #[test]
2327    fn export_name_equality_default() {
2328        let a = ExportName::Default;
2329        let b = ExportName::Default;
2330        assert_eq!(a, b);
2331    }
2332
2333    #[test]
2334    fn export_name_named_not_equal_to_default() {
2335        let named = ExportName::Named("default".to_string());
2336        let default = ExportName::Default;
2337        assert_ne!(named, default);
2338    }
2339
2340    #[test]
2341    fn export_name_hash_consistency() {
2342        use std::collections::hash_map::DefaultHasher;
2343        use std::hash::{Hash, Hasher};
2344
2345        let mut h1 = DefaultHasher::new();
2346        let mut h2 = DefaultHasher::new();
2347        ExportName::Named("foo".to_string()).hash(&mut h1);
2348        ExportName::Named("foo".to_string()).hash(&mut h2);
2349        assert_eq!(h1.finish(), h2.finish());
2350    }
2351
2352    #[test]
2353    fn export_name_matches_str_empty_string() {
2354        let name = ExportName::Named(String::new());
2355        assert!(name.matches_str(""));
2356        assert!(!name.matches_str("foo"));
2357    }
2358
2359    #[test]
2360    fn export_name_default_does_not_match_empty() {
2361        let name = ExportName::Default;
2362        assert!(!name.matches_str(""));
2363    }
2364
2365    #[test]
2366    fn imported_name_equality() {
2367        assert_eq!(
2368            ImportedName::Named("foo".to_string()),
2369            ImportedName::Named("foo".to_string())
2370        );
2371        assert_ne!(
2372            ImportedName::Named("foo".to_string()),
2373            ImportedName::Named("bar".to_string())
2374        );
2375        assert_eq!(ImportedName::Default, ImportedName::Default);
2376        assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
2377        assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
2378        assert_ne!(ImportedName::Default, ImportedName::Namespace);
2379        assert_ne!(
2380            ImportedName::Named("default".to_string()),
2381            ImportedName::Default
2382        );
2383    }
2384
2385    #[test]
2386    fn member_kind_equality() {
2387        assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
2388        assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
2389        assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
2390        assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
2391        assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
2392        assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
2393        assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
2394    }
2395
2396    #[test]
2397    fn member_kind_bitcode_roundtrip() {
2398        let kinds = [
2399            MemberKind::EnumMember,
2400            MemberKind::ClassMethod,
2401            MemberKind::ClassProperty,
2402            MemberKind::NamespaceMember,
2403        ];
2404        for kind in &kinds {
2405            let bytes = bitcode::encode(kind);
2406            let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
2407            assert_eq!(&decoded, kind);
2408        }
2409    }
2410
2411    #[test]
2412    fn member_access_bitcode_roundtrip() {
2413        let access = MemberAccess {
2414            object: "Status".to_string(),
2415            member: "Active".to_string(),
2416        };
2417        let bytes = bitcode::encode(&access);
2418        let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
2419        assert_eq!(decoded.object, "Status");
2420        assert_eq!(decoded.member, "Active");
2421    }
2422
2423    #[test]
2424    fn line_offsets_crlf_only_counts_lf() {
2425        let offsets = compute_line_offsets("ab\r\ncd");
2426        assert_eq!(offsets, vec![0, 4]);
2427    }
2428
2429    #[test]
2430    fn line_col_empty_file_offset_zero() {
2431        let offsets = compute_line_offsets("");
2432        let (line, col) = byte_offset_to_line_col(&offsets, 0);
2433        assert_eq!((line, col), (1, 0));
2434    }
2435
2436    // --- VisibilityTag ---
2437
2438    #[test]
2439    fn visibility_tag_default_is_none_variant() {
2440        assert_eq!(VisibilityTag::default(), VisibilityTag::None);
2441    }
2442
2443    #[test]
2444    fn visibility_tag_is_none_only_for_none_variant() {
2445        assert!(VisibilityTag::None.is_none());
2446        assert!(!VisibilityTag::Public.is_none());
2447        assert!(!VisibilityTag::Internal.is_none());
2448        assert!(!VisibilityTag::Beta.is_none());
2449        assert!(!VisibilityTag::Alpha.is_none());
2450        assert!(!VisibilityTag::ExpectedUnused.is_none());
2451    }
2452
2453    #[test]
2454    fn visibility_tag_suppresses_unused_for_api_tags() {
2455        assert!(VisibilityTag::Public.suppresses_unused());
2456        assert!(VisibilityTag::Internal.suppresses_unused());
2457        assert!(VisibilityTag::Beta.suppresses_unused());
2458        assert!(VisibilityTag::Alpha.suppresses_unused());
2459    }
2460
2461    #[test]
2462    fn visibility_tag_does_not_suppress_none_or_expected_unused() {
2463        assert!(!VisibilityTag::None.suppresses_unused());
2464        assert!(!VisibilityTag::ExpectedUnused.suppresses_unused());
2465    }
2466
2467    // --- is_public_env_path ---
2468
2469    #[test]
2470    fn is_public_env_path_process_env_public_prefix() {
2471        assert!(is_public_env_path("process.env.NEXT_PUBLIC_API_URL"));
2472        assert!(is_public_env_path("process.env.VITE_APP_KEY"));
2473        assert!(is_public_env_path("process.env.REACT_APP_TITLE"));
2474        assert!(is_public_env_path("process.env.NODE_ENV"));
2475    }
2476
2477    #[test]
2478    fn is_public_env_path_import_meta_env_public_prefix() {
2479        assert!(is_public_env_path("import.meta.env.VITE_BASE_URL"));
2480        assert!(is_public_env_path("import.meta.env.PUBLIC_API"));
2481    }
2482
2483    #[test]
2484    fn is_public_env_path_secret_env_vars_are_not_public() {
2485        assert!(!is_public_env_path("process.env.SECRET_KEY"));
2486        assert!(!is_public_env_path("process.env.DATABASE_PASSWORD"));
2487        assert!(!is_public_env_path("import.meta.env.API_TOKEN"));
2488    }
2489
2490    #[test]
2491    fn is_public_env_path_non_env_paths_are_not_public() {
2492        assert!(!is_public_env_path("req.query.id"));
2493        assert!(!is_public_env_path("process.argv"));
2494        assert!(!is_public_env_path("window.location.href"));
2495    }
2496
2497    // --- is_public_env_var edge cases ---
2498
2499    #[test]
2500    fn is_public_env_var_exact_matches() {
2501        assert!(is_public_env_var("NODE_ENV"));
2502    }
2503
2504    #[test]
2505    fn is_public_env_var_all_known_prefixes() {
2506        assert!(is_public_env_var("NUXT_PUBLIC_API_URL"));
2507        assert!(is_public_env_var("PUBLIC_API_KEY"));
2508        assert!(is_public_env_var("GATSBY_APP_ID"));
2509        assert!(is_public_env_var("EXPO_PUBLIC_SENTRY_DSN"));
2510        assert!(is_public_env_var("STORYBOOK_ENV"));
2511    }
2512
2513    #[test]
2514    fn is_public_env_var_secret_token_beats_metadata_token() {
2515        // "SECRET_SHA": has SECRET (wins) and SHA (metadata); should NOT be public
2516        assert!(!is_public_env_var("SECRET_SHA"));
2517        // "REF_TOKEN": has TOKEN (secret) and REF (metadata); should NOT be public
2518        assert!(!is_public_env_var("REF_TOKEN"));
2519    }
2520
2521    #[test]
2522    fn is_public_env_var_plain_unknown_names_are_not_public() {
2523        assert!(!is_public_env_var("MY_SERVICE_URL"));
2524        assert!(!is_public_env_var("FEATURE_FLAG"));
2525        assert!(!is_public_env_var("DATABASE_URL"));
2526    }
2527
2528    // --- SinkSite::span ---
2529
2530    #[test]
2531    fn sink_site_span_reconstructs_from_offsets() {
2532        let site = SinkSite {
2533            sink_shape: SinkShape::Call,
2534            callee_path: "eval".to_string(),
2535            arg_index: 0,
2536            arg_is_non_literal: true,
2537            arg_kind: SinkArgKind::Other,
2538            arg_literal: None,
2539            regex_pattern: None,
2540            object_properties: Vec::new(),
2541            object_property_keys: Vec::new(),
2542            object_property_keys_complete: false,
2543            arg_idents: Vec::new(),
2544            arg_source_paths: Vec::new(),
2545            span_start: 5,
2546            span_end: 15,
2547            url_arg_literal: None,
2548            url_shape: None,
2549        };
2550        let s = site.span();
2551        assert_eq!(s.start, 5);
2552        assert_eq!(s.end, 15);
2553    }
2554
2555    // --- SecurityControlKind ---
2556
2557    #[test]
2558    fn security_control_kind_equality_and_ordering() {
2559        assert_eq!(
2560            SecurityControlKind::Sanitization,
2561            SecurityControlKind::Sanitization
2562        );
2563        assert_eq!(
2564            SecurityControlKind::Validation,
2565            SecurityControlKind::Validation
2566        );
2567        assert_ne!(
2568            SecurityControlKind::Sanitization,
2569            SecurityControlKind::Validation
2570        );
2571        assert!(SecurityControlKind::Sanitization < SecurityControlKind::Validation);
2572        assert!(SecurityControlKind::Authentication < SecurityControlKind::Authorization);
2573    }
2574
2575    // --- SanitizerScope ---
2576
2577    #[test]
2578    fn sanitizer_scope_equality_and_ordering() {
2579        assert_eq!(SanitizerScope::Html, SanitizerScope::Html);
2580        assert_eq!(SanitizerScope::Url, SanitizerScope::Url);
2581        assert_eq!(SanitizerScope::Path, SanitizerScope::Path);
2582        assert_eq!(SanitizerScope::SqlIdentifier, SanitizerScope::SqlIdentifier);
2583        assert_ne!(SanitizerScope::Html, SanitizerScope::Url);
2584        assert!(SanitizerScope::Html < SanitizerScope::Url);
2585    }
2586
2587    // --- SkippedSecurityCalleeReason ---
2588
2589    #[test]
2590    fn skipped_security_callee_reason_equality() {
2591        assert_eq!(
2592            SkippedSecurityCalleeReason::ComputedMember,
2593            SkippedSecurityCalleeReason::ComputedMember
2594        );
2595        assert_ne!(
2596            SkippedSecurityCalleeReason::ComputedMember,
2597            SkippedSecurityCalleeReason::DynamicDispatch
2598        );
2599        assert_ne!(
2600            SkippedSecurityCalleeReason::DynamicDispatch,
2601            SkippedSecurityCalleeReason::UnsupportedAssignmentObject
2602        );
2603    }
2604
2605    // --- SkippedSecurityCalleeExpressionKind ---
2606
2607    #[test]
2608    fn skipped_security_callee_expression_kind_equality() {
2609        use SkippedSecurityCalleeExpressionKind as K;
2610        assert_eq!(K::StaticMemberExpression, K::StaticMemberExpression);
2611        assert_eq!(K::ComputedMemberExpression, K::ComputedMemberExpression);
2612        assert_eq!(K::Identifier, K::Identifier);
2613        assert_eq!(K::Other, K::Other);
2614        assert_ne!(K::StaticMemberExpression, K::ComputedMemberExpression);
2615        assert_ne!(K::Identifier, K::Other);
2616    }
2617
2618    // --- SinkLiteralValue ---
2619
2620    #[test]
2621    fn sink_literal_value_equality() {
2622        assert_eq!(
2623            SinkLiteralValue::String("x".to_string()),
2624            SinkLiteralValue::String("x".to_string())
2625        );
2626        assert_ne!(
2627            SinkLiteralValue::String("x".to_string()),
2628            SinkLiteralValue::String("y".to_string())
2629        );
2630        assert_eq!(SinkLiteralValue::Integer(42), SinkLiteralValue::Integer(42));
2631        assert_ne!(SinkLiteralValue::Integer(1), SinkLiteralValue::Integer(2));
2632        assert_eq!(
2633            SinkLiteralValue::Boolean(true),
2634            SinkLiteralValue::Boolean(true)
2635        );
2636        assert_ne!(
2637            SinkLiteralValue::Boolean(true),
2638            SinkLiteralValue::Boolean(false)
2639        );
2640        assert_eq!(SinkLiteralValue::Null, SinkLiteralValue::Null);
2641        assert_ne!(SinkLiteralValue::Null, SinkLiteralValue::Boolean(false));
2642    }
2643
2644    // --- SecurityUrlShape ---
2645
2646    #[test]
2647    fn security_url_shape_equality() {
2648        assert_eq!(
2649            SecurityUrlShape::FixedOriginDynamicPath,
2650            SecurityUrlShape::FixedOriginDynamicPath
2651        );
2652        assert_eq!(
2653            SecurityUrlShape::DynamicOrigin,
2654            SecurityUrlShape::DynamicOrigin
2655        );
2656        assert_ne!(
2657            SecurityUrlShape::FixedOriginDynamicPath,
2658            SecurityUrlShape::DynamicOrigin
2659        );
2660    }
2661
2662    // --- FlagUseKind ---
2663
2664    #[test]
2665    fn flag_use_kind_equality() {
2666        assert_eq!(FlagUseKind::EnvVar, FlagUseKind::EnvVar);
2667        assert_eq!(FlagUseKind::SdkCall, FlagUseKind::SdkCall);
2668        assert_eq!(FlagUseKind::ConfigObject, FlagUseKind::ConfigObject);
2669        assert_ne!(FlagUseKind::EnvVar, FlagUseKind::SdkCall);
2670        assert_ne!(FlagUseKind::SdkCall, FlagUseKind::ConfigObject);
2671    }
2672
2673    // --- ComplexityMetric ---
2674
2675    #[test]
2676    fn complexity_metric_equality() {
2677        assert_eq!(ComplexityMetric::Cyclomatic, ComplexityMetric::Cyclomatic);
2678        assert_eq!(ComplexityMetric::Cognitive, ComplexityMetric::Cognitive);
2679        assert_ne!(ComplexityMetric::Cyclomatic, ComplexityMetric::Cognitive);
2680    }
2681
2682    // --- ComplexityContributionKind ---
2683
2684    #[test]
2685    fn complexity_contribution_kind_equality_spot_check() {
2686        use ComplexityContributionKind as K;
2687        assert_eq!(K::If, K::If);
2688        assert_eq!(K::Else, K::Else);
2689        assert_eq!(K::ElseIf, K::ElseIf);
2690        assert_eq!(K::Ternary, K::Ternary);
2691        assert_eq!(K::LogicalAnd, K::LogicalAnd);
2692        assert_eq!(K::LogicalOr, K::LogicalOr);
2693        assert_eq!(K::NullishCoalescing, K::NullishCoalescing);
2694        assert_eq!(K::LogicalAssignment, K::LogicalAssignment);
2695        assert_eq!(K::OptionalChain, K::OptionalChain);
2696        assert_eq!(K::For, K::For);
2697        assert_eq!(K::ForIn, K::ForIn);
2698        assert_eq!(K::ForOf, K::ForOf);
2699        assert_eq!(K::While, K::While);
2700        assert_eq!(K::DoWhile, K::DoWhile);
2701        assert_eq!(K::Switch, K::Switch);
2702        assert_eq!(K::Case, K::Case);
2703        assert_eq!(K::Catch, K::Catch);
2704        assert_eq!(K::LabeledBreak, K::LabeledBreak);
2705        assert_eq!(K::LabeledContinue, K::LabeledContinue);
2706        assert_eq!(K::JsxDepth, K::JsxDepth);
2707        assert_eq!(K::HookDensity, K::HookDensity);
2708        assert_eq!(K::PropCount, K::PropCount);
2709        assert_ne!(K::If, K::Else);
2710        assert_ne!(K::For, K::While);
2711        assert_ne!(K::Switch, K::Case);
2712    }
2713
2714    // --- MisplacedDirectiveSite ---
2715
2716    #[test]
2717    fn misplaced_directive_site_equality() {
2718        let client = MisplacedDirectiveSite {
2719            is_server: false,
2720            span_start: 10,
2721        };
2722        let server = MisplacedDirectiveSite {
2723            is_server: true,
2724            span_start: 10,
2725        };
2726        let client2 = MisplacedDirectiveSite {
2727            is_server: false,
2728            span_start: 10,
2729        };
2730        assert_eq!(client, client2);
2731        assert_ne!(client, server);
2732    }
2733
2734    #[test]
2735    fn misplaced_directive_site_is_server_flag() {
2736        let site = MisplacedDirectiveSite {
2737            is_server: true,
2738            span_start: 42,
2739        };
2740        assert!(site.is_server);
2741        assert_eq!(site.span_start, 42);
2742
2743        let client_site = MisplacedDirectiveSite {
2744            is_server: false,
2745            span_start: 0,
2746        };
2747        assert!(!client_site.is_server);
2748    }
2749
2750    // --- DiRole / DiFramework ---
2751
2752    #[test]
2753    fn di_role_equality() {
2754        assert_eq!(DiRole::Provide, DiRole::Provide);
2755        assert_eq!(DiRole::Inject, DiRole::Inject);
2756        assert_ne!(DiRole::Provide, DiRole::Inject);
2757    }
2758
2759    #[test]
2760    fn di_framework_equality() {
2761        assert_eq!(DiFramework::Vue, DiFramework::Vue);
2762        assert_eq!(DiFramework::Svelte, DiFramework::Svelte);
2763        assert_eq!(DiFramework::Angular, DiFramework::Angular);
2764        assert_ne!(DiFramework::Vue, DiFramework::Svelte);
2765        assert_ne!(DiFramework::Svelte, DiFramework::Angular);
2766    }
2767
2768    // --- ComponentEmit ---
2769
2770    #[test]
2771    fn component_emit_equality() {
2772        let a = ComponentEmit {
2773            name: "close".to_string(),
2774            span_start: 10,
2775            used: true,
2776        };
2777        let b = ComponentEmit {
2778            name: "close".to_string(),
2779            span_start: 10,
2780            used: true,
2781        };
2782        let different_used = ComponentEmit {
2783            name: "close".to_string(),
2784            span_start: 10,
2785            used: false,
2786        };
2787        let different_name = ComponentEmit {
2788            name: "open".to_string(),
2789            span_start: 10,
2790            used: true,
2791        };
2792        assert_eq!(a, b);
2793        assert_ne!(a, different_used);
2794        assert_ne!(a, different_name);
2795    }
2796
2797    // --- DispatchedEvent ---
2798
2799    #[test]
2800    fn dispatched_event_equality() {
2801        let a = DispatchedEvent {
2802            name: "myEvent".to_string(),
2803            span_start: 20,
2804        };
2805        let b = DispatchedEvent {
2806            name: "myEvent".to_string(),
2807            span_start: 20,
2808        };
2809        let c = DispatchedEvent {
2810            name: "otherEvent".to_string(),
2811            span_start: 20,
2812        };
2813        let d = DispatchedEvent {
2814            name: "myEvent".to_string(),
2815            span_start: 99,
2816        };
2817        assert_eq!(a, b);
2818        assert_ne!(a, c);
2819        assert_ne!(a, d);
2820    }
2821
2822    // --- AngularInputMember / AngularOutputMember ---
2823
2824    #[test]
2825    fn angular_input_member_equality() {
2826        let a = AngularInputMember {
2827            name: "title".to_string(),
2828            span_start: 5,
2829        };
2830        let b = AngularInputMember {
2831            name: "title".to_string(),
2832            span_start: 5,
2833        };
2834        let c = AngularInputMember {
2835            name: "label".to_string(),
2836            span_start: 5,
2837        };
2838        assert_eq!(a, b);
2839        assert_ne!(a, c);
2840    }
2841
2842    #[test]
2843    fn angular_output_member_equality() {
2844        let a = AngularOutputMember {
2845            name: "clicked".to_string(),
2846            span_start: 8,
2847        };
2848        let b = AngularOutputMember {
2849            name: "clicked".to_string(),
2850            span_start: 8,
2851        };
2852        let c = AngularOutputMember {
2853            name: "hovered".to_string(),
2854            span_start: 8,
2855        };
2856        assert_eq!(a, b);
2857        assert_ne!(a, c);
2858    }
2859
2860    // --- AngularComponentSelector ---
2861
2862    #[test]
2863    fn angular_component_selector_fields() {
2864        let s = AngularComponentSelector {
2865            selectors: vec!["app-foo".to_string(), "[appFoo]".to_string()],
2866            span_start: 100,
2867            class_name: "FooComponent".to_string(),
2868        };
2869        assert_eq!(s.selectors.len(), 2);
2870        assert_eq!(s.selectors[0], "app-foo");
2871        assert_eq!(s.selectors[1], "[appFoo]");
2872        assert_eq!(s.class_name, "FooComponent");
2873    }
2874
2875    #[test]
2876    fn angular_component_selector_equality() {
2877        let a = AngularComponentSelector {
2878            selectors: vec!["app-bar".to_string()],
2879            span_start: 0,
2880            class_name: "BarComponent".to_string(),
2881        };
2882        let b = AngularComponentSelector {
2883            selectors: vec!["app-bar".to_string()],
2884            span_start: 0,
2885            class_name: "BarComponent".to_string(),
2886        };
2887        let c = AngularComponentSelector {
2888            selectors: vec!["app-baz".to_string()],
2889            span_start: 0,
2890            class_name: "BazComponent".to_string(),
2891        };
2892        assert_eq!(a, b);
2893        assert_ne!(a, c);
2894    }
2895
2896    // --- LoadReturnKey ---
2897
2898    #[test]
2899    fn load_return_key_equality() {
2900        let a = LoadReturnKey {
2901            name: "user".to_string(),
2902            span_start: 50,
2903            span_end: 54,
2904        };
2905        let b = LoadReturnKey {
2906            name: "user".to_string(),
2907            span_start: 50,
2908            span_end: 54,
2909        };
2910        let c = LoadReturnKey {
2911            name: "posts".to_string(),
2912            span_start: 50,
2913            span_end: 55,
2914        };
2915        assert_eq!(a, b);
2916        assert_ne!(a, c);
2917    }
2918
2919    #[test]
2920    fn load_return_key_span_fields() {
2921        let key = LoadReturnKey {
2922            name: "data".to_string(),
2923            span_start: 10,
2924            span_end: 14,
2925        };
2926        assert_eq!(key.span_start, 10);
2927        assert_eq!(key.span_end, 14);
2928        assert_eq!(key.name, "data");
2929    }
2930
2931    // --- ComponentFunctionKind ---
2932
2933    #[test]
2934    fn component_function_kind_equality() {
2935        assert_eq!(ComponentFunctionKind::FnDecl, ComponentFunctionKind::FnDecl);
2936        assert_eq!(ComponentFunctionKind::Arrow, ComponentFunctionKind::Arrow);
2937        assert_eq!(
2938            ComponentFunctionKind::ForwardRefWrapper,
2939            ComponentFunctionKind::ForwardRefWrapper
2940        );
2941        assert_eq!(
2942            ComponentFunctionKind::MemoWrapper,
2943            ComponentFunctionKind::MemoWrapper
2944        );
2945        assert_ne!(ComponentFunctionKind::FnDecl, ComponentFunctionKind::Arrow);
2946        assert_ne!(
2947            ComponentFunctionKind::ForwardRefWrapper,
2948            ComponentFunctionKind::MemoWrapper
2949        );
2950    }
2951
2952    // --- HookUseKind ---
2953
2954    #[test]
2955    fn hook_use_kind_equality() {
2956        assert_eq!(HookUseKind::UseState, HookUseKind::UseState);
2957        assert_eq!(HookUseKind::UseEffect, HookUseKind::UseEffect);
2958        assert_eq!(HookUseKind::UseMemo, HookUseKind::UseMemo);
2959        assert_eq!(HookUseKind::UseCallback, HookUseKind::UseCallback);
2960        assert_eq!(HookUseKind::Custom, HookUseKind::Custom);
2961        assert_ne!(HookUseKind::UseState, HookUseKind::UseEffect);
2962        assert_ne!(HookUseKind::UseMemo, HookUseKind::Custom);
2963    }
2964
2965    // --- HookUse ---
2966
2967    #[test]
2968    fn hook_use_fields() {
2969        let h = HookUse {
2970            kind: HookUseKind::UseEffect,
2971            dep_array_arity: Some(2),
2972            span_start: 30,
2973            component: "Widget".to_string(),
2974        };
2975        assert_eq!(h.kind, HookUseKind::UseEffect);
2976        assert_eq!(h.dep_array_arity, Some(2));
2977        assert_eq!(h.span_start, 30);
2978        assert_eq!(h.component, "Widget");
2979    }
2980
2981    #[test]
2982    fn hook_use_no_dep_array() {
2983        let h = HookUse {
2984            kind: HookUseKind::UseCallback,
2985            dep_array_arity: None,
2986            span_start: 0,
2987            component: String::new(),
2988        };
2989        assert!(h.dep_array_arity.is_none());
2990    }
2991
2992    // --- MemberKind::StoreMember (missed in existing bitcode test) ---
2993
2994    #[test]
2995    fn member_kind_store_member_bitcode_roundtrip() {
2996        let kind = MemberKind::StoreMember;
2997        let bytes = bitcode::encode(&kind);
2998        let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
2999        assert_eq!(decoded, kind);
3000    }
3001
3002    // --- RenderEdge / ForwardAttr ---
3003
3004    #[test]
3005    fn render_edge_fields() {
3006        let edge = RenderEdge {
3007            parent_component: "Parent".to_string(),
3008            child_component_name: "Child".to_string(),
3009            attr_names: vec!["title".to_string(), "onClick".to_string()],
3010            has_spread: false,
3011            forward_attrs: vec![ForwardAttr {
3012                attr: "title".to_string(),
3013                root: "props".to_string(),
3014            }],
3015            has_complex_forward: false,
3016        };
3017        assert_eq!(edge.parent_component, "Parent");
3018        assert_eq!(edge.child_component_name, "Child");
3019        assert_eq!(edge.attr_names.len(), 2);
3020        assert!(!edge.has_spread);
3021        assert_eq!(edge.forward_attrs.len(), 1);
3022        assert_eq!(edge.forward_attrs[0].attr, "title");
3023        assert_eq!(edge.forward_attrs[0].root, "props");
3024        assert!(!edge.has_complex_forward);
3025    }
3026
3027    #[test]
3028    fn render_edge_with_spread() {
3029        let edge = RenderEdge {
3030            parent_component: "Wrapper".to_string(),
3031            child_component_name: "Inner".to_string(),
3032            attr_names: Vec::new(),
3033            has_spread: true,
3034            forward_attrs: Vec::new(),
3035            has_complex_forward: true,
3036        };
3037        assert!(edge.has_spread);
3038        assert!(edge.has_complex_forward);
3039    }
3040
3041    // --- ComponentFunction ---
3042
3043    #[test]
3044    fn component_function_fields() {
3045        let cf = ComponentFunction {
3046            name: "MyButton".to_string(),
3047            span_start: 0,
3048            kind: ComponentFunctionKind::Arrow,
3049            is_exported: true,
3050            has_unharvestable_props: false,
3051            uses_clone_element: false,
3052            renders_provider: false,
3053            has_children_as_function: false,
3054            is_pure_passthrough: false,
3055        };
3056        assert_eq!(cf.name, "MyButton");
3057        assert_eq!(cf.kind, ComponentFunctionKind::Arrow);
3058        assert!(cf.is_exported);
3059        assert!(!cf.has_unharvestable_props);
3060        assert!(!cf.is_pure_passthrough);
3061    }
3062
3063    #[test]
3064    fn component_function_passthrough_flag() {
3065        let cf = ComponentFunction {
3066            name: "Passthrough".to_string(),
3067            span_start: 5,
3068            kind: ComponentFunctionKind::FnDecl,
3069            is_exported: false,
3070            has_unharvestable_props: false,
3071            uses_clone_element: false,
3072            renders_provider: false,
3073            has_children_as_function: false,
3074            is_pure_passthrough: true,
3075        };
3076        assert!(cf.is_pure_passthrough);
3077        assert!(!cf.is_exported);
3078    }
3079
3080    // --- DiKeySite ---
3081
3082    #[test]
3083    fn di_key_site_fields() {
3084        let site = DiKeySite {
3085            key_local: "MY_KEY".to_string(),
3086            role: DiRole::Provide,
3087            framework: DiFramework::Vue,
3088            span_start: 77,
3089        };
3090        assert_eq!(site.key_local, "MY_KEY");
3091        assert_eq!(site.role, DiRole::Provide);
3092        assert_eq!(site.framework, DiFramework::Vue);
3093        assert_eq!(site.span_start, 77);
3094    }
3095
3096    #[test]
3097    fn di_key_site_inject_svelte() {
3098        let site = DiKeySite {
3099            key_local: "ctx_key".to_string(),
3100            role: DiRole::Inject,
3101            framework: DiFramework::Svelte,
3102            span_start: 0,
3103        };
3104        assert_eq!(site.role, DiRole::Inject);
3105        assert_eq!(site.framework, DiFramework::Svelte);
3106    }
3107
3108    // --- release_resolution_payload: page data store whole-use derivation ---
3109
3110    #[test]
3111    fn release_payload_derives_page_data_store_whole_use_from_page_data() {
3112        let mut m = minimal_module_info();
3113        m.whole_object_uses = vec!["page.data".to_string()];
3114        m.release_resolution_payload();
3115        assert!(m.has_page_data_store_whole_use);
3116    }
3117
3118    #[test]
3119    fn release_payload_derives_page_data_store_whole_use_from_dollar_page_data() {
3120        let mut m = minimal_module_info();
3121        m.whole_object_uses = vec!["$page.data".to_string()];
3122        m.release_resolution_payload();
3123        assert!(m.has_page_data_store_whole_use);
3124    }
3125
3126    #[test]
3127    fn release_payload_does_not_set_page_data_store_whole_use_for_other_names() {
3128        let mut m = minimal_module_info();
3129        m.whole_object_uses = vec!["data".to_string(), "page".to_string()];
3130        m.release_resolution_payload();
3131        assert!(!m.has_page_data_store_whole_use);
3132    }
3133
3134    // --- release_resolution_payload: referenced_import_bindings derivation ---
3135
3136    #[test]
3137    fn release_payload_referenced_bindings_excludes_empty_local_names() {
3138        let mut m = minimal_module_info();
3139        m.imports = vec![
3140            ImportInfo {
3141                source: "./styles.css".to_string(),
3142                imported_name: ImportedName::SideEffect,
3143                local_name: String::new(), // empty = side-effect import
3144                is_type_only: false,
3145                from_style: true,
3146                span: span(),
3147                source_span: span(),
3148            },
3149            ImportInfo {
3150                source: "react".to_string(),
3151                imported_name: ImportedName::Default,
3152                local_name: "React".to_string(),
3153                is_type_only: false,
3154                from_style: false,
3155                span: span(),
3156                source_span: span(),
3157            },
3158        ];
3159        m.unused_import_bindings = vec!["React".to_string()];
3160        m.release_resolution_payload();
3161        // "React" was unused, empty local is filtered; result should be empty
3162        assert!(m.referenced_import_bindings.is_empty());
3163    }
3164
3165    #[test]
3166    fn release_payload_referenced_bindings_sorted_and_deduped() {
3167        let mut m = minimal_module_info();
3168        // Two imports with the same local name (unusual but possible via re-exports)
3169        m.imports = vec![
3170            ImportInfo {
3171                source: "a".to_string(),
3172                imported_name: ImportedName::Named("foo".to_string()),
3173                local_name: "foo".to_string(),
3174                is_type_only: false,
3175                from_style: false,
3176                span: span(),
3177                source_span: span(),
3178            },
3179            ImportInfo {
3180                source: "b".to_string(),
3181                imported_name: ImportedName::Named("bar".to_string()),
3182                local_name: "bar".to_string(),
3183                is_type_only: false,
3184                from_style: false,
3185                span: span(),
3186                source_span: span(),
3187            },
3188            ImportInfo {
3189                source: "c".to_string(),
3190                imported_name: ImportedName::Named("foo".to_string()),
3191                local_name: "foo".to_string(),
3192                is_type_only: false,
3193                from_style: false,
3194                span: span(),
3195                source_span: span(),
3196            },
3197        ];
3198        m.unused_import_bindings = Vec::new();
3199        m.release_resolution_payload();
3200        // sorted: ["bar", "foo"] with "foo" deduped
3201        assert_eq!(
3202            m.referenced_import_bindings,
3203            vec!["bar".to_string(), "foo".to_string()]
3204        );
3205    }
3206
3207    // --- CalleeUse ---
3208
3209    #[test]
3210    fn callee_use_fields() {
3211        let cu = CalleeUse {
3212            callee_path: "child_process.exec".to_string(),
3213            span_start: 100,
3214        };
3215        assert_eq!(cu.callee_path, "child_process.exec");
3216        assert_eq!(cu.span_start, 100);
3217    }
3218
3219    // --- Helper to build a minimal ModuleInfo for targeted tests ---
3220
3221    fn minimal_module_info() -> ModuleInfo {
3222        ModuleInfo {
3223            file_id: FileId(0),
3224            exports: Vec::new(),
3225            imports: Vec::new(),
3226            re_exports: Vec::new(),
3227            dynamic_imports: Vec::new(),
3228            dynamic_import_patterns: Vec::new(),
3229            require_calls: Vec::new(),
3230            package_path_references: Vec::new(),
3231            member_accesses: Vec::new(),
3232            whole_object_uses: Vec::new(),
3233            has_cjs_exports: false,
3234            has_angular_component_template_url: false,
3235            content_hash: 0,
3236            suppressions: Vec::new(),
3237            unknown_suppression_kinds: Vec::new(),
3238            unused_import_bindings: Vec::new(),
3239            type_referenced_import_bindings: Vec::new(),
3240            value_referenced_import_bindings: Vec::new(),
3241            line_offsets: Vec::new(),
3242            complexity: Vec::new(),
3243            flag_uses: Vec::new(),
3244            class_heritage: Vec::new(),
3245            injection_tokens: Vec::new(),
3246            local_type_declarations: Vec::new(),
3247            public_signature_type_references: Vec::new(),
3248            namespace_object_aliases: Vec::new(),
3249            iconify_prefixes: Vec::new(),
3250            iconify_icon_names: Vec::new(),
3251            auto_import_candidates: Vec::new(),
3252            directives: Vec::new(),
3253            client_only_dynamic_import_spans: Vec::new(),
3254            security_sinks: Vec::new(),
3255            security_sinks_skipped: 0,
3256            security_unresolved_callee_sites: Vec::new(),
3257            tainted_bindings: Vec::new(),
3258            sanitized_sink_args: Vec::new(),
3259            security_control_sites: Vec::new(),
3260            callee_uses: Vec::new(),
3261            misplaced_directives: Vec::new(),
3262            inline_server_action_exports: Vec::new(),
3263            di_key_sites: Vec::new(),
3264            has_dynamic_provide: false,
3265            referenced_import_bindings: Vec::new(),
3266            component_props: Vec::new(),
3267            has_props_attrs_fallthrough: false,
3268            has_define_expose: false,
3269            has_define_model: false,
3270            has_unharvestable_props: false,
3271            component_emits: Vec::new(),
3272            angular_inputs: Vec::new(),
3273            angular_outputs: Vec::new(),
3274            angular_component_selectors: Vec::new(),
3275            registered_custom_elements: Vec::new(),
3276            used_custom_element_tags: Vec::new(),
3277            angular_used_selectors: Vec::new(),
3278            angular_entry_component_refs: Vec::new(),
3279            has_dynamic_component_render: false,
3280            has_unharvestable_emits: false,
3281            has_dynamic_emit: false,
3282            has_emit_whole_object_use: false,
3283            load_return_keys: Vec::new(),
3284            has_unharvestable_load: false,
3285            has_load_data_whole_use: false,
3286            has_page_data_store_whole_use: false,
3287            component_functions: Vec::new(),
3288            react_props: Vec::new(),
3289            hook_uses: Vec::new(),
3290            render_edges: Vec::new(),
3291            svelte_dispatched_events: Vec::new(),
3292            svelte_listened_events: Vec::new(),
3293            has_dynamic_dispatch: false,
3294        }
3295    }
3296
3297    #[test]
3298    fn function_complexity_bitcode_roundtrip() {
3299        let fc = FunctionComplexity {
3300            name: "processData".to_string(),
3301            line: 42,
3302            col: 4,
3303            cyclomatic: 15,
3304            cognitive: 25,
3305            line_count: 80,
3306            param_count: 3,
3307            react_hook_count: 0,
3308            react_jsx_max_depth: 0,
3309            react_prop_count: 0,
3310            source_hash: Some("0123456789abcdef".to_string()),
3311            contributions: vec![
3312                ComplexityContribution {
3313                    line: 43,
3314                    col: 8,
3315                    metric: ComplexityMetric::Cyclomatic,
3316                    kind: ComplexityContributionKind::If,
3317                    weight: 1,
3318                    nesting: 0,
3319                },
3320                ComplexityContribution {
3321                    line: 45,
3322                    col: 12,
3323                    metric: ComplexityMetric::Cognitive,
3324                    kind: ComplexityContributionKind::ElseIf,
3325                    weight: 3,
3326                    nesting: 2,
3327                },
3328            ],
3329        };
3330        let bytes = bitcode::encode(&fc);
3331        let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
3332        assert_eq!(decoded.name, "processData");
3333        assert_eq!(decoded.line, 42);
3334        assert_eq!(decoded.col, 4);
3335        assert_eq!(decoded.cyclomatic, 15);
3336        assert_eq!(decoded.cognitive, 25);
3337        assert_eq!(decoded.line_count, 80);
3338        assert_eq!(decoded.source_hash.as_deref(), Some("0123456789abcdef"));
3339        assert_eq!(decoded.contributions.len(), 2);
3340        assert_eq!(
3341            decoded.contributions[1].kind,
3342            ComplexityContributionKind::ElseIf
3343        );
3344        assert_eq!(decoded.contributions[1].weight, 3);
3345        assert_eq!(decoded.contributions[1].nesting, 2);
3346        assert_eq!(decoded.contributions[1].metric, ComplexityMetric::Cognitive);
3347    }
3348}