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