Skip to main content

fallow_types/
extract.rs

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