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