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