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