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