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