Skip to main content

fallow_types/
extract.rs

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