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 /// Custom element selector tag names referenced in this file's Angular
210 /// templates (inline `@Component({ template })` and the linked external
211 /// `templateUrl` `.html` module), e.g. `<app-foo>` -> `app-foo`. Native HTML
212 /// tag names are excluded at harvest. The detector unions these project-wide
213 /// into the used-selector set. Empty for non-Angular files.
214 pub angular_used_selectors: Vec<String>,
215 /// Angular component class names referenced as a route entry or bootstrap
216 /// target: a route `component: Foo` / `loadComponent: () => import().then(m =>
217 /// m.Foo)` value, a `bootstrapApplication(Foo)` argument, or a
218 /// `bootstrap: [Foo]` NgModule entry. These are render-equivalent entry points
219 /// (Angular instantiates them without a template `<tag>`), so the Angular
220 /// `unrendered-component` detector abstains on a component whose class name is
221 /// in the project-wide union. A plain `declarations: [...]` / `imports: [...]`
222 /// registration is intentionally NOT harvested here (that is the dead case the
223 /// rule catches). Empty for non-Angular files.
224 pub angular_entry_component_refs: Vec<String>,
225 /// `true` when this file dynamically renders an Angular component fallow
226 /// cannot attribute to a literal class reference: a
227 /// `ViewContainerRef.createComponent(...)` / `*.createComponent(<ident>)`
228 /// call, or an `*ngComponentOutlet` template binding. The Angular
229 /// `unrendered-component` detector abstains project-wide when ANY reachable
230 /// module sets this (mirroring `unprovided-inject`'s `has_dynamic_provide`),
231 /// since a component could be rendered by a non-literal class reference.
232 pub has_dynamic_component_render: bool,
233 /// `true` when `defineEmits` was called with an unharvestable argument (a
234 /// type-reference type argument such as `defineEmits<MyEmits>()`, a
235 /// non-literal runtime form, or an unbound `defineEmits([...])`). The
236 /// detector abstains on the whole file so an emit is never falsely flagged.
237 pub has_unharvestable_emits: bool,
238 /// `true` when an `emit(<nonLiteral>)` call was seen (the emitted event name
239 /// cannot be known statically). The detector abstains on the whole file.
240 pub has_dynamic_emit: bool,
241 /// `true` when the `defineEmits` return binding was used as a WHOLE value
242 /// (passed to a function, returned, or spread), which can emit any event
243 /// opaquely. The detector abstains on the whole file.
244 pub has_emit_whole_object_use: bool,
245 /// SvelteKit `load()` return-object keys harvested from a
246 /// `+page.{ts,server.ts,js,server.js}` file's terminal return literal.
247 /// Consumed by the `unused-load-data-key` detector. Empty for every file
248 /// that is not a page-load producer (gated by basename at harvest time).
249 pub load_return_keys: Vec<LoadReturnKey>,
250 /// `true` when this file's `load()` body could not be harvested safely (a
251 /// spread return, a non-object/non-literal return, more than one top-level
252 /// `return`, a computed key, or a wrapped/re-exported `load`). The detector
253 /// abstains on the whole file so a key is never falsely flagged.
254 pub has_unharvestable_load: bool,
255 /// `true` when this file passes the whole `data` object opaquely (script
256 /// `const X = data`, `fn(data)` / `fn(...data)`, or template `data={data}` /
257 /// `{...data}` in a route component), so a child can read arbitrary keys the
258 /// detector cannot see. Name-gated on the `data` binding. Read ONLY by the
259 /// `unused-load-data-key` detector, so capturing it for all files is
260 /// byte-identity-safe. See FP-1 in the plan.
261 pub has_load_data_whole_use: bool,
262 /// `true` when this file uses the whole `page.data` / `$page.data` store
263 /// object opaquely (e.g. `Object.values(page.data)`, `{...$page.data}`), so a
264 /// reflective read could consume any route's key. Drives the
265 /// `unused-load-data-key` detector's project-wide abstain. Derived in
266 /// `release_resolution_payload` from `whole_object_uses` BEFORE that vector is
267 /// released (mirroring `referenced_import_bindings`), so it survives the
268 /// release the detector runs after; it is never cached (recomputed each run
269 /// from the cached `whole_object_uses`). Reassignment forms
270 /// (`const all = $page.data`) are not whole-object-tracked and stay out of
271 /// scope, matching the syntactic analyzer's conservative posture.
272 pub has_page_data_store_whole_use: bool,
273 /// React/JSX component definitions: functions/arrows whose body returns JSX.
274 /// Captured only for `.jsx`/`.tsx` files when a React/Preact dependency is
275 /// plausible. Consumed by the React `unused-component-prop` arm and the
276 /// complexity-fold phase. Empty for non-React files.
277 pub component_functions: Vec<ComponentFunction>,
278 /// React component props (reuses the shared `ComponentProp` struct). For
279 /// React, `used_in_template` is always false and `used_in_script` means
280 /// used-in-body. Empty for non-React files.
281 pub react_props: Vec<ComponentProp>,
282 /// React hook call sites (`useState` / `useEffect` / `useMemo` /
283 /// `useCallback` / custom `use*`). Drives hook-density complexity context.
284 /// Empty for non-React files.
285 pub hook_uses: Vec<HookUse>,
286 /// React render edges: one component rendering another. Captured with the
287 /// child's written name; child-to-`FileId` resolution is deferred to graph
288 /// build. Empty for non-React files.
289 pub render_edges: Vec<RenderEdge>,
290 /// Svelte custom events dispatched via `dispatch('<name>')` where `dispatch`
291 /// is the binding from `const dispatch = createEventDispatcher()`. Consumed
292 /// by the `unused-svelte-event` detector to flag an event dispatched here but
293 /// listened to nowhere project-wide. Each entry carries the literal event
294 /// name and its span. Empty for every non-Svelte file.
295 pub svelte_dispatched_events: Vec<DispatchedEvent>,
296 /// Svelte custom-event listener names harvested from template `on:<name>`
297 /// bindings on COMPONENT tags (PascalCase tag names). Lowercase DOM-element
298 /// `on:click` is a DOM event, not a custom event, and is excluded. Unioned
299 /// project-wide by the `unused-svelte-event` detector to build the liberal
300 /// "listened" set. Empty for every non-Svelte file.
301 pub svelte_listened_events: Vec<String>,
302 /// `true` when a `dispatch(<nonLiteral>)` call was seen (the dispatched event
303 /// name cannot be known statically), or the `dispatch` binding was used as a
304 /// whole value (passed / returned). The `unused-svelte-event` detector
305 /// abstains on the whole component so an event is never falsely flagged.
306 pub has_dynamic_dispatch: bool,
307}
308
309impl ModuleInfo {
310 /// Release extraction payload that resolution has already copied into the graph.
311 ///
312 /// This keeps fields needed by analysis, health, security, LSP, coverage,
313 /// and hash drift checks, while dropping vectors that otherwise duplicate
314 /// data owned by `ResolvedModule` or already credited into the module graph.
315 pub fn release_resolution_payload(&mut self) {
316 // Derive the referenced-binding set BEFORE releasing `unused_import_bindings`:
317 // the analyze-layer `unrendered-component` detector needs "which imports are
318 // actually used" but runs after this release, so capture the compact
319 // complement here. Skip empty local names (side-effect imports).
320 self.referenced_import_bindings = self
321 .imports
322 .iter()
323 .map(|import| import.local_name.clone())
324 .filter(|name| !name.is_empty() && !self.unused_import_bindings.contains(name))
325 .collect();
326 self.referenced_import_bindings.sort_unstable();
327 self.referenced_import_bindings.dedup();
328
329 // Derive the project-wide page-data-store whole-use signal BEFORE
330 // releasing `whole_object_uses`: the `unused-load-data-key` detector runs
331 // after this release and needs to know whether ANY module reflectively
332 // consumes the whole `page.data` / `$page.data` store.
333 self.has_page_data_store_whole_use = self
334 .whole_object_uses
335 .iter()
336 .any(|name| name == "page.data" || name == "$page.data");
337
338 Self::release_vec(&mut self.dynamic_imports);
339 Self::release_vec(&mut self.require_calls);
340 Self::release_vec(&mut self.package_path_references);
341 Self::release_vec(&mut self.whole_object_uses);
342 Self::release_vec(&mut self.unused_import_bindings);
343 Self::release_vec(&mut self.type_referenced_import_bindings);
344 Self::release_vec(&mut self.value_referenced_import_bindings);
345 Self::release_vec(&mut self.namespace_object_aliases);
346 Self::release_vec(&mut self.auto_import_candidates);
347 }
348
349 fn release_vec<T>(values: &mut Vec<T>) {
350 *values = Vec::new();
351 }
352}
353
354/// Defensive control family detected on a source to sink path.
355#[derive(
356 Debug,
357 Clone,
358 Copy,
359 PartialEq,
360 Eq,
361 PartialOrd,
362 Ord,
363 serde::Serialize,
364 serde::Deserialize,
365 bitcode::Encode,
366 bitcode::Decode,
367)]
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[serde(rename_all = "kebab-case")]
370pub enum SecurityControlKind {
371 /// Sanitization or escaping before a sink.
372 Sanitization,
373 /// Input validation or schema parsing.
374 Validation,
375 /// Authentication check or middleware.
376 Authentication,
377 /// Authorization or permission check.
378 Authorization,
379}
380
381/// A known defensive control call site.
382#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
383pub struct SecurityControlSite {
384 /// Control family.
385 pub kind: SecurityControlKind,
386 /// Flattened callee path or a stable synthetic name for guard-derived
387 /// controls.
388 pub callee_path: String,
389 /// Byte offset of the control span start.
390 pub span_start: u32,
391 /// Byte offset of the control span end.
392 pub span_end: u32,
393}
394
395/// Sanitizer output domain. Kept intentionally narrow so a sanitizer for one
396/// domain cannot suppress a different sink family.
397#[derive(
398 Debug,
399 Clone,
400 Copy,
401 PartialEq,
402 Eq,
403 PartialOrd,
404 Ord,
405 serde::Serialize,
406 serde::Deserialize,
407 bitcode::Encode,
408 bitcode::Decode,
409)]
410pub enum SanitizerScope {
411 /// HTML markup sanitized by DOMPurify-compatible APIs.
412 Html,
413 /// URL or redirect target checked against a literal-backed allowlist.
414 Url,
415 /// Path value checked against a high-confidence containment guard.
416 Path,
417 /// SQL identifier quoted with a helper that doubles embedded identifier quotes.
418 SqlIdentifier,
419}
420
421/// A captured sink argument that is itself a recognized sanitizer call.
422#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
423pub struct SanitizedSinkArg {
424 /// Byte offset of the owning sink span start.
425 pub span_start: u32,
426 /// The positional argument index on the owning sink.
427 pub arg_index: u32,
428 /// The sanitizer output domain for this argument.
429 pub scope: SanitizerScope,
430}
431
432/// A local binding tied to the flattened member-access path it was initialized
433/// from. The analyze layer matches `source_path` against the data-driven source
434/// catalogue; when it matches, `local` is treated as carrying untrusted input.
435///
436/// Captured for two shapes: a direct assignment (`const id = req.query.id` ->
437/// `{ local: "id", source_path: "req.query" }`, the literal-key tail dropped so
438/// the path matches a catalogue prefix) and an object destructure
439/// (`const { id } = req.query` -> `{ local: "id", source_path: "req.query" }`).
440#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
441pub struct TaintedBinding {
442 /// The local binding name introduced by the declarator.
443 pub local: String,
444 /// The flattened object member-access path the binding was sourced from.
445 pub source_path: String,
446 /// Byte offset of the source read (the member-access expression the binding
447 /// was sourced from), so the analyze layer can anchor a taint trace's source
448 /// node at the real read line instead of the module import line. Stored as a
449 /// `u32` (not `Span`) to stay bitcode-encodable for the cache. `0` when no
450 /// concrete read expression is available (synthetic framework-param /
451 /// helper-return bindings), in which case the analyze layer falls back to the
452 /// sink site rather than claiming a spurious line.
453 pub source_span_start: u32,
454}
455
456/// Why a sink-shaped callee could not be flattened into a static catalogue
457/// path.
458#[derive(
459 Debug,
460 Clone,
461 Copy,
462 PartialEq,
463 Eq,
464 PartialOrd,
465 Ord,
466 serde::Serialize,
467 serde::Deserialize,
468 bitcode::Encode,
469 bitcode::Decode,
470)]
471#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
472#[serde(rename_all = "kebab-case")]
473pub enum SkippedSecurityCalleeReason {
474 /// A computed member access such as `client[method](input)`.
475 ComputedMember,
476 /// A dynamic non-member callee such as `(factory())(input)`.
477 DynamicDispatch,
478 /// An assignment target whose object could not be flattened.
479 UnsupportedAssignmentObject,
480}
481
482/// Syntactic expression shape for a skipped security callee.
483#[derive(
484 Debug,
485 Clone,
486 Copy,
487 PartialEq,
488 Eq,
489 PartialOrd,
490 Ord,
491 serde::Serialize,
492 serde::Deserialize,
493 bitcode::Encode,
494 bitcode::Decode,
495)]
496#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
497#[serde(rename_all = "kebab-case")]
498pub enum SkippedSecurityCalleeExpressionKind {
499 /// `obj.prop(...)`.
500 StaticMemberExpression,
501 /// `obj[prop](...)`.
502 ComputedMemberExpression,
503 /// A bare identifier or private identifier callee.
504 Identifier,
505 /// Any other call-like expression that cannot be represented compactly.
506 Other,
507}
508
509/// Span-only diagnostic for a skipped security callee inside one module.
510#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
511pub struct SkippedSecurityCalleeSite {
512 /// Why the callee was skipped.
513 pub reason: SkippedSecurityCalleeReason,
514 /// Compact expression shape of the skipped callee.
515 pub expression_kind: SkippedSecurityCalleeExpressionKind,
516 /// Start byte offset of the skipped callee expression.
517 pub span_start: u32,
518 /// End byte offset of the skipped callee expression.
519 pub span_end: u32,
520}
521
522/// The syntactic shape of a captured security sink site. Category-blind: the
523/// extractor records the shape and the dotted/bare callee path; the analyze
524/// layer matches it against the data-driven catalogue. See
525/// `crates/core/data/security_matchers.toml`.
526#[derive(
527 Debug,
528 Clone,
529 Copy,
530 PartialEq,
531 Eq,
532 serde::Serialize,
533 serde::Deserialize,
534 bitcode::Encode,
535 bitcode::Decode,
536)]
537pub enum SinkShape {
538 /// A call to a bare identifier (e.g. `eval(x)`).
539 Call,
540 /// A call to a dotted member path (e.g. `child_process.exec(x)`).
541 MemberCall,
542 /// An assignment to a member target (e.g. `el.innerHTML = x`).
543 MemberAssign,
544 /// A tagged template expression (e.g. ``sql`...${x}...` ``).
545 TaggedTemplate,
546 /// A JSX attribute value (e.g. `dangerouslySetInnerHTML={x}`).
547 JsxAttr,
548 /// A constructor call (e.g. `new Function("return x")`).
549 NewExpression,
550 /// A static string literal assigned to a secret-shaped identifier or known
551 /// provider credential prefix.
552 SecretLiteral,
553}
554
555/// The shape of the argument captured at a sink site. Category-blind like
556/// [`SinkShape`], but finer-grained: it lets the catalogue matcher require or
557/// exclude specific argument shapes. The discriminator is what distinguishes an
558/// unsafe SQL string concatenation or template-into-`.execute()` from a
559/// safely-parameterized `` sql`${x}` `` tagged template, an object-literal
560/// `.execute({ sql, args })` argument, or a literal-aware sink argument.
561#[derive(
562 Debug,
563 Clone,
564 Copy,
565 PartialEq,
566 Eq,
567 serde::Serialize,
568 serde::Deserialize,
569 bitcode::Encode,
570 bitcode::Decode,
571)]
572pub enum SinkArgKind {
573 /// A template literal with at least one `${...}` substitution (e.g.
574 /// `` `SELECT ${x}` ``). On a `tagged-template` shape this is the tag's
575 /// quasi; on a `call`/`member-call` shape it is the positional argument.
576 TemplateWithSubst,
577 /// A binary `+` string concatenation (e.g. `"SELECT " + x`).
578 Concat,
579 /// An object literal (e.g. `.execute({ sql, args })`, the parameterized form).
580 Object,
581 /// A call expression argument (e.g. `query(buildSql())`).
582 Call,
583 /// A literal argument admitted by a literal-aware security matcher.
584 Literal,
585 /// A zero-argument sink captured because the callee itself is the signal.
586 NoArg,
587 /// Any other non-literal expression (bare identifier, member access, etc.).
588 Other,
589}
590
591/// Static URL construction shape captured for URL-shaped security sinks.
592#[derive(
593 Debug,
594 Clone,
595 Copy,
596 PartialEq,
597 Eq,
598 serde::Serialize,
599 serde::Deserialize,
600 bitcode::Encode,
601 bitcode::Decode,
602)]
603#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
604#[serde(rename_all = "kebab-case")]
605pub enum SecurityUrlShape {
606 /// The sink target has a fixed origin, scheme, or relative root while only
607 /// path or query components are dynamic.
608 FixedOriginDynamicPath,
609 /// The sink target's scheme or origin is dynamic or opaque.
610 DynamicOrigin,
611}
612
613/// Literal values attached to literal-aware security sink captures.
614#[derive(
615 Debug,
616 Clone,
617 PartialEq,
618 Eq,
619 serde::Serialize,
620 serde::Deserialize,
621 bitcode::Encode,
622 bitcode::Decode,
623)]
624pub enum SinkLiteralValue {
625 /// A string literal value.
626 String(String),
627 /// An integer numeric literal value.
628 Integer(i64),
629 /// A boolean literal value.
630 Boolean(bool),
631 /// A null literal value.
632 Null,
633}
634
635/// Static object-literal property metadata attached to a captured sink
636/// argument. Nested object paths are flattened with dot-separated keys.
637#[derive(
638 Debug,
639 Clone,
640 PartialEq,
641 Eq,
642 serde::Serialize,
643 serde::Deserialize,
644 bitcode::Encode,
645 bitcode::Decode,
646)]
647pub struct SinkObjectProperty {
648 /// Static property name. Nested object properties use dot-separated paths.
649 pub key: String,
650 /// Literal property value when statically knowable.
651 pub value: SinkLiteralValue,
652}
653
654/// A captured sink site. The visitor records every existing non-literal call /
655/// member-assign / member-call / tagged-template / jsx-attr sink site, and a
656/// small allowlist of literal-aware sites where the literal value is the signal.
657/// It knows nothing about CWE categories.
658#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
659pub struct SinkSite {
660 /// The syntactic shape of the sink site.
661 pub sink_shape: SinkShape,
662 /// The flattened dotted/bare callee or member path.
663 pub callee_path: String,
664 /// The positional argument index. For zero-argument captures this is 0.
665 pub arg_index: u32,
666 /// Whether the relevant argument is non-literal. Existing non-literal
667 /// catalogue rows require this to remain true.
668 pub arg_is_non_literal: bool,
669 /// The finer-grained shape of the captured argument. Lets the catalogue
670 /// require unsafe shapes (concat / template-with-substitution / literal /
671 /// no-arg) and exclude safe ones (object literal, the parameterized form).
672 /// See [`SinkArgKind`].
673 pub arg_kind: SinkArgKind,
674 /// Literal argument value for literal-aware rows.
675 pub arg_literal: Option<SinkLiteralValue>,
676 /// Risky regex fragment for structural ReDoS candidates.
677 pub regex_pattern: Option<String>,
678 /// Static object-literal properties for option-object rows.
679 pub object_properties: Vec<SinkObjectProperty>,
680 /// Static top-level object-literal keys, including keys whose values are not
681 /// literal. Used by missing-option rows that only need key presence.
682 pub object_property_keys: Vec<String>,
683 /// Whether [`object_property_keys`](Self::object_property_keys) is complete.
684 /// False for non-object arguments and object literals with spread or
685 /// non-static keys, where a missing-key claim would be speculative.
686 pub object_property_keys_complete: bool,
687 /// Identifier names referenced anywhere inside the captured non-literal sink
688 /// argument, or contextual names for zero-argument captures such as a
689 /// token-like `Math.random()` assignment target. Deduped in source order.
690 /// Used by the analyze layer to back-trace the sink argument to a known
691 /// untrusted source or to apply narrow context gates. Intra-module,
692 /// name-based, conservative; it is never a taint proof.
693 pub arg_idents: Vec<String>,
694 /// Flattened static member paths referenced inside the captured non-literal
695 /// sink argument. Includes both the full path and source-object path for
696 /// leaf reads (`process.env.SECRET` records `process.env.SECRET` and
697 /// `process.env`) so direct source expressions can be matched without an
698 /// intermediate local binding.
699 pub arg_source_paths: Vec<String>,
700 /// Byte offset of the sink span start. Stored as `u32` (not `Span`) so the
701 /// struct is bitcode-encodable and can be persisted directly in the cache.
702 pub span_start: u32,
703 /// Byte offset of the sink span end.
704 pub span_end: u32,
705 /// The arg-0 URL string literal of a network-shaped call (`fetch`, `axios.*`,
706 /// `got`, ...), captured so the `secret-to-network` category (#890) can carry
707 /// a destination-host signal on its candidate: `Some(literal)` when the
708 /// destination is a static string literal (almost always intended auth, e.g.
709 /// the credential's own provider), `None` when it is dynamic (the suspicious
710 /// case). `None` for non-call sinks and calls with no arg 0.
711 pub url_arg_literal: Option<String>,
712 /// URL construction shape for URL-like sink arguments when the extractor can
713 /// classify it syntactically. `None` for non-URL sinks and URL expressions
714 /// whose shape is not visible at the sink.
715 pub url_shape: Option<SecurityUrlShape>,
716}
717
718impl SinkSite {
719 /// Reconstruct the source span from the stored byte offsets.
720 #[must_use]
721 pub fn span(&self) -> Span {
722 Span::new(self.span_start, self.span_end)
723 }
724}
725
726/// Env var-name prefixes that frameworks inline into the client bundle by
727/// convention. A read of one of these is normal and safe, so it does NOT count
728/// as a secret source (issue #890). Shared by the extract layer (so public env
729/// vars never become source signals) and the bespoke `client-server-leak` rule.
730pub const PUBLIC_ENV_PREFIXES: &[&str] = &[
731 "NEXT_PUBLIC_",
732 "VITE_",
733 "NUXT_PUBLIC_",
734 "REACT_APP_",
735 "PUBLIC_",
736 "GATSBY_",
737 "EXPO_PUBLIC_",
738 "STORYBOOK_",
739];
740
741/// Exact env var names that are public by convention (no prefix).
742pub const PUBLIC_ENV_EXACT: &[&str] = &["NODE_ENV"];
743
744/// Env var-name tokens that usually describe public build or deployment
745/// metadata rather than secrets. Secret-shaped names win over these tokens.
746pub const PUBLIC_ENV_METADATA_TOKENS: &[&str] =
747 &["BRANCH", "ENVIRONMENT", "MODE", "REF", "SHA", "TAG"];
748
749/// Env var-name tokens that should keep a variable source-backed even when the
750/// name also contains public metadata tokens such as `REF` or `SHA`.
751pub const SECRET_ENV_TOKENS: &[&str] = &[
752 "AUTH",
753 "CREDENTIAL",
754 "CREDENTIALS",
755 "KEY",
756 "PASS",
757 "PASSWORD",
758 "PRIVATE",
759 "SECRET",
760 "TOKEN",
761];
762
763fn env_name_has_token(name: &str, tokens: &[&str]) -> bool {
764 name.split(|ch: char| !ch.is_ascii_alphanumeric())
765 .filter(|part| !part.is_empty())
766 .any(|part| tokens.contains(&part))
767}
768
769/// Whether an env var name is public-by-convention (build-inlined into the
770/// client bundle), and therefore not a secret.
771#[must_use]
772pub fn is_public_env_var(name: &str) -> bool {
773 if PUBLIC_ENV_EXACT.contains(&name) || PUBLIC_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
774 return true;
775 }
776 env_name_has_token(name, PUBLIC_ENV_METADATA_TOKENS)
777 && !env_name_has_token(name, SECRET_ENV_TOKENS)
778}
779
780/// Whether a flattened member path is a PUBLIC env-secret read
781/// (`process.env.NEXT_PUBLIC_X`, `import.meta.env.VITE_Y`), which must not be
782/// recorded as a secret source. Non-env paths (`req.query.id`) are never public.
783#[must_use]
784pub fn is_public_env_path(path: &str) -> bool {
785 for object in ["process.env.", "import.meta.env."] {
786 if let Some(var) = path.strip_prefix(object) {
787 return is_public_env_var(var);
788 }
789 }
790 false
791}
792
793/// One alias entry tying an exported object's dotted property path to a namespace import.
794#[derive(Debug, Clone)]
795pub struct NamespaceObjectAlias {
796 /// Canonical export name.
797 pub via_export_name: String,
798 /// Dotted suffix of the property path relative to the export.
799 pub suffix: String,
800 /// Local name of the namespace import.
801 pub namespace_local: String,
802}
803
804/// Compute a table of line-start byte offsets from source text.
805#[must_use]
806#[expect(
807 clippy::cast_possible_truncation,
808 reason = "source files are practically < 4GB"
809)]
810pub fn compute_line_offsets(source: &str) -> Vec<u32> {
811 let mut offsets = vec![0u32];
812 for (i, byte) in source.bytes().enumerate() {
813 if byte == b'\n' {
814 debug_assert!(
815 u32::try_from(i + 1).is_ok(),
816 "source file exceeds u32::MAX bytes โ line offsets would overflow"
817 );
818 offsets.push((i + 1) as u32);
819 }
820 }
821 offsets
822}
823
824/// Convert a byte offset to a 1-based line number and 0-based byte column.
825#[must_use]
826#[expect(
827 clippy::cast_possible_truncation,
828 reason = "line count is bounded by source size"
829)]
830pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
831 let line_idx = match line_offsets.binary_search(&byte_offset) {
832 Ok(idx) => idx,
833 Err(idx) => idx.saturating_sub(1),
834 };
835 let line = line_idx as u32 + 1;
836 let col = byte_offset - line_offsets[line_idx];
837 (line, col)
838}
839
840/// Complexity metrics for a single function/method/arrow.
841#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
842pub struct FunctionComplexity {
843 /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
844 pub name: String,
845 /// 1-based line number where the function starts.
846 pub line: u32,
847 /// 0-based byte column where the function starts.
848 pub col: u32,
849 /// `McCabe` cyclomatic complexity (1 + decision points).
850 pub cyclomatic: u16,
851 /// `SonarSource` cognitive complexity (structural + nesting penalty).
852 pub cognitive: u16,
853 /// Number of lines in the function body.
854 pub line_count: u32,
855 /// Number of parameters (excluding TypeScript's `this` parameter).
856 pub param_count: u8,
857 /// Number of React hook calls (`useState` / `useEffect` / `useMemo` /
858 /// `useCallback` / custom `use*`) made directly in this function's body.
859 /// Non-zero only for React components/hooks; descriptive context surfaced in
860 /// the hotspot drill-down, never a tunable threshold (anti-numerology).
861 pub react_hook_count: u16,
862 /// Maximum JSX element nesting depth reached in this function's body (the
863 /// deepest chain of element-inside-element). `0` when the function renders
864 /// no JSX. Descriptive context surfaced in the hotspot drill-down, never a
865 /// tunable threshold (anti-numerology).
866 pub react_jsx_max_depth: u16,
867 /// Number of props destructured from this component's first parameter (the
868 /// `{ a, b, c }` props object). `0` for non-component functions and for
869 /// components taking a bare `props` identifier (not statically countable).
870 /// Descriptive context surfaced in the hotspot drill-down, never a tunable
871 /// threshold (anti-numerology).
872 pub react_prop_count: u16,
873 /// Content digest of the function's full-span source slice.
874 pub source_hash: Option<String>,
875 /// Per-decision-point breakdown explaining WHICH constructs drove the
876 /// cyclomatic and cognitive scores. One entry per increment event (an `if`
877 /// emits one cyclomatic and one cognitive entry at the same line, because
878 /// the two metrics accrue at different granularities). Always computed and
879 /// cached; surfaced in JSON only behind `health --complexity-breakdown`.
880 pub contributions: Vec<ComplexityContribution>,
881}
882
883/// Structural CSS metrics for a single style rule, computed from the parsed CSS
884/// syntax tree. A rule is recorded only when it crosses a structural floor (an
885/// id selector, a complex selector, a `!important` declaration, or deep
886/// nesting), so the vector stays bounded on normal stylesheets.
887///
888/// Not persisted in the extraction cache: `fallow health` computes these
889/// on demand from the CSS source, so there is no `bitcode` derive.
890#[derive(Debug, Clone, serde::Serialize)]
891#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
892pub struct CssRuleMetric {
893 /// 1-based line of the rule's first selector.
894 pub line: u32,
895 /// 1-based column of the rule's first selector.
896 pub col: u32,
897 /// Specificity component `a` (id selectors), max across the rule's selectors.
898 pub specificity_a: u16,
899 /// Specificity component `b` (class / attribute / pseudo-class selectors).
900 pub specificity_b: u16,
901 /// Specificity component `c` (type / pseudo-element selectors).
902 pub specificity_c: u16,
903 /// Largest selector component count across the rule's selector list.
904 pub complexity: u16,
905 /// Declaration count in the rule (normal plus `!important`).
906 pub declaration_count: u16,
907 /// `!important` declaration count in the rule.
908 pub important_count: u16,
909 /// Style-rule nesting depth (0 = top level).
910 pub nesting_depth: u8,
911}
912
913/// A style rule's declaration-block fingerprint and location, for cross-file
914/// duplicate-block detection. Only rules with a meaningful number of
915/// declarations are recorded (small blocks repeat legitimately). Internal
916/// staging only: this is consumed in-process by the health layer to build the
917/// grouped `duplicate_declaration_blocks` output and is never serialized.
918#[derive(Debug, Clone)]
919pub struct CssDeclarationBlock {
920 /// xxh3 fingerprint over the rule's normalized (sorted, `!important`-tagged)
921 /// declaration set.
922 pub fingerprint: u64,
923 /// 1-based line of the rule's first selector.
924 pub line: u32,
925 /// Declaration count in the rule (normal plus `!important`).
926 pub declaration_count: u16,
927}
928
929/// Stylesheet-level structural CSS analytics, computed from the parsed CSS
930/// syntax tree. Feeds `fallow health` penalty weights and located findings,
931/// never a standalone CSS score.
932#[derive(Debug, Clone, Default, serde::Serialize)]
933#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
934pub struct CssAnalytics {
935 /// Total declarations across every style rule (normal plus `!important`).
936 pub total_declarations: u32,
937 /// Total `!important` declarations across every style rule.
938 pub important_declarations: u32,
939 /// Number of style rules.
940 pub rule_count: u32,
941 /// Number of style rules with no declarations.
942 pub empty_rule_count: u32,
943 /// Deepest style-rule nesting depth observed (0 = no nesting).
944 pub max_nesting_depth: u8,
945 /// Rules that crossed the structural floor, in source order. Bounded; see
946 /// [`Self::notable_truncated`]. The scalar aggregates above always reflect
947 /// the full stylesheet regardless of truncation.
948 pub notable_rules: Vec<CssRuleMetric>,
949 /// `true` when more rules crossed the structural floor than `notable_rules`
950 /// retains (compiled utility CSS can emit thousands of `!important` rules),
951 /// so consumers can note that per-rule findings were capped.
952 pub notable_truncated: bool,
953 /// Distinct color VALUES in the stylesheet, sorted (a palette-size /
954 /// design-token-sprawl signal). The parser canonicalizes notation, so the
955 /// authored format is NOT preserved: `red`, `#f00`, `#ff0000`, and
956 /// `rgb(255,0,0)` all collapse to one entry, and every legacy sRGB notation
957 /// renders as hex. Notation-MIXING (hex vs rgb vs hsl) is therefore not
958 /// detectable from this set; it would need a separate raw-token pass.
959 pub colors: Vec<String>,
960 /// Distinct `font-size` declaration values in the stylesheet, sorted.
961 pub font_sizes: Vec<String>,
962 /// Distinct `z-index` declaration values in the stylesheet, sorted.
963 pub z_indexes: Vec<String>,
964 /// Distinct `box-shadow` declaration values in the stylesheet, sorted. A
965 /// high count signals an uncontrolled shadow scale (design-token sprawl).
966 pub box_shadows: Vec<String>,
967 /// Distinct `border-radius` declaration values in the stylesheet, sorted.
968 pub border_radii: Vec<String>,
969 /// Distinct `line-height` declaration values in the stylesheet, sorted.
970 pub line_heights: Vec<String>,
971 /// Distinct custom properties (`--x`) DEFINED in the stylesheet, sorted.
972 pub defined_custom_properties: Vec<String>,
973 /// Distinct custom properties REFERENCED via `var()` in the stylesheet.
974 pub referenced_custom_properties: Vec<String>,
975 /// Distinct `@keyframes` names DEFINED in the stylesheet, sorted.
976 pub defined_keyframes: Vec<String>,
977 /// Distinct `@keyframes` names REFERENCED via `animation` / `animation-name`.
978 pub referenced_keyframes: Vec<String>,
979 /// Distinct custom properties REGISTERED via an `@property` rule, sorted.
980 pub registered_custom_properties: Vec<String>,
981 /// Distinct cascade layers DECLARED (via `@layer a, b;` statements or named
982 /// `@layer a { }` blocks), sorted.
983 pub declared_layers: Vec<String>,
984 /// Distinct cascade layers POPULATED by a named `@layer a { }` block, sorted.
985 /// A layer declared but never populated (and not imported into) is a
986 /// cleanup candidate.
987 pub populated_layers: Vec<String>,
988 /// Distinct font families DECLARED by an `@font-face` rule in the stylesheet,
989 /// sorted. A declared family referenced by no `font-family` anywhere is a
990 /// dead web-font payload (cleanup candidate).
991 pub defined_font_faces: Vec<String>,
992 /// Distinct font families REFERENCED via `font-family` / `font` in the
993 /// stylesheet, sorted (generic keywords like `serif` excluded).
994 pub referenced_font_families: Vec<String>,
995 /// Per-rule declaration-block fingerprints for rules at or above the minimum
996 /// block size, used to detect duplicate declaration blocks across the
997 /// project. Internal staging consumed by the health layer; never serialized
998 /// (the public output is the grouped `duplicate_declaration_blocks`).
999 #[serde(skip)]
1000 #[cfg_attr(feature = "schema", schemars(skip))]
1001 pub declaration_blocks: Vec<CssDeclarationBlock>,
1002}
1003
1004/// Which complexity metric a [`ComplexityContribution`] adds to.
1005#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1006#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1007#[serde(rename_all = "kebab-case")]
1008pub enum ComplexityMetric {
1009 /// `McCabe` cyclomatic complexity (independent execution paths).
1010 Cyclomatic,
1011 /// `SonarSource` cognitive complexity (structural + nesting penalty).
1012 Cognitive,
1013}
1014
1015/// The syntactic construct that produced a single complexity increment.
1016///
1017/// Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
1018/// `Case` means a `case` label carrying a test; a bare `default` adds nothing
1019/// to cyclomatic complexity and so produces no contribution.
1020#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1021#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1022#[serde(rename_all = "kebab-case")]
1023pub enum ComplexityContributionKind {
1024 /// An `if` condition.
1025 If,
1026 /// A bare `else` branch (cognitive only).
1027 Else,
1028 /// An `else if` continuation (both metrics: cyclomatic +1, cognitive flat
1029 /// +1 with no nesting penalty).
1030 ElseIf,
1031 /// A `?:` conditional (ternary) expression.
1032 Ternary,
1033 /// A logical `&&` operator.
1034 LogicalAnd,
1035 /// A logical `||` operator.
1036 LogicalOr,
1037 /// A `??` nullish-coalescing operator.
1038 NullishCoalescing,
1039 /// A logical assignment operator (`&&=`, `||=`, `??=`); cyclomatic only.
1040 LogicalAssignment,
1041 /// An optional-chaining link (`?.`); cyclomatic only.
1042 OptionalChain,
1043 /// A `for` loop.
1044 For,
1045 /// A `for...in` loop.
1046 ForIn,
1047 /// A `for...of` loop.
1048 ForOf,
1049 /// A `while` loop.
1050 While,
1051 /// A `do...while` loop.
1052 DoWhile,
1053 /// A `switch` statement (cognitive only; each `case` adds cyclomatic).
1054 Switch,
1055 /// A `case` label carrying a test (cyclomatic only).
1056 Case,
1057 /// A `catch` clause.
1058 Catch,
1059 /// A labeled `break` (cognitive only).
1060 LabeledBreak,
1061 /// A labeled `continue` (cognitive only).
1062 LabeledContinue,
1063 /// Legacy JSX-depth contribution kind kept for schema compatibility. Current
1064 /// extraction records JSX nesting as descriptive `react_jsx_max_depth`
1065 /// context and does not emit this kind for layout depth.
1066 JsxDepth,
1067 /// React hook density (cognitive only). One contribution per hook call in a
1068 /// component body (`useState` / `useEffect` / `useMemo` / `useCallback` /
1069 /// custom `use*`); a hook-heavy component accrues cognitive load the same way
1070 /// branching does.
1071 HookDensity,
1072 /// React prop count past the comfortable floor (cognitive only). A component
1073 /// destructuring many props is doing many things; the props beyond the floor
1074 /// fold into cognitive so a wide-interface component surfaces as a hotspot.
1075 PropCount,
1076}
1077
1078/// A single complexity increment, located at its source line/column.
1079///
1080/// `weight` is the amount this construct added to `metric`; for nested
1081/// cognitive increments `weight == 1 + nesting`. Consumers that render inline
1082/// (the VS Code editor breakdown) group contributions by `line` and sum the
1083/// weights, deferring the per-kind list to a hover.
1084#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1085#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1086pub struct ComplexityContribution {
1087 /// 1-based line number where the construct begins.
1088 pub line: u32,
1089 /// 0-based byte column where the construct begins.
1090 pub col: u32,
1091 /// Which metric this increment contributes to.
1092 pub metric: ComplexityMetric,
1093 /// The syntactic construct responsible for the increment.
1094 pub kind: ComplexityContributionKind,
1095 /// The amount added to `metric` at this site (`1 + nesting` for nested
1096 /// cognitive increments, otherwise `1`).
1097 pub weight: u16,
1098 /// The nesting depth at the increment site (`0` when not nested). Lets a
1099 /// consumer explain a cognitive `+3` as "+1 base, +2 nesting".
1100 pub nesting: u16,
1101}
1102
1103/// The kind of feature flag pattern detected.
1104#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1105pub enum FlagUseKind {
1106 /// `process.env.FEATURE_X` pattern.
1107 EnvVar,
1108 /// SDK function call like `useFlag('name')`.
1109 SdkCall,
1110 /// Config object access like `config.features.x`.
1111 ConfigObject,
1112}
1113
1114/// A feature flag use site.
1115#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1116pub struct FlagUse {
1117 /// Flag identifier.
1118 pub flag_name: String,
1119 /// Detection kind.
1120 pub kind: FlagUseKind,
1121 /// 1-based line number.
1122 pub line: u32,
1123 /// 0-based byte column offset.
1124 pub col: u32,
1125 /// Start byte offset of the guarded block.
1126 pub guard_span_start: Option<u32>,
1127 /// End byte offset of the guarded block.
1128 pub guard_span_end: Option<u32>,
1129 /// SDK/provider name.
1130 pub sdk_name: Option<String>,
1131}
1132
1133const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);
1134
1135/// A dynamic import with a partially resolved pattern.
1136#[derive(Debug, Clone)]
1137pub struct DynamicImportPattern {
1138 /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
1139 pub prefix: String,
1140 /// Static suffix of the import path (e.g., ".json"), if any.
1141 pub suffix: Option<String>,
1142 /// Source span in the original file.
1143 pub span: Span,
1144}
1145
1146/// Visibility tag from JSDoc/TSDoc comments that suppresses unused-export detection.
1147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
1148#[serde(rename_all = "lowercase")]
1149#[repr(u8)]
1150pub enum VisibilityTag {
1151 /// No visibility tag present.
1152 #[default]
1153 None = 0,
1154 /// `@public` or `@api public` -- part of the public API surface.
1155 Public = 1,
1156 /// `@internal` -- exported for internal use (sister packages, build tools).
1157 Internal = 2,
1158 /// `@beta` -- public but unstable, may change without notice.
1159 Beta = 3,
1160 /// `@alpha` -- early preview, may change drastically without notice.
1161 Alpha = 4,
1162 /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
1163 ExpectedUnused = 5,
1164}
1165
1166impl VisibilityTag {
1167 /// Whether this tag permanently suppresses unused-export detection.
1168 /// `ExpectedUnused` is handled separately (conditionally suppresses,
1169 /// reports stale when the export becomes used).
1170 pub const fn suppresses_unused(self) -> bool {
1171 matches!(
1172 self,
1173 Self::Public | Self::Internal | Self::Beta | Self::Alpha
1174 )
1175 }
1176
1177 /// For serde `skip_serializing_if`.
1178 pub fn is_none(&self) -> bool {
1179 matches!(self, Self::None)
1180 }
1181}
1182
1183/// An export declaration.
1184#[derive(Debug, Clone, serde::Serialize)]
1185pub struct ExportInfo {
1186 /// The exported name (named or default).
1187 pub name: ExportName,
1188 /// The local binding name, if different from the exported name.
1189 pub local_name: Option<String>,
1190 /// Whether this is a type-only export (`export type`).
1191 pub is_type_only: bool,
1192 /// Whether this export is registered through a runtime side effect at module load time.
1193 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1194 pub is_side_effect_used: bool,
1195 /// Visibility tag from JSDoc/TSDoc comment.
1196 #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
1197 pub visibility: VisibilityTag,
1198 /// Human-authored reason on `@expected-unused -- <reason>`, when present.
1199 #[serde(default, skip_serializing_if = "Option::is_none")]
1200 pub expected_unused_reason: Option<String>,
1201 /// Source span of the export declaration.
1202 #[serde(serialize_with = "serialize_span")]
1203 pub span: Span,
1204 /// Members of this export (for enums, classes, and namespaces).
1205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1206 pub members: Vec<MemberInfo>,
1207 /// The local name of the parent class from `extends` clause, if any.
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1209 pub super_class: Option<String>,
1210}
1211
1212/// Additional heritage metadata for an exported class.
1213#[derive(
1214 Debug,
1215 Clone,
1216 serde::Serialize,
1217 serde::Deserialize,
1218 bitcode::Encode,
1219 bitcode::Decode,
1220 PartialEq,
1221 Eq,
1222)]
1223pub struct ClassHeritageInfo {
1224 /// Export name (`default` for default-exported classes).
1225 pub export_name: String,
1226 /// Parent class name from the `extends` clause, if any.
1227 pub super_class: Option<String>,
1228 /// Interface names from the class `implements` clause.
1229 pub implements: Vec<String>,
1230 /// Typed instance bindings used to resolve member-access chains in external templates.
1231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1232 pub instance_bindings: Vec<(String, String)>,
1233}
1234
1235/// A module-scope declaration that can be used as a TypeScript type.
1236#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1237pub struct LocalTypeDeclaration {
1238 /// Local declaration name.
1239 pub name: String,
1240 /// Declaration identifier span.
1241 #[serde(serialize_with = "serialize_span")]
1242 pub span: Span,
1243}
1244
1245/// A reference from an exported symbol's public signature to a type name.
1246#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1247pub struct PublicSignatureTypeReference {
1248 /// Exported symbol whose signature contains the reference.
1249 pub export_name: String,
1250 /// Referenced type name. Qualified names are reduced to their root identifier.
1251 pub type_name: String,
1252 /// Reference span.
1253 #[serde(serialize_with = "serialize_span")]
1254 pub span: Span,
1255}
1256
1257/// A member of an enum, class, or namespace.
1258#[derive(Debug, Clone, serde::Serialize)]
1259pub struct MemberInfo {
1260 /// Member name.
1261 pub name: String,
1262 /// The kind of member (enum, class method/property, or namespace member).
1263 pub kind: MemberKind,
1264 /// Source span of the member declaration.
1265 #[serde(serialize_with = "serialize_span")]
1266 pub span: Span,
1267 /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
1268 /// Decorated members are used by frameworks at runtime and should not be
1269 /// flagged as unused class members, unless every decorator on the member
1270 /// is opted out via `FallowConfig.ignore_decorators`.
1271 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1272 pub has_decorator: bool,
1273 /// Full dotted path of each decorator on this member, in source order.
1274 /// `@step("x")` stores `"step"`; `@ns.foo` stores `"ns.foo"`. Empty for
1275 /// undecorated members, Angular signal-initializer properties (which set
1276 /// `has_decorator` without a literal decorator AST node), and decorators
1277 /// whose expression is not an identifier ladder (the entry is the empty
1278 /// string in that case, treated as never-matching by the predicate).
1279 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1280 pub decorator_names: Vec<String>,
1281 /// True when this is a static class method that returns a fresh instance
1282 /// of the same class: either via `return new this()` / `return new
1283 /// <SameClassName>()` in the body's last statement, or via a declared
1284 /// return type matching the class name. Consumers calling such a static
1285 /// method receive an instance, so the call result's member accesses are
1286 /// credited against the class. See issues #346, #387.
1287 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1288 pub is_instance_returning_static: bool,
1289 /// True when this is an instance class method whose call result is an
1290 /// instance of the same class. Qualifies when the declared return type
1291 /// matches the class name (`setX(): EventBuilder { ... }`) or when the
1292 /// body's last statement is `return this`. The analyze layer walks fluent
1293 /// chains (`Class.factory().setX().setY()`) only through methods carrying
1294 /// this flag, so the chain stops at a non-self-returning method like
1295 /// `.build()`. See issue #387.
1296 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1297 pub is_self_returning: bool,
1298}
1299
1300/// The kind of member.
1301#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
1302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1303#[serde(rename_all = "snake_case")]
1304pub enum MemberKind {
1305 /// A TypeScript enum member.
1306 EnumMember,
1307 /// A class method.
1308 ClassMethod,
1309 /// A class property.
1310 ClassProperty,
1311 /// A member exported from a TypeScript namespace.
1312 NamespaceMember,
1313 /// A member declared by a store object (Pinia `state` / `getters` /
1314 /// `actions` key, or a setup-store returned key). Cross-graph dead-member
1315 /// detection: a store member never accessed by any consumer project-wide.
1316 StoreMember,
1317}
1318
1319/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
1320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
1321pub struct MemberAccess {
1322 /// The identifier being accessed (the import name).
1323 pub object: String,
1324 /// The member being accessed.
1325 pub member: String,
1326}
1327
1328/// A statically flattenable callee path invoked in a module (e.g. `execSync`,
1329/// `child_process.exec`, `console.log`). One entry per unique `callee_path`
1330/// per module; the span anchors the first occurrence. Consumed by the
1331/// `boundaries.calls.forbidden` detector.
1332#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1333pub struct CalleeUse {
1334 /// The dotted or bare callee path as written at the call site.
1335 pub callee_path: String,
1336 /// Start byte offset of the first call site using this path.
1337 pub span_start: u32,
1338}
1339
1340/// A `"use client"` / `"use server"` directive string written as an expression
1341/// statement in `program.body` (NOT the leading prologue), so the RSC bundler
1342/// silently ignores it. One entry per offending occurrence. Consumed by the
1343/// `misplaced-directive` detector.
1344#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1345pub struct MisplacedDirectiveSite {
1346 /// `true` for `"use server"`, `false` for `"use client"`.
1347 pub is_server: bool,
1348 /// Start byte offset of the misplaced directive statement.
1349 pub span_start: u32,
1350}
1351
1352/// Which side of a dependency-injection link a call site represents.
1353#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1354pub enum DiRole {
1355 /// `provide(KEY, value)` / `app.provide(KEY, value)` / `setContext(KEY, value)`.
1356 Provide,
1357 /// `inject(KEY)` / `getContext(KEY)`.
1358 Inject,
1359}
1360
1361/// Which framework's DI API a call site came from (drives the finding message).
1362#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1363pub enum DiFramework {
1364 /// Vue `provide` / `inject` (from `vue` / `@vue/runtime-core`).
1365 Vue,
1366 /// Svelte `setContext` / `getContext` (from `svelte`).
1367 Svelte,
1368 /// Angular `inject(TOKEN)` / `@Inject(TOKEN)` (from `@angular/core`),
1369 /// matched against `{ provide: TOKEN, ... }` provider objects.
1370 Angular,
1371}
1372
1373/// A Vue `provide`/`inject` or Svelte `setContext`/`getContext` call site keyed
1374/// by an identifier symbol. The `key_local` is resolved at analyze time through
1375/// the consuming module's import/export tables to a canonical defining-site
1376/// export key, so a provide and an inject of the same shared symbol unify even
1377/// across barrel re-exports. Consumed by the `unprovided-inject` detector.
1378#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1379pub struct DiKeySite {
1380 /// The key identifier as written at the call site.
1381 pub key_local: String,
1382 /// Whether this is a provide or an inject.
1383 pub role: DiRole,
1384 /// Which framework's API this came from.
1385 pub framework: DiFramework,
1386 /// Start byte offset of the call expression (anchors the finding).
1387 pub span_start: u32,
1388}
1389
1390/// A component prop declared by Vue `<script setup>` `defineProps` or Svelte 5
1391/// `$props()`. `used_in_script` / `used_in_template` are set during extraction;
1392/// the `unused-component-prop` detector flags a prop where neither is true. See
1393/// `harvest_define_props` and `harvest_svelte_props` in `sfc_props.rs`.
1394#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1395pub struct ComponentProp {
1396 /// The declared prop name.
1397 pub name: String,
1398 /// The template/script-visible local binding name: the destructure alias for
1399 /// `const { name: alias } = defineProps()` or
1400 /// `let { name: alias } = $props()`, otherwise the prop name itself. A
1401 /// renamed prop is read through this local, so usage must be checked against
1402 /// it, not the declared name.
1403 pub local: String,
1404 /// Start byte offset of the prop declaration (anchors the finding).
1405 pub span_start: u32,
1406 /// Whether this prop is referenced in the component's `<script>` (a
1407 /// destructured local binding with a resolved reference, or a `props.<name>`
1408 /// member access). For React, this is set-in-body: a resolved reference to the
1409 /// destructured local anywhere in the component function body.
1410 pub used_in_script: bool,
1411 /// Whether this prop name is referenced in the component's `<template>`.
1412 /// Set by `apply_template_usage` when the template scanner credits the name.
1413 /// Always false for React (no template; React uses `used_in_script`).
1414 pub used_in_template: bool,
1415 /// The enclosing component name. Empty for Vue SFCs (one component per file,
1416 /// the file stem is the component, set by the detector). For React this is the
1417 /// component function/arrow name a prop was declared on, so the detector can
1418 /// emit the right `component_name` and apply the per-component abstain ladder
1419 /// (a file can declare several React components).
1420 pub component: String,
1421 /// React-only: `true` when the destructured prop local is referenced at least
1422 /// once OUTSIDE a child-JSX attribute value expression (a substantive
1423 /// consumption: a hook arg, a host-element child, a non-JSX-attr read). When
1424 /// `used_in_script` is true but this is false, the prop is referenced ONLY as
1425 /// the root of forwarded child attribute values, i.e. a pure pass-through.
1426 /// Always `false` for Vue (no forward-vs-consume distinction is computed).
1427 pub used_outside_forward: bool,
1428}
1429
1430/// A Vue `<script setup>` `defineEmits` declared event, harvested from the type
1431/// tuple-call form (`defineEmits<{ (e: 'foo'): void }>()`), the type object form
1432/// (`defineEmits<{ foo: [x: string] }>()`), or the runtime array form
1433/// (`defineEmits(['foo'])`). `used` is set during extraction when the bound emit
1434/// name is called as `emit('<name>')`. The `unused-component-emit` detector flags
1435/// an event where `used` is false. See `harvest_define_emits` in `sfc_props.rs`.
1436#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1437pub struct ComponentEmit {
1438 /// The declared emit event name.
1439 pub name: String,
1440 /// Start byte offset of the emit declaration (anchors the finding).
1441 pub span_start: u32,
1442 /// Whether this event is emitted via `emit('<name>')` somewhere in the
1443 /// component's `<script>`.
1444 pub used: bool,
1445}
1446
1447/// A Svelte custom event dispatched via `dispatch('<name>')`, where `dispatch`
1448/// is the binding from a `const dispatch = createEventDispatcher()` call. Only
1449/// literal-first-arg dispatches are recorded; a `dispatch(<nonLiteral>)` sets
1450/// `ModuleInfo::has_dynamic_dispatch` instead. Consumed by the
1451/// `unused-svelte-event` detector, which flags an event dispatched here but
1452/// listened to nowhere project-wide (the cross-file dead-output direction). The
1453/// span is a byte offset (not an `oxc_span::Span`) so the type round-trips
1454/// through the bitcode cache directly, mirroring `ComponentEmit::span_start`.
1455#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1456pub struct DispatchedEvent {
1457 /// The dispatched event name (the literal first argument).
1458 pub name: String,
1459 /// Start byte offset of the `dispatch(...)` call (anchors the finding).
1460 pub span_start: u32,
1461}
1462
1463/// A declared Angular component/directive input, harvested from an `@Input()`
1464/// decorator or a signal `input()` / `input.required()` / `model()` initializer
1465/// on an Angular-decorated class. Consumed by the `unused-component-input`
1466/// detector, which flags an input read nowhere in its own component (neither the
1467/// template nor the class body). The span is stored as a byte offset (not an
1468/// `oxc_span::Span`) so the type is cheap to mirror onto the cache, matching
1469/// `ComponentEmit::span_start`. `ModuleInfo` is not serialized, so no serde
1470/// attrs are derived here. `bitcode` derives let the type be mirrored directly
1471/// onto `CachedModule` (the same pattern as `ComponentEmit`).
1472#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1473pub struct AngularInputMember {
1474 /// The declared input name (the property key).
1475 pub name: String,
1476 /// Start byte offset of the property key (anchors the finding).
1477 pub span_start: u32,
1478}
1479
1480/// A declared Angular component/directive output, harvested from an `@Output()`
1481/// decorator or a signal `output()` / `outputFromObservable()` initializer on an
1482/// Angular-decorated class. Consumed by the `unused-component-output` detector,
1483/// which flags an output emitted nowhere in its own component. A `model()` is an
1484/// input and a framework-driven output, so it is recorded ONLY as an input and
1485/// never appears here (the implicit `update:` emit is framework-managed). The
1486/// span is a byte offset for the same reason as `AngularInputMember`.
1487#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1488pub struct AngularOutputMember {
1489 /// The declared output name (the property key).
1490 pub name: String,
1491 /// Start byte offset of the property key (anchors the finding).
1492 pub span_start: u32,
1493}
1494
1495/// A declared Angular `@Component` and its `selector` value(s), harvested from a
1496/// `@Component({ selector: '...' })` decorator. Consumed by the Angular arm of
1497/// the `unrendered-component` detector, which flags a component whose every
1498/// element selector is used in NO template project-wide (and that is not
1499/// referenced by class name anywhere, e.g. routed / bootstrapped / dynamically
1500/// rendered). A multi-selector string (`'app-foo, [appBar]'`) is split into the
1501/// `selectors` list. The span is stored as a byte offset (not an
1502/// `oxc_span::Span`) so the type round-trips through the bitcode cache directly,
1503/// mirroring `AngularInputMember::span_start`. `@Directive` is intentionally NOT
1504/// harvested here (directives have no template render). `ModuleInfo` is not
1505/// serialized, so no serde attrs are derived.
1506#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1507pub struct AngularComponentSelector {
1508 /// The declared selector strings for this component, split on `,`. A purely
1509 /// element-selector component has only `app-foo`-shaped entries; attribute
1510 /// (`[appFoo]`) and class (`.foo`) selectors are retained verbatim so the
1511 /// detector can abstain when ANY non-element selector is present.
1512 pub selectors: Vec<String>,
1513 /// Start byte offset of the component class declaration (anchors the
1514 /// finding).
1515 pub span_start: u32,
1516 /// The component class name (used to credit routed / bootstrapped / dynamic
1517 /// class-name references project-wide).
1518 pub class_name: String,
1519}
1520
1521/// A key returned from a SvelteKit route `load()` function's terminal return
1522/// object literal. Harvested from `+page.{ts,server.ts,js,server.js}` files
1523/// exporting a `load` function. Consumed by the `unused-load-data-key` detector,
1524/// which flags a key read by no consumer. The span is stored as byte offsets
1525/// (not an `oxc_span::Span`) so the type round-trips through the bitcode cache
1526/// directly, mirroring `DiKeySite::span_start` / `ComponentEmit::span_start`.
1527#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
1528pub struct LoadReturnKey {
1529 /// The returned-object property key name.
1530 pub name: String,
1531 /// Start byte offset of the key (anchors the finding).
1532 pub span_start: u32,
1533 /// End byte offset of the key.
1534 pub span_end: u32,
1535}
1536
1537/// The syntactic shape of an identified React component definition. Drives the
1538/// abstain ladder later phases apply: a `forwardRef` / `memo` wrapper whose
1539/// props come from an imported interface fallow cannot resolve must abstain
1540/// (ADR-001), not guess.
1541#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1542pub enum ComponentFunctionKind {
1543 /// A `function Foo() { return <.../> }` declaration.
1544 FnDecl,
1545 /// A `const Foo = () => <.../>` arrow (or function-expression) binding.
1546 Arrow,
1547 /// A `const Foo = forwardRef((props, ref) => <.../>)` wrapper.
1548 ForwardRefWrapper,
1549 /// A `const Foo = memo((props) => <.../>)` wrapper.
1550 MemoWrapper,
1551}
1552
1553/// An identified React component: a function/arrow whose body returns JSX.
1554/// Captured by `visit_jsx_element`'s enclosing-component tracking. The
1555/// `unused-component-prop` (React arm) and complexity-fold phases consume this;
1556/// the abstain flags keep zero-FP on the cases ADR-001 cannot resolve.
1557#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1558pub struct ComponentFunction {
1559 /// The component name (the binding or declaration identifier).
1560 pub name: String,
1561 /// Start byte offset of the component definition (anchors findings).
1562 pub span_start: u32,
1563 /// The syntactic shape of the definition.
1564 pub kind: ComponentFunctionKind,
1565 /// Whether the component is exported from its module (a named export, a
1566 /// `export default`, or re-exported in the same module). Public-API
1567 /// components abstain in the prop phase.
1568 pub is_exported: bool,
1569 /// `true` when the component's props are not statically harvestable: a
1570 /// rest/spread in the signature (`{ ...rest }`), props passed wholesale to a
1571 /// hook/helper, or a `forwardRef` / `memo` wrapper whose props come from an
1572 /// imported interface generic fallow cannot resolve (ADR-001). The prop
1573 /// phase abstains on the whole component when set.
1574 pub has_unharvestable_props: bool,
1575 /// `true` when the component body calls `cloneElement` / `React.cloneElement`.
1576 /// `cloneElement` injects props by reflection, so the static forward-set is
1577 /// incomplete; the prop-drilling phase abstains on any chain through this
1578 /// component (ADR-001, zero-FP).
1579 pub uses_clone_element: bool,
1580 /// `true` when the component renders a `*.Provider` member-expression tag
1581 /// (`<FooContext.Provider>`). A context provider in the subtree means the
1582 /// drilling may be a deliberate non-context choice (or the prop is about to
1583 /// be provided); the prop-drilling phase downgrades/abstains.
1584 pub renders_provider: bool,
1585 /// `true` when the component passes a function as a child render value
1586 /// (render-props / children-as-function: `<Foo>{() => ...}</Foo>` or
1587 /// `<Foo render={() => ...}/>`). The forwarded shape is dynamic; the
1588 /// prop-drilling phase abstains on chains through this component.
1589 pub has_children_as_function: bool,
1590 /// `true` when the component body is pure structural indirection: a single
1591 /// statement returning exactly one capitalized/member-expression JSX element
1592 /// (no host wrapper, no extra children, optionally a fragment wrapping a
1593 /// single element) that forwards props via a bare spread of the component's
1594 /// own props binding / rest local (`<Child {...props}/>`), with NO named
1595 /// attributes alongside the spread and NO self-render. The cross-component
1596 /// `thin-wrapper` phase joins this with hook-density / cyclomatic checks and
1597 /// the resolved single render edge to flag a component that is a candidate
1598 /// for inlining. Computed from the component's own AST only, so it caches
1599 /// byte-identity-safe (ADR-001).
1600 pub is_pure_passthrough: bool,
1601}
1602
1603/// The kind of a React hook call. `Custom` covers any `use*`-named call that is
1604/// not one of the built-in hooks.
1605#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
1606pub enum HookUseKind {
1607 /// `useState(...)`.
1608 UseState,
1609 /// `useEffect(...)`.
1610 UseEffect,
1611 /// `useMemo(...)`.
1612 UseMemo,
1613 /// `useCallback(...)`.
1614 UseCallback,
1615 /// Any other `use*`-named call (a custom hook).
1616 Custom,
1617}
1618
1619/// A React hook call site inside a component. Consumed by the complexity-fold
1620/// phase (hook density) and surfaced as descriptive hotspot context.
1621#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1622pub struct HookUse {
1623 /// The hook kind.
1624 pub kind: HookUseKind,
1625 /// The dependency-array arity, recorded ONLY when a literal array is present
1626 /// at the dependency-array position (`[a, b]` -> `Some(2)`, `[]` ->
1627 /// `Some(0)`). `None` when the call has no dependency array argument or the
1628 /// argument is not a literal array (ADR-001: do not guess).
1629 pub dep_array_arity: Option<u32>,
1630 /// Start byte offset of the hook call (anchors findings).
1631 pub span_start: u32,
1632}
1633
1634/// A render edge: one component rendering another (a capitalized or
1635/// member-expression JSX tag). Captured at extraction time with the child's
1636/// written name; resolution of `child_component_name` to a `FileId`/export is
1637/// deferred to graph build via the existing import map.
1638#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1639pub struct RenderEdge {
1640 /// The name of the component that renders the child (the enclosing
1641 /// component). Empty when the JSX is not inside an identified component (a
1642 /// top-level render expression).
1643 pub parent_component: String,
1644 /// The rendered child component name as written (`Foo` or the full
1645 /// member-expression path `Foo.Bar`).
1646 pub child_component_name: String,
1647 /// The attribute (prop) names passed at the render site, in source order.
1648 pub attr_names: Vec<String>,
1649 /// `true` when the render site contains a JSX spread (`{...x}`), so the
1650 /// passed-prop set is not statically complete.
1651 pub has_spread: bool,
1652 /// The forwarded attributes at this render site: each pairs the child
1653 /// attribute NAME with the identifier ROOT of its value expression
1654 /// (`userName={user.name}` -> `{ attr: "userName", root: "user" }`;
1655 /// `value={x}` -> `{ attr: "value", root: "x" }`). ONLY plain identifier or
1656 /// member-root access values are recorded (`{x}`, `{x.y}`, `{x.y.z}`); a value
1657 /// that is a call, an arrow/function, a conditional, a JSX element, or any
1658 /// other complex expression is NOT recorded here (its root would not be a pure
1659 /// forward) and sets `has_complex_forward` instead. The prop-drilling chain
1660 /// walk uses this pairing to map "this component forwards prop P" to "the
1661 /// child receives it as attribute A".
1662 pub forward_attrs: Vec<ForwardAttr>,
1663 /// `true` when at least one attribute value at this render site is a complex
1664 /// expression (a call, an arrow/function render-prop, a conditional, a JSX
1665 /// element-as-prop, a template literal, etc.) whose identifier root was NOT
1666 /// recorded in `forward_attrs`. The prop-drilling phase abstains on a chain
1667 /// whose forwarded prop flows through such a value (ADR-001, zero-FP).
1668 pub has_complex_forward: bool,
1669}
1670
1671/// One forwarded JSX attribute: the child attribute name plus the identifier
1672/// root of its value expression. See [`RenderEdge::forward_attrs`].
1673#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
1674pub struct ForwardAttr {
1675 /// The child attribute (prop) name as written (`userName`).
1676 pub attr: String,
1677 /// The identifier root of the attribute value expression (`user` for
1678 /// `userName={user.name}`).
1679 pub root: String,
1680}
1681
1682#[expect(
1683 clippy::trivially_copy_pass_by_ref,
1684 reason = "serde serialize_with requires &T"
1685)]
1686fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
1687 use serde::ser::SerializeMap;
1688 let mut map = serializer.serialize_map(Some(2))?;
1689 map.serialize_entry("start", &span.start)?;
1690 map.serialize_entry("end", &span.end)?;
1691 map.end()
1692}
1693
1694/// Export identifier.
1695#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
1696pub enum ExportName {
1697 /// A named export (e.g., `export const foo`).
1698 Named(String),
1699 /// The default export.
1700 Default,
1701}
1702
1703impl ExportName {
1704 /// Compare against a string without allocating (avoids `to_string()`).
1705 #[must_use]
1706 pub fn matches_str(&self, s: &str) -> bool {
1707 match self {
1708 Self::Named(n) => n == s,
1709 Self::Default => s == "default",
1710 }
1711 }
1712}
1713
1714impl std::fmt::Display for ExportName {
1715 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1716 match self {
1717 Self::Named(n) => write!(f, "{n}"),
1718 Self::Default => write!(f, "default"),
1719 }
1720 }
1721}
1722
1723/// An import declaration.
1724#[derive(Debug, Clone)]
1725pub struct ImportInfo {
1726 /// The import specifier (e.g., `./utils` or `react`).
1727 pub source: String,
1728 /// How the symbol is imported (named, default, namespace, or side-effect).
1729 pub imported_name: ImportedName,
1730 /// The local binding name in the importing module.
1731 pub local_name: String,
1732 /// Whether this is a type-only import (`import type`).
1733 pub is_type_only: bool,
1734 /// Whether this import originated from a CSS-context.
1735 pub from_style: bool,
1736 /// Source span of the import declaration.
1737 pub span: Span,
1738 /// Span of the source string literal used by the LSP to highlight the specifier.
1739 pub source_span: Span,
1740}
1741
1742/// How a symbol is imported.
1743#[derive(Debug, Clone, PartialEq, Eq)]
1744pub enum ImportedName {
1745 /// A named import (e.g., `import { foo }`).
1746 Named(String),
1747 /// A default import (e.g., `import React`).
1748 Default,
1749 /// A namespace import (e.g., `import * as utils`).
1750 Namespace,
1751 /// A side-effect import (e.g., `import './styles.css'`).
1752 SideEffect,
1753}
1754
1755#[cfg(target_pointer_width = "64")]
1756const _: () = assert!(std::mem::size_of::<ExportInfo>() == 136);
1757#[cfg(target_pointer_width = "64")]
1758const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
1759#[cfg(target_pointer_width = "64")]
1760const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
1761#[cfg(target_pointer_width = "64")]
1762const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
1763#[cfg(target_pointer_width = "64")]
1764const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
1765#[cfg(target_pointer_width = "64")]
1766const _: () = assert!(std::mem::size_of::<SinkSite>() == 216);
1767#[cfg(target_pointer_width = "64")]
1768const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 1256);
1769
1770/// A re-export declaration.
1771#[derive(Debug, Clone)]
1772pub struct ReExportInfo {
1773 /// The module being re-exported from.
1774 pub source: String,
1775 /// The name imported from the source module (or `*` for star re-exports).
1776 pub imported_name: String,
1777 /// The name exported from this module.
1778 pub exported_name: String,
1779 /// Whether this is a type-only re-export.
1780 pub is_type_only: bool,
1781 /// Source span of the re-export declaration on this module.
1782 pub span: oxc_span::Span,
1783}
1784
1785/// A dynamic `import()` call.
1786#[derive(Debug, Clone)]
1787pub struct DynamicImportInfo {
1788 /// The import specifier.
1789 pub source: String,
1790 /// Source span of the `import()` expression.
1791 pub span: Span,
1792 /// Names destructured from the dynamic import result.
1793 /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
1794 /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
1795 pub destructured_names: Vec<String>,
1796 /// The local variable name for `const x = await import(...)`.
1797 /// Used for namespace import narrowing via member access tracking.
1798 pub local_name: Option<String>,
1799 /// True when this dynamic import was synthesised by fallow rather than appearing in user source.
1800 pub is_speculative: bool,
1801}
1802
1803/// A `require()` call.
1804#[derive(Debug, Clone)]
1805pub struct RequireCallInfo {
1806 /// The require specifier.
1807 pub source: String,
1808 /// Source span of the `require()` call.
1809 pub span: Span,
1810 /// Source span of the specifier string-literal argument (including its
1811 /// quotes), e.g. the `'./x'` in `require('./x')`. Used to anchor an
1812 /// `unresolved-import` diagnostic squiggly under the specifier rather than
1813 /// the `require` keyword. `Span::default()` when the argument is not a
1814 /// plain string literal.
1815 pub source_span: Span,
1816 /// Names destructured from the `require()` result.
1817 pub destructured_names: Vec<String>,
1818 /// The local variable name for `const x = require(...)`.
1819 pub local_name: Option<String>,
1820}
1821
1822/// Result of parsing all files, including incremental cache statistics.
1823pub struct ParseResult {
1824 /// Extracted module information for all successfully parsed files.
1825 pub modules: Vec<ModuleInfo>,
1826 /// Number of files whose parse results were loaded from cache (unchanged).
1827 pub cache_hits: usize,
1828 /// Number of files that required a full parse (new or changed).
1829 pub cache_misses: usize,
1830 /// Summed wall-clock time of the actual AST parses across all rayon workers.
1831 pub parse_cpu_ms: f64,
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836 use super::*;
1837
1838 fn span() -> Span {
1839 Span::new(0, 1)
1840 }
1841
1842 macro_rules! assert_released {
1843 ($values:expr) => {{
1844 assert!($values.is_empty());
1845 assert_eq!($values.capacity(), 0);
1846 }};
1847 }
1848
1849 #[test]
1850 fn public_env_var_includes_public_ci_metadata() {
1851 for name in ["TAG_REF", "GITHUB_SHA", "CI_COMMIT_BRANCH", "APP_MODE"] {
1852 assert!(is_public_env_var(name), "{name} should be public metadata");
1853 }
1854 }
1855
1856 #[test]
1857 fn public_env_var_keeps_secret_shaped_names_source_backed() {
1858 for name in ["GITHUB_TOKEN", "REFRESH_TOKEN", "API_KEY", "SECRET_SHA"] {
1859 assert!(
1860 !is_public_env_var(name),
1861 "{name} should remain secret-shaped"
1862 );
1863 }
1864 }
1865
1866 #[test]
1867 fn line_offsets_empty_string() {
1868 assert_eq!(compute_line_offsets(""), vec![0]);
1869 }
1870
1871 #[test]
1872 #[expect(
1873 clippy::too_many_lines,
1874 reason = "exhaustive field-by-field construction + release assertions for every ModuleInfo field"
1875 )]
1876 fn release_resolution_payload_drops_copied_vectors_only() {
1877 let mut module = ModuleInfo {
1878 file_id: FileId(7),
1879 exports: vec![ExportInfo {
1880 name: ExportName::Named("kept".to_string()),
1881 local_name: None,
1882 is_type_only: false,
1883 is_side_effect_used: false,
1884 visibility: VisibilityTag::None,
1885 expected_unused_reason: None,
1886 span: span(),
1887 members: Vec::new(),
1888 super_class: None,
1889 }],
1890 imports: vec![ImportInfo {
1891 source: "node:child_process".to_string(),
1892 imported_name: ImportedName::Default,
1893 local_name: "childProcess".to_string(),
1894 is_type_only: false,
1895 from_style: false,
1896 span: span(),
1897 source_span: span(),
1898 }],
1899 re_exports: vec![ReExportInfo {
1900 source: "./kept".to_string(),
1901 imported_name: "kept".to_string(),
1902 exported_name: "kept".to_string(),
1903 is_type_only: false,
1904 span: span(),
1905 }],
1906 dynamic_imports: vec![DynamicImportInfo {
1907 source: "./dynamic".to_string(),
1908 span: span(),
1909 destructured_names: vec!["value".to_string()],
1910 local_name: None,
1911 is_speculative: false,
1912 }],
1913 dynamic_import_patterns: vec![DynamicImportPattern {
1914 prefix: "./pages/".to_string(),
1915 suffix: Some(".tsx".to_string()),
1916 span: span(),
1917 }],
1918 require_calls: vec![RequireCallInfo {
1919 source: "./required".to_string(),
1920 span: span(),
1921 source_span: span(),
1922 destructured_names: Vec::new(),
1923 local_name: Some("required".to_string()),
1924 }],
1925 package_path_references: vec!["react".to_string()],
1926 member_accesses: vec![MemberAccess {
1927 object: "Status".to_string(),
1928 member: "Active".to_string(),
1929 }],
1930 whole_object_uses: vec!["Status".to_string()],
1931 has_cjs_exports: true,
1932 has_angular_component_template_url: true,
1933 content_hash: 42,
1934 suppressions: Vec::new(),
1935 unknown_suppression_kinds: Vec::new(),
1936 unused_import_bindings: vec!["unused".to_string()],
1937 type_referenced_import_bindings: vec!["TypeOnly".to_string()],
1938 value_referenced_import_bindings: vec!["Value".to_string()],
1939 line_offsets: vec![0, 8],
1940 complexity: vec![FunctionComplexity {
1941 name: "work".to_string(),
1942 line: 1,
1943 col: 0,
1944 cyclomatic: 2,
1945 cognitive: 3,
1946 line_count: 4,
1947 param_count: 1,
1948 react_hook_count: 0,
1949 react_jsx_max_depth: 0,
1950 react_prop_count: 0,
1951 source_hash: Some("hash".to_string()),
1952 contributions: Vec::new(),
1953 }],
1954 flag_uses: vec![FlagUse {
1955 flag_name: "FEATURE_X".to_string(),
1956 kind: FlagUseKind::EnvVar,
1957 line: 1,
1958 col: 0,
1959 guard_span_start: None,
1960 guard_span_end: None,
1961 sdk_name: None,
1962 }],
1963 class_heritage: vec![ClassHeritageInfo {
1964 export_name: "Child".to_string(),
1965 super_class: Some("Parent".to_string()),
1966 implements: vec!["Contract".to_string()],
1967 instance_bindings: Vec::new(),
1968 }],
1969 injection_tokens: vec![("TOKEN".to_string(), "Contract".to_string())],
1970 local_type_declarations: vec![LocalTypeDeclaration {
1971 name: "Contract".to_string(),
1972 span: span(),
1973 }],
1974 public_signature_type_references: vec![PublicSignatureTypeReference {
1975 export_name: "kept".to_string(),
1976 type_name: "Contract".to_string(),
1977 span: span(),
1978 }],
1979 namespace_object_aliases: vec![NamespaceObjectAlias {
1980 via_export_name: "api".to_string(),
1981 suffix: "read".to_string(),
1982 namespace_local: "ns".to_string(),
1983 }],
1984 iconify_prefixes: vec!["hero".to_string()],
1985 iconify_icon_names: vec!["hero-home".to_string()],
1986 auto_import_candidates: vec!["useState".to_string()],
1987 directives: vec!["use client".to_string()],
1988 client_only_dynamic_import_spans: Vec::new(),
1989 security_sinks: Vec::new(),
1990 security_sinks_skipped: 1,
1991 security_unresolved_callee_sites: Vec::new(),
1992 tainted_bindings: Vec::new(),
1993 sanitized_sink_args: Vec::new(),
1994 security_control_sites: Vec::new(),
1995 callee_uses: Vec::new(),
1996 misplaced_directives: Vec::new(),
1997 inline_server_action_exports: Vec::new(),
1998 di_key_sites: Vec::new(),
1999 has_dynamic_provide: false,
2000 referenced_import_bindings: Vec::new(),
2001 component_props: Vec::new(),
2002 has_props_attrs_fallthrough: false,
2003 has_define_expose: false,
2004 has_define_model: false,
2005 has_unharvestable_props: false,
2006 component_emits: Vec::new(),
2007 angular_inputs: Vec::new(),
2008 angular_outputs: Vec::new(),
2009 angular_component_selectors: Vec::new(),
2010 angular_used_selectors: Vec::new(),
2011 angular_entry_component_refs: Vec::new(),
2012 has_dynamic_component_render: false,
2013 has_unharvestable_emits: false,
2014 has_dynamic_emit: false,
2015 has_emit_whole_object_use: false,
2016 load_return_keys: Vec::new(),
2017 has_unharvestable_load: false,
2018 has_load_data_whole_use: false,
2019 has_page_data_store_whole_use: false,
2020 component_functions: Vec::new(),
2021 react_props: Vec::new(),
2022 hook_uses: Vec::new(),
2023 render_edges: Vec::new(),
2024 svelte_dispatched_events: Vec::new(),
2025 svelte_listened_events: Vec::new(),
2026 has_dynamic_dispatch: false,
2027 };
2028
2029 module.release_resolution_payload();
2030
2031 assert_eq!(module.file_id, FileId(7));
2032 assert_eq!(module.content_hash, 42);
2033 assert_eq!(module.line_offsets, vec![0, 8]);
2034 assert_eq!(module.imports.len(), 1);
2035 assert_eq!(module.exports.len(), 1);
2036 assert_eq!(module.re_exports.len(), 1);
2037 assert_eq!(module.dynamic_import_patterns.len(), 1);
2038 assert_eq!(module.member_accesses.len(), 1);
2039 assert_eq!(module.complexity.len(), 1);
2040 assert_eq!(module.flag_uses.len(), 1);
2041 assert_eq!(module.class_heritage.len(), 1);
2042 assert_eq!(module.injection_tokens.len(), 1);
2043 assert_eq!(module.local_type_declarations.len(), 1);
2044 assert_eq!(module.public_signature_type_references.len(), 1);
2045 assert_eq!(module.iconify_prefixes.len(), 1);
2046 assert_eq!(module.iconify_icon_names.len(), 1);
2047 assert_eq!(module.directives.len(), 1);
2048 assert_eq!(module.security_sinks_skipped, 1);
2049 assert_released!(module.dynamic_imports);
2050 assert_released!(module.require_calls);
2051 assert_released!(module.package_path_references);
2052 assert_released!(module.whole_object_uses);
2053 assert_released!(module.unused_import_bindings);
2054 assert_released!(module.type_referenced_import_bindings);
2055 assert_released!(module.value_referenced_import_bindings);
2056 assert_released!(module.namespace_object_aliases);
2057 assert_released!(module.auto_import_candidates);
2058 assert_eq!(
2059 module.referenced_import_bindings,
2060 vec!["childProcess".to_string()]
2061 );
2062 }
2063
2064 #[test]
2065 fn sink_shape_bitcode_roundtrip() {
2066 for shape in [
2067 SinkShape::Call,
2068 SinkShape::MemberCall,
2069 SinkShape::MemberAssign,
2070 SinkShape::TaggedTemplate,
2071 SinkShape::JsxAttr,
2072 SinkShape::NewExpression,
2073 SinkShape::SecretLiteral,
2074 ] {
2075 let encoded = bitcode::encode(&shape);
2076 let decoded: SinkShape = bitcode::decode(&encoded).expect("decode sink shape");
2077 assert_eq!(shape, decoded);
2078 }
2079 }
2080
2081 #[test]
2082 fn sink_arg_kind_bitcode_roundtrip() {
2083 for kind in [
2084 SinkArgKind::TemplateWithSubst,
2085 SinkArgKind::Concat,
2086 SinkArgKind::Object,
2087 SinkArgKind::Call,
2088 SinkArgKind::Literal,
2089 SinkArgKind::NoArg,
2090 SinkArgKind::Other,
2091 ] {
2092 let encoded = bitcode::encode(&kind);
2093 let decoded: SinkArgKind = bitcode::decode(&encoded).expect("decode sink arg kind");
2094 assert_eq!(kind, decoded);
2095 }
2096 }
2097
2098 #[test]
2099 fn security_url_shape_bitcode_roundtrip() {
2100 for shape in [
2101 SecurityUrlShape::FixedOriginDynamicPath,
2102 SecurityUrlShape::DynamicOrigin,
2103 ] {
2104 let encoded = bitcode::encode(&shape);
2105 let decoded: SecurityUrlShape =
2106 bitcode::decode(&encoded).expect("decode security url shape");
2107 assert_eq!(shape, decoded);
2108 }
2109 }
2110
2111 #[test]
2112 fn sink_site_bitcode_roundtrip() {
2113 let site = SinkSite {
2114 sink_shape: SinkShape::MemberAssign,
2115 callee_path: "el.innerHTML".to_string(),
2116 arg_index: 0,
2117 arg_is_non_literal: true,
2118 arg_kind: SinkArgKind::Other,
2119 arg_literal: Some(SinkLiteralValue::Integer(511)),
2120 regex_pattern: None,
2121 object_properties: vec![SinkObjectProperty {
2122 key: "origin".to_string(),
2123 value: SinkLiteralValue::String("*".to_string()),
2124 }],
2125 object_property_keys: vec!["origin".to_string()],
2126 object_property_keys_complete: true,
2127 arg_idents: vec!["userInput".to_string()],
2128 arg_source_paths: vec!["req.body.email".to_string(), "req.body".to_string()],
2129 span_start: 10,
2130 span_end: 20,
2131 url_arg_literal: Some("https://api.example.com".to_string()),
2132 url_shape: Some(SecurityUrlShape::FixedOriginDynamicPath),
2133 };
2134 let encoded = bitcode::encode(&site);
2135 let decoded: SinkSite = bitcode::decode(&encoded).expect("decode sink site");
2136 assert_eq!(decoded.sink_shape, site.sink_shape);
2137 assert_eq!(decoded.callee_path, site.callee_path);
2138 assert_eq!(decoded.arg_index, site.arg_index);
2139 assert_eq!(decoded.arg_is_non_literal, site.arg_is_non_literal);
2140 assert_eq!(decoded.arg_kind, site.arg_kind);
2141 assert_eq!(decoded.arg_literal, site.arg_literal);
2142 assert_eq!(decoded.object_properties, site.object_properties);
2143 assert_eq!(decoded.object_property_keys, site.object_property_keys);
2144 assert_eq!(
2145 decoded.object_property_keys_complete,
2146 site.object_property_keys_complete
2147 );
2148 assert_eq!(decoded.arg_idents, site.arg_idents);
2149 assert_eq!(decoded.arg_source_paths, site.arg_source_paths);
2150 assert_eq!(decoded.url_shape, site.url_shape);
2151 assert_eq!(decoded.span(), site.span());
2152 }
2153
2154 #[test]
2155 fn line_offsets_single_line_no_newline() {
2156 assert_eq!(compute_line_offsets("hello"), vec![0]);
2157 }
2158
2159 #[test]
2160 fn line_offsets_single_line_with_newline() {
2161 assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
2162 }
2163
2164 #[test]
2165 fn line_offsets_multiple_lines() {
2166 assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
2167 }
2168
2169 #[test]
2170 fn line_offsets_trailing_newline() {
2171 assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
2172 }
2173
2174 #[test]
2175 fn line_offsets_consecutive_newlines() {
2176 assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
2177 }
2178
2179 #[test]
2180 fn line_offsets_multibyte_utf8() {
2181 assert_eq!(compute_line_offsets("รก\n"), vec![0, 3]);
2182 }
2183
2184 #[test]
2185 fn line_col_offset_zero() {
2186 let offsets = compute_line_offsets("abc\ndef\nghi");
2187 let (line, col) = byte_offset_to_line_col(&offsets, 0);
2188 assert_eq!((line, col), (1, 0));
2189 }
2190
2191 #[test]
2192 fn line_col_middle_of_first_line() {
2193 let offsets = compute_line_offsets("abc\ndef\nghi");
2194 let (line, col) = byte_offset_to_line_col(&offsets, 2);
2195 assert_eq!((line, col), (1, 2));
2196 }
2197
2198 #[test]
2199 fn line_col_start_of_second_line() {
2200 let offsets = compute_line_offsets("abc\ndef\nghi");
2201 let (line, col) = byte_offset_to_line_col(&offsets, 4);
2202 assert_eq!((line, col), (2, 0));
2203 }
2204
2205 #[test]
2206 fn line_col_middle_of_second_line() {
2207 let offsets = compute_line_offsets("abc\ndef\nghi");
2208 let (line, col) = byte_offset_to_line_col(&offsets, 5);
2209 assert_eq!((line, col), (2, 1));
2210 }
2211
2212 #[test]
2213 fn line_col_start_of_third_line() {
2214 let offsets = compute_line_offsets("abc\ndef\nghi");
2215 let (line, col) = byte_offset_to_line_col(&offsets, 8);
2216 assert_eq!((line, col), (3, 0));
2217 }
2218
2219 #[test]
2220 fn line_col_end_of_file() {
2221 let offsets = compute_line_offsets("abc\ndef\nghi");
2222 let (line, col) = byte_offset_to_line_col(&offsets, 10);
2223 assert_eq!((line, col), (3, 2));
2224 }
2225
2226 #[test]
2227 fn line_col_single_line() {
2228 let offsets = compute_line_offsets("hello");
2229 let (line, col) = byte_offset_to_line_col(&offsets, 3);
2230 assert_eq!((line, col), (1, 3));
2231 }
2232
2233 #[test]
2234 fn line_col_at_newline_byte() {
2235 let offsets = compute_line_offsets("abc\ndef");
2236 let (line, col) = byte_offset_to_line_col(&offsets, 3);
2237 assert_eq!((line, col), (1, 3));
2238 }
2239
2240 #[test]
2241 fn export_name_matches_str_named() {
2242 let name = ExportName::Named("foo".to_string());
2243 assert!(name.matches_str("foo"));
2244 assert!(!name.matches_str("bar"));
2245 assert!(!name.matches_str("default"));
2246 }
2247
2248 #[test]
2249 fn export_name_matches_str_default() {
2250 let name = ExportName::Default;
2251 assert!(name.matches_str("default"));
2252 assert!(!name.matches_str("foo"));
2253 }
2254
2255 #[test]
2256 fn export_name_display_named() {
2257 let name = ExportName::Named("myExport".to_string());
2258 assert_eq!(name.to_string(), "myExport");
2259 }
2260
2261 #[test]
2262 fn export_name_display_default() {
2263 let name = ExportName::Default;
2264 assert_eq!(name.to_string(), "default");
2265 }
2266
2267 #[test]
2268 fn export_name_equality_named() {
2269 let a = ExportName::Named("foo".to_string());
2270 let b = ExportName::Named("foo".to_string());
2271 let c = ExportName::Named("bar".to_string());
2272 assert_eq!(a, b);
2273 assert_ne!(a, c);
2274 }
2275
2276 #[test]
2277 fn export_name_equality_default() {
2278 let a = ExportName::Default;
2279 let b = ExportName::Default;
2280 assert_eq!(a, b);
2281 }
2282
2283 #[test]
2284 fn export_name_named_not_equal_to_default() {
2285 let named = ExportName::Named("default".to_string());
2286 let default = ExportName::Default;
2287 assert_ne!(named, default);
2288 }
2289
2290 #[test]
2291 fn export_name_hash_consistency() {
2292 use std::collections::hash_map::DefaultHasher;
2293 use std::hash::{Hash, Hasher};
2294
2295 let mut h1 = DefaultHasher::new();
2296 let mut h2 = DefaultHasher::new();
2297 ExportName::Named("foo".to_string()).hash(&mut h1);
2298 ExportName::Named("foo".to_string()).hash(&mut h2);
2299 assert_eq!(h1.finish(), h2.finish());
2300 }
2301
2302 #[test]
2303 fn export_name_matches_str_empty_string() {
2304 let name = ExportName::Named(String::new());
2305 assert!(name.matches_str(""));
2306 assert!(!name.matches_str("foo"));
2307 }
2308
2309 #[test]
2310 fn export_name_default_does_not_match_empty() {
2311 let name = ExportName::Default;
2312 assert!(!name.matches_str(""));
2313 }
2314
2315 #[test]
2316 fn imported_name_equality() {
2317 assert_eq!(
2318 ImportedName::Named("foo".to_string()),
2319 ImportedName::Named("foo".to_string())
2320 );
2321 assert_ne!(
2322 ImportedName::Named("foo".to_string()),
2323 ImportedName::Named("bar".to_string())
2324 );
2325 assert_eq!(ImportedName::Default, ImportedName::Default);
2326 assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
2327 assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
2328 assert_ne!(ImportedName::Default, ImportedName::Namespace);
2329 assert_ne!(
2330 ImportedName::Named("default".to_string()),
2331 ImportedName::Default
2332 );
2333 }
2334
2335 #[test]
2336 fn member_kind_equality() {
2337 assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
2338 assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
2339 assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
2340 assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
2341 assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
2342 assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
2343 assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
2344 }
2345
2346 #[test]
2347 fn member_kind_bitcode_roundtrip() {
2348 let kinds = [
2349 MemberKind::EnumMember,
2350 MemberKind::ClassMethod,
2351 MemberKind::ClassProperty,
2352 MemberKind::NamespaceMember,
2353 ];
2354 for kind in &kinds {
2355 let bytes = bitcode::encode(kind);
2356 let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
2357 assert_eq!(&decoded, kind);
2358 }
2359 }
2360
2361 #[test]
2362 fn member_access_bitcode_roundtrip() {
2363 let access = MemberAccess {
2364 object: "Status".to_string(),
2365 member: "Active".to_string(),
2366 };
2367 let bytes = bitcode::encode(&access);
2368 let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
2369 assert_eq!(decoded.object, "Status");
2370 assert_eq!(decoded.member, "Active");
2371 }
2372
2373 #[test]
2374 fn line_offsets_crlf_only_counts_lf() {
2375 let offsets = compute_line_offsets("ab\r\ncd");
2376 assert_eq!(offsets, vec![0, 4]);
2377 }
2378
2379 #[test]
2380 fn line_col_empty_file_offset_zero() {
2381 let offsets = compute_line_offsets("");
2382 let (line, col) = byte_offset_to_line_col(&offsets, 0);
2383 assert_eq!((line, col), (1, 0));
2384 }
2385
2386 #[test]
2387 fn function_complexity_bitcode_roundtrip() {
2388 let fc = FunctionComplexity {
2389 name: "processData".to_string(),
2390 line: 42,
2391 col: 4,
2392 cyclomatic: 15,
2393 cognitive: 25,
2394 line_count: 80,
2395 param_count: 3,
2396 react_hook_count: 0,
2397 react_jsx_max_depth: 0,
2398 react_prop_count: 0,
2399 source_hash: Some("0123456789abcdef".to_string()),
2400 contributions: vec![
2401 ComplexityContribution {
2402 line: 43,
2403 col: 8,
2404 metric: ComplexityMetric::Cyclomatic,
2405 kind: ComplexityContributionKind::If,
2406 weight: 1,
2407 nesting: 0,
2408 },
2409 ComplexityContribution {
2410 line: 45,
2411 col: 12,
2412 metric: ComplexityMetric::Cognitive,
2413 kind: ComplexityContributionKind::ElseIf,
2414 weight: 3,
2415 nesting: 2,
2416 },
2417 ],
2418 };
2419 let bytes = bitcode::encode(&fc);
2420 let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
2421 assert_eq!(decoded.name, "processData");
2422 assert_eq!(decoded.line, 42);
2423 assert_eq!(decoded.col, 4);
2424 assert_eq!(decoded.cyclomatic, 15);
2425 assert_eq!(decoded.cognitive, 25);
2426 assert_eq!(decoded.line_count, 80);
2427 assert_eq!(decoded.source_hash.as_deref(), Some("0123456789abcdef"));
2428 assert_eq!(decoded.contributions.len(), 2);
2429 assert_eq!(
2430 decoded.contributions[1].kind,
2431 ComplexityContributionKind::ElseIf
2432 );
2433 assert_eq!(decoded.contributions[1].weight, 3);
2434 assert_eq!(decoded.contributions[1].nesting, 2);
2435 assert_eq!(decoded.contributions[1].metric, ComplexityMetric::Cognitive);
2436 }
2437}