fallow-types 2.99.0

Shared types and serde paths for fallow codebase intelligence
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
//! Module extraction types.

use oxc_span::Span;

use crate::discover::FileId;
use crate::suppress::{Suppression, UnknownSuppressionKind};

/// Extracted module information from a single file.
#[derive(Debug, Clone)]
pub struct ModuleInfo {
    /// Unique identifier for this file.
    pub file_id: FileId,
    /// All export declarations in this module.
    pub exports: Vec<ExportInfo>,
    /// All import declarations in this module.
    pub imports: Vec<ImportInfo>,
    /// All re-export declarations (e.g., `export { foo } from './bar'`).
    pub re_exports: Vec<ReExportInfo>,
    /// All dynamic `import()` calls with string literal sources.
    pub dynamic_imports: Vec<DynamicImportInfo>,
    /// Dynamic import patterns.
    pub dynamic_import_patterns: Vec<DynamicImportPattern>,
    /// All `require()` calls.
    pub require_calls: Vec<RequireCallInfo>,
    /// Package names statically referenced through package path resolution.
    pub package_path_references: Vec<String>,
    /// Static member access expressions (e.g., `Status.Active`).
    pub member_accesses: Vec<MemberAccess>,
    /// Identifiers used in whole-object access patterns.
    pub whole_object_uses: Vec<String>,
    /// Whether this module uses CommonJS exports.
    pub has_cjs_exports: bool,
    /// Whether this module declares an Angular component `templateUrl`.
    pub has_angular_component_template_url: bool,
    /// xxh3 hash of the file content for incremental caching.
    pub content_hash: u64,
    /// Inline suppression directives parsed from comments.
    pub suppressions: Vec<Suppression>,
    /// Suppression tokens that did not parse to any known `IssueKind`.
    /// Surfaced as `StaleSuppression` findings via `find_stale` so users see
    /// typos or obsolete kind names instead of having the entire marker
    /// silently discarded. See issue #449.
    pub unknown_suppression_kinds: Vec<UnknownSuppressionKind>,
    /// Local names of import bindings that are never referenced in this file.
    /// Populated via `oxc_semantic` scope analysis. Used at graph-build time
    /// to skip adding references for imports whose binding is never read,
    /// improving unused-export detection precision.
    pub unused_import_bindings: Vec<String>,
    /// Local import bindings that are referenced from TypeScript type positions.
    /// Used to distinguish value-namespace and type-namespace references when a
    /// module exports both `const X` and `type X`.
    pub type_referenced_import_bindings: Vec<String>,
    /// Local import bindings referenced from runtime/value positions.
    pub value_referenced_import_bindings: Vec<String>,
    /// Pre-computed byte offsets where each line starts.
    pub line_offsets: Vec<u32>,
    /// Per-function complexity metrics.
    pub complexity: Vec<FunctionComplexity>,
    /// Feature flag use sites.
    pub flag_uses: Vec<FlagUse>,
    /// Heritage metadata for exported classes that declare `implements`.
    pub class_heritage: Vec<ClassHeritageInfo>,
    /// Angular `InjectionToken<Interface>` declarations, as
    /// `(token_export_name, interface_name)` pairs. Recorded only for
    /// `new InjectionToken<I>(...)` initializers whose `InjectionToken` is
    /// imported from `@angular/core`. The analyze layer follows the token's
    /// interface type argument to the classes that `implement` it so a template
    /// member call through `inject(TOKEN)` credits the concrete implementation.
    /// See issue #920 (follow-up to #911 / #913).
    pub injection_tokens: Vec<(String, String)>,
    /// Local type-capable declarations.
    pub local_type_declarations: Vec<LocalTypeDeclaration>,
    /// Type references in exported public signatures.
    pub public_signature_type_references: Vec<PublicSignatureTypeReference>,
    /// Aliases of namespace imports re-exported through an object literal.
    pub namespace_object_aliases: Vec<NamespaceObjectAlias>,
    /// Deduped Iconify collection prefixes found in static icon props.
    pub iconify_prefixes: Vec<String>,
    /// Deduped Nuxt UI `i-<collection>-<icon>` icon class suffixes found in
    /// static script-side icon properties.
    pub iconify_icon_names: Vec<String>,
    /// Bare identifiers that may be resolved by framework auto-imports.
    pub auto_import_candidates: Vec<String>,
    /// File-level string directives in source order (e.g. `"use client"`,
    /// `"use server"`, `"use strict"`). Captured from `Program::directives`.
    /// Consumed by the security `client-server-leak` detector to identify
    /// React Server Component client boundaries.
    pub directives: Vec<String>,
    /// Byte-offset starts of dynamic `import()` expressions wrapped in
    /// `next/dynamic(() => import('./X'), { ssr: false })`. The ssr:false option
    /// is Next.js's sanctioned way to pull a client-only module, so a server-only
    /// module reached ONLY through such an import is NOT a client-server leak. The
    /// security `client-server-leak` BFS resolves each dynamic import to a graph
    /// edge; these span starts let the BFS exclude exactly those edges (matched
    /// against the edge's `import_span`). Empty for files with no ssr:false
    /// dynamic import. Captured only by JS/TS extraction.
    pub client_only_dynamic_import_spans: Vec<u32>,
    /// Captured security sink sites (category-blind). Consumed by the
    /// catalogue-driven `tainted_sink` detector. Captured only by JS/TS
    /// extraction; empty for CSS/MDX/etc. See `security_matchers.toml`.
    pub security_sinks: Vec<SinkSite>,
    /// Count of sink-shaped nodes whose callee could not be flattened to a
    /// static path (dynamic dispatch, computed members, aliased bindings).
    /// Surfaced in-band so an empty catalogue result with a non-zero count is
    /// not a clean bill.
    pub security_sinks_skipped: u32,
    /// Compact span-level diagnostics for skipped security sink callees. Kept
    /// next to `security_sinks_skipped` so warm-cache and cold-cache security
    /// output can explain where the blind spots are concentrated without source
    /// snippets.
    pub security_unresolved_callee_sites: Vec<SkippedSecurityCalleeSite>,
    /// Local bindings whose initializer (or destructured object) is a flattened
    /// member-access path. Used by the security `tainted_sink` detector to
    /// back-trace a sink argument to a known untrusted source: the analyze layer
    /// matches each binding's `source_path` against the data-driven source
    /// catalogue (`security_matchers.toml` `[[source]]` rows) and treats the
    /// matching `local` names as source-tainted. Intra-module and name-based
    /// (no scope analysis); a conservative association, never a taint proof.
    pub tainted_bindings: Vec<TaintedBinding>,
    /// Sink arguments that were recognized as sanitizer calls at extraction
    /// time. Used for direct sink calls such as
    /// `el.innerHTML = DOMPurify.sanitize(input)`.
    pub sanitized_sink_args: Vec<SanitizedSinkArg>,
    /// Known defensive control call sites found in this module. Consumed only by
    /// the `fallow security --surface` agent JSON path.
    pub security_control_sites: Vec<SecurityControlSite>,
    /// Statically flattenable callee paths invoked in this module, deduped per
    /// unique path (first occurrence wins). Consumed by the
    /// `boundaries.calls.forbidden` detector. Captured unconditionally because
    /// extraction is config-blind; the per-module cost is bounded by the
    /// unique-callee count.
    pub callee_uses: Vec<CalleeUse>,
    /// `"use client"` / `"use server"` directive strings written as expression
    /// statements in `program.body` (misplaced, NOT in the leading
    /// prologue), so the RSC bundler silently ignores them. One entry per
    /// occurrence. Consumed by the `misplaced-directive` detector. Captured
    /// only by JS/TS extraction.
    pub misplaced_directives: Vec<MisplacedDirectiveSite>,
    /// Export LOCAL NAMES of exported functions / const-arrows whose body has an
    /// inline `"use server"` directive (`export async function f() { "use server"
    /// }`), captured in a NON-`"use server"` file. Consumed by the
    /// `unused-server-action` detector to reclassify an unused inline Server
    /// Action export out of `unused-export`. Captured only by JS/TS extraction.
    pub inline_server_action_exports: Vec<String>,
    /// Vue `provide`/`inject` and Svelte `setContext`/`getContext` call sites
    /// keyed by an identifier symbol. Consumed by the `unprovided-inject`
    /// detector to find an inject/getContext whose key is provided nowhere
    /// project-wide. Only identifier-keyed sites are recorded (string-literal
    /// and computed keys abstain). Captured by JS/TS and SFC extraction.
    pub di_key_sites: Vec<DiKeySite>,
    /// `true` when this module contains a `provide(...)` / `*.provide(...)` /
    /// `setContext(...)` call whose key argument is NOT a plain identifier
    /// (spread, computed, member, loop variable). Such a call can provide an
    /// unknowable key, so the `unprovided-inject` detector abstains on ALL
    /// inject findings project-wide when any reachable module sets this flag.
    /// Mirrors the spread-return whole-object abstain used for Pinia stores.
    pub has_dynamic_provide: bool,
    /// Local names of import bindings that ARE referenced somewhere in this file
    /// (script value/type position OR template/markup). The complement of
    /// `unused_import_bindings` among `imports`. Derived in
    /// `release_resolution_payload` (where both `imports` and
    /// `unused_import_bindings` are still present) so it survives the release and
    /// is readable by the analyze layer; it is never cached (recomputed on every
    /// cache load). Consumed by the `unrendered-component` detector to credit a
    /// Vue/Svelte SFC that some file actually imports-and-uses, distinguishing it
    /// from a component reachable only through a barrel re-export.
    pub referenced_import_bindings: Vec<String>,
    /// Vue `<script setup>` `defineProps` declared props. Consumed by the
    /// `unused-component-prop` detector to flag a prop referenced nowhere in its
    /// own SFC. Each entry carries `used_in_script` / `used_in_template`.
    pub component_props: Vec<ComponentProp>,
    /// `true` when the template spreads the whole props/attrs object
    /// (`v-bind="$attrs"` / `v-bind="$props"` / `v-bind="props"`) or the
    /// `defineProps` return is destructured with a rest element. Either form can
    /// consume a prop indirectly, so the detector abstains on the whole file.
    pub has_props_attrs_fallthrough: bool,
    /// `true` when the SFC calls `defineExpose(...)`. A prop may be re-exposed,
    /// so the detector conservatively abstains on the whole file.
    pub has_define_expose: bool,
    /// `true` when the SFC calls `defineModel(...)`. Two-way model props are out
    /// of scope for v1, so the detector abstains on the whole file.
    pub has_define_model: bool,
    /// `true` when `defineProps` was called with an unharvestable argument (a
    /// type-reference type argument such as `defineProps<Props>()` whose names
    /// require cross-file type resolution). The detector abstains on the whole
    /// file so a prop is never falsely flagged.
    pub has_unharvestable_props: bool,
    /// Vue `<script setup>` `defineEmits` declared events. Consumed by the
    /// `unused-component-emit` detector to flag an event emitted nowhere in its
    /// own SFC. Each entry carries `used`.
    pub component_emits: Vec<ComponentEmit>,
    /// Angular component/directive inputs declared via `@Input()` decorators or
    /// signal `input()` / `input.required()` / `model()` initializers. Consumed
    /// by the `unused-component-input` detector to flag an input read nowhere in
    /// its own component. Empty for every non-Angular class.
    pub angular_inputs: Vec<AngularInputMember>,
    /// Angular component/directive outputs declared via `@Output()` decorators or
    /// signal `output()` / `outputFromObservable()` initializers. Consumed by the
    /// `unused-component-output` detector to flag an output emitted nowhere in its
    /// own component. A `model()` is recorded as an input only (see
    /// `AngularOutputMember`). Empty for every non-Angular class.
    pub angular_outputs: Vec<AngularOutputMember>,
    /// Angular `@Component` declarations with their `selector` value(s), harvested
    /// from `@Component({ selector: '...' })` decorators. Consumed by the Angular
    /// arm of the `unrendered-component` detector. Empty for every non-Angular
    /// class and for `@Directive`. See `AngularComponentSelector`.
    pub angular_component_selectors: Vec<AngularComponentSelector>,
    /// Custom element selector tag names referenced in this file's Angular
    /// templates (inline `@Component({ template })` and the linked external
    /// `templateUrl` `.html` module), e.g. `<app-foo>` -> `app-foo`. Native HTML
    /// tag names are excluded at harvest. The detector unions these project-wide
    /// into the used-selector set. Empty for non-Angular files.
    pub angular_used_selectors: Vec<String>,
    /// Angular component class names referenced as a route entry or bootstrap
    /// target: a route `component: Foo` / `loadComponent: () => import().then(m =>
    /// m.Foo)` value, a `bootstrapApplication(Foo)` argument, or a
    /// `bootstrap: [Foo]` NgModule entry. These are render-equivalent entry points
    /// (Angular instantiates them without a template `<tag>`), so the Angular
    /// `unrendered-component` detector abstains on a component whose class name is
    /// in the project-wide union. A plain `declarations: [...]` / `imports: [...]`
    /// registration is intentionally NOT harvested here (that is the dead case the
    /// rule catches). Empty for non-Angular files.
    pub angular_entry_component_refs: Vec<String>,
    /// `true` when this file dynamically renders an Angular component fallow
    /// cannot attribute to a literal class reference: a
    /// `ViewContainerRef.createComponent(...)` / `*.createComponent(<ident>)`
    /// call, or an `*ngComponentOutlet` template binding. The Angular
    /// `unrendered-component` detector abstains project-wide when ANY reachable
    /// module sets this (mirroring `unprovided-inject`'s `has_dynamic_provide`),
    /// since a component could be rendered by a non-literal class reference.
    pub has_dynamic_component_render: bool,
    /// `true` when `defineEmits` was called with an unharvestable argument (a
    /// type-reference type argument such as `defineEmits<MyEmits>()`, a
    /// non-literal runtime form, or an unbound `defineEmits([...])`). The
    /// detector abstains on the whole file so an emit is never falsely flagged.
    pub has_unharvestable_emits: bool,
    /// `true` when an `emit(<nonLiteral>)` call was seen (the emitted event name
    /// cannot be known statically). The detector abstains on the whole file.
    pub has_dynamic_emit: bool,
    /// `true` when the `defineEmits` return binding was used as a WHOLE value
    /// (passed to a function, returned, or spread), which can emit any event
    /// opaquely. The detector abstains on the whole file.
    pub has_emit_whole_object_use: bool,
    /// SvelteKit `load()` return-object keys harvested from a
    /// `+page.{ts,server.ts,js,server.js}` file's terminal return literal.
    /// Consumed by the `unused-load-data-key` detector. Empty for every file
    /// that is not a page-load producer (gated by basename at harvest time).
    pub load_return_keys: Vec<LoadReturnKey>,
    /// `true` when this file's `load()` body could not be harvested safely (a
    /// spread return, a non-object/non-literal return, more than one top-level
    /// `return`, a computed key, or a wrapped/re-exported `load`). The detector
    /// abstains on the whole file so a key is never falsely flagged.
    pub has_unharvestable_load: bool,
    /// `true` when this file passes the whole `data` object opaquely (script
    /// `const X = data`, `fn(data)` / `fn(...data)`, or template `data={data}` /
    /// `{...data}` in a route component), so a child can read arbitrary keys the
    /// detector cannot see. Name-gated on the `data` binding. Read ONLY by the
    /// `unused-load-data-key` detector, so capturing it for all files is
    /// byte-identity-safe. See FP-1 in the plan.
    pub has_load_data_whole_use: bool,
    /// `true` when this file uses the whole `page.data` / `$page.data` store
    /// object opaquely (e.g. `Object.values(page.data)`, `{...$page.data}`), so a
    /// reflective read could consume any route's key. Drives the
    /// `unused-load-data-key` detector's project-wide abstain. Derived in
    /// `release_resolution_payload` from `whole_object_uses` BEFORE that vector is
    /// released (mirroring `referenced_import_bindings`), so it survives the
    /// release the detector runs after; it is never cached (recomputed each run
    /// from the cached `whole_object_uses`). Reassignment forms
    /// (`const all = $page.data`) are not whole-object-tracked and stay out of
    /// scope, matching the syntactic analyzer's conservative posture.
    pub has_page_data_store_whole_use: bool,
    /// React/JSX component definitions: functions/arrows whose body returns JSX.
    /// Captured only for `.jsx`/`.tsx` files when a React/Preact dependency is
    /// plausible. Consumed by the React `unused-component-prop` arm and the
    /// complexity-fold phase. Empty for non-React files.
    pub component_functions: Vec<ComponentFunction>,
    /// React component props (reuses the shared `ComponentProp` struct). For
    /// React, `used_in_template` is always false and `used_in_script` means
    /// used-in-body. Empty for non-React files.
    pub react_props: Vec<ComponentProp>,
    /// React hook call sites (`useState` / `useEffect` / `useMemo` /
    /// `useCallback` / custom `use*`). Drives hook-density complexity context.
    /// Empty for non-React files.
    pub hook_uses: Vec<HookUse>,
    /// React render edges: one component rendering another. Captured with the
    /// child's written name; child-to-`FileId` resolution is deferred to graph
    /// build. Empty for non-React files.
    pub render_edges: Vec<RenderEdge>,
    /// Svelte custom events dispatched via `dispatch('<name>')` where `dispatch`
    /// is the binding from `const dispatch = createEventDispatcher()`. Consumed
    /// by the `unused-svelte-event` detector to flag an event dispatched here but
    /// listened to nowhere project-wide. Each entry carries the literal event
    /// name and its span. Empty for every non-Svelte file.
    pub svelte_dispatched_events: Vec<DispatchedEvent>,
    /// Svelte custom-event listener names harvested from template `on:<name>`
    /// bindings on COMPONENT tags (PascalCase tag names). Lowercase DOM-element
    /// `on:click` is a DOM event, not a custom event, and is excluded. Unioned
    /// project-wide by the `unused-svelte-event` detector to build the liberal
    /// "listened" set. Empty for every non-Svelte file.
    pub svelte_listened_events: Vec<String>,
    /// `true` when a `dispatch(<nonLiteral>)` call was seen (the dispatched event
    /// name cannot be known statically), or the `dispatch` binding was used as a
    /// whole value (passed / returned). The `unused-svelte-event` detector
    /// abstains on the whole component so an event is never falsely flagged.
    pub has_dynamic_dispatch: bool,
}

impl ModuleInfo {
    /// Release extraction payload that resolution has already copied into the graph.
    ///
    /// This keeps fields needed by analysis, health, security, LSP, coverage,
    /// and hash drift checks, while dropping vectors that otherwise duplicate
    /// data owned by `ResolvedModule` or already credited into the module graph.
    pub fn release_resolution_payload(&mut self) {
        // Derive the referenced-binding set BEFORE releasing `unused_import_bindings`:
        // the analyze-layer `unrendered-component` detector needs "which imports are
        // actually used" but runs after this release, so capture the compact
        // complement here. Skip empty local names (side-effect imports).
        self.referenced_import_bindings = self
            .imports
            .iter()
            .map(|import| import.local_name.clone())
            .filter(|name| !name.is_empty() && !self.unused_import_bindings.contains(name))
            .collect();
        self.referenced_import_bindings.sort_unstable();
        self.referenced_import_bindings.dedup();

        // Derive the project-wide page-data-store whole-use signal BEFORE
        // releasing `whole_object_uses`: the `unused-load-data-key` detector runs
        // after this release and needs to know whether ANY module reflectively
        // consumes the whole `page.data` / `$page.data` store.
        self.has_page_data_store_whole_use = self
            .whole_object_uses
            .iter()
            .any(|name| name == "page.data" || name == "$page.data");

        Self::release_vec(&mut self.dynamic_imports);
        Self::release_vec(&mut self.require_calls);
        Self::release_vec(&mut self.package_path_references);
        Self::release_vec(&mut self.whole_object_uses);
        Self::release_vec(&mut self.unused_import_bindings);
        Self::release_vec(&mut self.type_referenced_import_bindings);
        Self::release_vec(&mut self.value_referenced_import_bindings);
        Self::release_vec(&mut self.namespace_object_aliases);
        Self::release_vec(&mut self.auto_import_candidates);
    }

    fn release_vec<T>(values: &mut Vec<T>) {
        *values = Vec::new();
    }
}

/// Defensive control family detected on a source to sink path.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SecurityControlKind {
    /// Sanitization or escaping before a sink.
    Sanitization,
    /// Input validation or schema parsing.
    Validation,
    /// Authentication check or middleware.
    Authentication,
    /// Authorization or permission check.
    Authorization,
}

/// A known defensive control call site.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
pub struct SecurityControlSite {
    /// Control family.
    pub kind: SecurityControlKind,
    /// Flattened callee path or a stable synthetic name for guard-derived
    /// controls.
    pub callee_path: String,
    /// Byte offset of the control span start.
    pub span_start: u32,
    /// Byte offset of the control span end.
    pub span_end: u32,
}

/// Sanitizer output domain. Kept intentionally narrow so a sanitizer for one
/// domain cannot suppress a different sink family.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
pub enum SanitizerScope {
    /// HTML markup sanitized by DOMPurify-compatible APIs.
    Html,
    /// URL or redirect target checked against a literal-backed allowlist.
    Url,
    /// Path value checked against a high-confidence containment guard.
    Path,
    /// SQL identifier quoted with a helper that doubles embedded identifier quotes.
    SqlIdentifier,
}

/// A captured sink argument that is itself a recognized sanitizer call.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
pub struct SanitizedSinkArg {
    /// Byte offset of the owning sink span start.
    pub span_start: u32,
    /// The positional argument index on the owning sink.
    pub arg_index: u32,
    /// The sanitizer output domain for this argument.
    pub scope: SanitizerScope,
}

/// A local binding tied to the flattened member-access path it was initialized
/// from. The analyze layer matches `source_path` against the data-driven source
/// catalogue; when it matches, `local` is treated as carrying untrusted input.
///
/// Captured for two shapes: a direct assignment (`const id = req.query.id` ->
/// `{ local: "id", source_path: "req.query" }`, the literal-key tail dropped so
/// the path matches a catalogue prefix) and an object destructure
/// (`const { id } = req.query` -> `{ local: "id", source_path: "req.query" }`).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
pub struct TaintedBinding {
    /// The local binding name introduced by the declarator.
    pub local: String,
    /// The flattened object member-access path the binding was sourced from.
    pub source_path: String,
    /// Byte offset of the source read (the member-access expression the binding
    /// was sourced from), so the analyze layer can anchor a taint trace's source
    /// node at the real read line instead of the module import line. Stored as a
    /// `u32` (not `Span`) to stay bitcode-encodable for the cache. `0` when no
    /// concrete read expression is available (synthetic framework-param /
    /// helper-return bindings), in which case the analyze layer falls back to the
    /// sink site rather than claiming a spurious line.
    pub source_span_start: u32,
}

/// Why a sink-shaped callee could not be flattened into a static catalogue
/// path.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SkippedSecurityCalleeReason {
    /// A computed member access such as `client[method](input)`.
    ComputedMember,
    /// A dynamic non-member callee such as `(factory())(input)`.
    DynamicDispatch,
    /// An assignment target whose object could not be flattened.
    UnsupportedAssignmentObject,
}

/// Syntactic expression shape for a skipped security callee.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SkippedSecurityCalleeExpressionKind {
    /// `obj.prop(...)`.
    StaticMemberExpression,
    /// `obj[prop](...)`.
    ComputedMemberExpression,
    /// A bare identifier or private identifier callee.
    Identifier,
    /// Any other call-like expression that cannot be represented compactly.
    Other,
}

/// Span-only diagnostic for a skipped security callee inside one module.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
pub struct SkippedSecurityCalleeSite {
    /// Why the callee was skipped.
    pub reason: SkippedSecurityCalleeReason,
    /// Compact expression shape of the skipped callee.
    pub expression_kind: SkippedSecurityCalleeExpressionKind,
    /// Start byte offset of the skipped callee expression.
    pub span_start: u32,
    /// End byte offset of the skipped callee expression.
    pub span_end: u32,
}

/// The syntactic shape of a captured security sink site. Category-blind: the
/// extractor records the shape and the dotted/bare callee path; the analyze
/// layer matches it against the data-driven catalogue. See
/// `crates/core/data/security_matchers.toml`.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
pub enum SinkShape {
    /// A call to a bare identifier (e.g. `eval(x)`).
    Call,
    /// A call to a dotted member path (e.g. `child_process.exec(x)`).
    MemberCall,
    /// An assignment to a member target (e.g. `el.innerHTML = x`).
    MemberAssign,
    /// A tagged template expression (e.g. ``sql`...${x}...` ``).
    TaggedTemplate,
    /// A JSX attribute value (e.g. `dangerouslySetInnerHTML={x}`).
    JsxAttr,
    /// A constructor call (e.g. `new Function("return x")`).
    NewExpression,
    /// A static string literal assigned to a secret-shaped identifier or known
    /// provider credential prefix.
    SecretLiteral,
}

/// The shape of the argument captured at a sink site. Category-blind like
/// [`SinkShape`], but finer-grained: it lets the catalogue matcher require or
/// exclude specific argument shapes. The discriminator is what distinguishes an
/// unsafe SQL string concatenation or template-into-`.execute()` from a
/// safely-parameterized `` sql`${x}` `` tagged template, an object-literal
/// `.execute({ sql, args })` argument, or a literal-aware sink argument.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
pub enum SinkArgKind {
    /// A template literal with at least one `${...}` substitution (e.g.
    /// `` `SELECT ${x}` ``). On a `tagged-template` shape this is the tag's
    /// quasi; on a `call`/`member-call` shape it is the positional argument.
    TemplateWithSubst,
    /// A binary `+` string concatenation (e.g. `"SELECT " + x`).
    Concat,
    /// An object literal (e.g. `.execute({ sql, args })`, the parameterized form).
    Object,
    /// A call expression argument (e.g. `query(buildSql())`).
    Call,
    /// A literal argument admitted by a literal-aware security matcher.
    Literal,
    /// A zero-argument sink captured because the callee itself is the signal.
    NoArg,
    /// Any other non-literal expression (bare identifier, member access, etc.).
    Other,
}

/// Static URL construction shape captured for URL-shaped security sinks.
#[derive(
    Debug,
    Clone,
    Copy,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum SecurityUrlShape {
    /// The sink target has a fixed origin, scheme, or relative root while only
    /// path or query components are dynamic.
    FixedOriginDynamicPath,
    /// The sink target's scheme or origin is dynamic or opaque.
    DynamicOrigin,
}

/// Literal values attached to literal-aware security sink captures.
#[derive(
    Debug,
    Clone,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
pub enum SinkLiteralValue {
    /// A string literal value.
    String(String),
    /// An integer numeric literal value.
    Integer(i64),
    /// A boolean literal value.
    Boolean(bool),
    /// A null literal value.
    Null,
}

/// Static object-literal property metadata attached to a captured sink
/// argument. Nested object paths are flattened with dot-separated keys.
#[derive(
    Debug,
    Clone,
    PartialEq,
    Eq,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
)]
pub struct SinkObjectProperty {
    /// Static property name. Nested object properties use dot-separated paths.
    pub key: String,
    /// Literal property value when statically knowable.
    pub value: SinkLiteralValue,
}

/// A captured sink site. The visitor records every existing non-literal call /
/// member-assign / member-call / tagged-template / jsx-attr sink site, and a
/// small allowlist of literal-aware sites where the literal value is the signal.
/// It knows nothing about CWE categories.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
pub struct SinkSite {
    /// The syntactic shape of the sink site.
    pub sink_shape: SinkShape,
    /// The flattened dotted/bare callee or member path.
    pub callee_path: String,
    /// The positional argument index. For zero-argument captures this is 0.
    pub arg_index: u32,
    /// Whether the relevant argument is non-literal. Existing non-literal
    /// catalogue rows require this to remain true.
    pub arg_is_non_literal: bool,
    /// The finer-grained shape of the captured argument. Lets the catalogue
    /// require unsafe shapes (concat / template-with-substitution / literal /
    /// no-arg) and exclude safe ones (object literal, the parameterized form).
    /// See [`SinkArgKind`].
    pub arg_kind: SinkArgKind,
    /// Literal argument value for literal-aware rows.
    pub arg_literal: Option<SinkLiteralValue>,
    /// Risky regex fragment for structural ReDoS candidates.
    pub regex_pattern: Option<String>,
    /// Static object-literal properties for option-object rows.
    pub object_properties: Vec<SinkObjectProperty>,
    /// Static top-level object-literal keys, including keys whose values are not
    /// literal. Used by missing-option rows that only need key presence.
    pub object_property_keys: Vec<String>,
    /// Whether [`object_property_keys`](Self::object_property_keys) is complete.
    /// False for non-object arguments and object literals with spread or
    /// non-static keys, where a missing-key claim would be speculative.
    pub object_property_keys_complete: bool,
    /// Identifier names referenced anywhere inside the captured non-literal sink
    /// argument, or contextual names for zero-argument captures such as a
    /// token-like `Math.random()` assignment target. Deduped in source order.
    /// Used by the analyze layer to back-trace the sink argument to a known
    /// untrusted source or to apply narrow context gates. Intra-module,
    /// name-based, conservative; it is never a taint proof.
    pub arg_idents: Vec<String>,
    /// Flattened static member paths referenced inside the captured non-literal
    /// sink argument. Includes both the full path and source-object path for
    /// leaf reads (`process.env.SECRET` records `process.env.SECRET` and
    /// `process.env`) so direct source expressions can be matched without an
    /// intermediate local binding.
    pub arg_source_paths: Vec<String>,
    /// Byte offset of the sink span start. Stored as `u32` (not `Span`) so the
    /// struct is bitcode-encodable and can be persisted directly in the cache.
    pub span_start: u32,
    /// Byte offset of the sink span end.
    pub span_end: u32,
    /// The arg-0 URL string literal of a network-shaped call (`fetch`, `axios.*`,
    /// `got`, ...), captured so the `secret-to-network` category (#890) can carry
    /// a destination-host signal on its candidate: `Some(literal)` when the
    /// destination is a static string literal (almost always intended auth, e.g.
    /// the credential's own provider), `None` when it is dynamic (the suspicious
    /// case). `None` for non-call sinks and calls with no arg 0.
    pub url_arg_literal: Option<String>,
    /// URL construction shape for URL-like sink arguments when the extractor can
    /// classify it syntactically. `None` for non-URL sinks and URL expressions
    /// whose shape is not visible at the sink.
    pub url_shape: Option<SecurityUrlShape>,
}

impl SinkSite {
    /// Reconstruct the source span from the stored byte offsets.
    #[must_use]
    pub fn span(&self) -> Span {
        Span::new(self.span_start, self.span_end)
    }
}

/// Env var-name prefixes that frameworks inline into the client bundle by
/// convention. A read of one of these is normal and safe, so it does NOT count
/// as a secret source (issue #890). Shared by the extract layer (so public env
/// vars never become source signals) and the bespoke `client-server-leak` rule.
pub const PUBLIC_ENV_PREFIXES: &[&str] = &[
    "NEXT_PUBLIC_",
    "VITE_",
    "NUXT_PUBLIC_",
    "REACT_APP_",
    "PUBLIC_",
    "GATSBY_",
    "EXPO_PUBLIC_",
    "STORYBOOK_",
];

/// Exact env var names that are public by convention (no prefix).
pub const PUBLIC_ENV_EXACT: &[&str] = &["NODE_ENV"];

/// Env var-name tokens that usually describe public build or deployment
/// metadata rather than secrets. Secret-shaped names win over these tokens.
pub const PUBLIC_ENV_METADATA_TOKENS: &[&str] =
    &["BRANCH", "ENVIRONMENT", "MODE", "REF", "SHA", "TAG"];

/// Env var-name tokens that should keep a variable source-backed even when the
/// name also contains public metadata tokens such as `REF` or `SHA`.
pub const SECRET_ENV_TOKENS: &[&str] = &[
    "AUTH",
    "CREDENTIAL",
    "CREDENTIALS",
    "KEY",
    "PASS",
    "PASSWORD",
    "PRIVATE",
    "SECRET",
    "TOKEN",
];

fn env_name_has_token(name: &str, tokens: &[&str]) -> bool {
    name.split(|ch: char| !ch.is_ascii_alphanumeric())
        .filter(|part| !part.is_empty())
        .any(|part| tokens.contains(&part))
}

/// Whether an env var name is public-by-convention (build-inlined into the
/// client bundle), and therefore not a secret.
#[must_use]
pub fn is_public_env_var(name: &str) -> bool {
    if PUBLIC_ENV_EXACT.contains(&name) || PUBLIC_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
        return true;
    }
    env_name_has_token(name, PUBLIC_ENV_METADATA_TOKENS)
        && !env_name_has_token(name, SECRET_ENV_TOKENS)
}

/// Whether a flattened member path is a PUBLIC env-secret read
/// (`process.env.NEXT_PUBLIC_X`, `import.meta.env.VITE_Y`), which must not be
/// recorded as a secret source. Non-env paths (`req.query.id`) are never public.
#[must_use]
pub fn is_public_env_path(path: &str) -> bool {
    for object in ["process.env.", "import.meta.env."] {
        if let Some(var) = path.strip_prefix(object) {
            return is_public_env_var(var);
        }
    }
    false
}

/// One alias entry tying an exported object's dotted property path to a namespace import.
#[derive(Debug, Clone)]
pub struct NamespaceObjectAlias {
    /// Canonical export name.
    pub via_export_name: String,
    /// Dotted suffix of the property path relative to the export.
    pub suffix: String,
    /// Local name of the namespace import.
    pub namespace_local: String,
}

/// Compute a table of line-start byte offsets from source text.
#[must_use]
#[expect(
    clippy::cast_possible_truncation,
    reason = "source files are practically < 4GB"
)]
pub fn compute_line_offsets(source: &str) -> Vec<u32> {
    let mut offsets = vec![0u32];
    for (i, byte) in source.bytes().enumerate() {
        if byte == b'\n' {
            debug_assert!(
                u32::try_from(i + 1).is_ok(),
                "source file exceeds u32::MAX bytes — line offsets would overflow"
            );
            offsets.push((i + 1) as u32);
        }
    }
    offsets
}

/// Convert a byte offset to a 1-based line number and 0-based byte column.
#[must_use]
#[expect(
    clippy::cast_possible_truncation,
    reason = "line count is bounded by source size"
)]
pub fn byte_offset_to_line_col(line_offsets: &[u32], byte_offset: u32) -> (u32, u32) {
    let line_idx = match line_offsets.binary_search(&byte_offset) {
        Ok(idx) => idx,
        Err(idx) => idx.saturating_sub(1),
    };
    let line = line_idx as u32 + 1;
    let col = byte_offset - line_offsets[line_idx];
    (line, col)
}

/// Complexity metrics for a single function/method/arrow.
#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
pub struct FunctionComplexity {
    /// Function name (or `"<anonymous>"` for unnamed functions/arrows).
    pub name: String,
    /// 1-based line number where the function starts.
    pub line: u32,
    /// 0-based byte column where the function starts.
    pub col: u32,
    /// `McCabe` cyclomatic complexity (1 + decision points).
    pub cyclomatic: u16,
    /// `SonarSource` cognitive complexity (structural + nesting penalty).
    pub cognitive: u16,
    /// Number of lines in the function body.
    pub line_count: u32,
    /// Number of parameters (excluding TypeScript's `this` parameter).
    pub param_count: u8,
    /// Number of React hook calls (`useState` / `useEffect` / `useMemo` /
    /// `useCallback` / custom `use*`) made directly in this function's body.
    /// Non-zero only for React components/hooks; descriptive context surfaced in
    /// the hotspot drill-down, never a tunable threshold (anti-numerology).
    pub react_hook_count: u16,
    /// Maximum JSX element nesting depth reached in this function's body (the
    /// deepest chain of element-inside-element). `0` when the function renders
    /// no JSX. Descriptive context surfaced in the hotspot drill-down, never a
    /// tunable threshold (anti-numerology).
    pub react_jsx_max_depth: u16,
    /// Number of props destructured from this component's first parameter (the
    /// `{ a, b, c }` props object). `0` for non-component functions and for
    /// components taking a bare `props` identifier (not statically countable).
    /// Descriptive context surfaced in the hotspot drill-down, never a tunable
    /// threshold (anti-numerology).
    pub react_prop_count: u16,
    /// Content digest of the function's full-span source slice.
    pub source_hash: Option<String>,
    /// Per-decision-point breakdown explaining WHICH constructs drove the
    /// cyclomatic and cognitive scores. One entry per increment event (an `if`
    /// emits one cyclomatic and one cognitive entry at the same line, because
    /// the two metrics accrue at different granularities). Always computed and
    /// cached; surfaced in JSON only behind `health --complexity-breakdown`.
    pub contributions: Vec<ComplexityContribution>,
}

/// Structural CSS metrics for a single style rule, computed from the parsed CSS
/// syntax tree. A rule is recorded only when it crosses a structural floor (an
/// id selector, a complex selector, a `!important` declaration, or deep
/// nesting), so the vector stays bounded on normal stylesheets.
///
/// Not persisted in the extraction cache: `fallow health` computes these
/// on demand from the CSS source, so there is no `bitcode` derive.
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssRuleMetric {
    /// 1-based line of the rule's first selector.
    pub line: u32,
    /// 1-based column of the rule's first selector.
    pub col: u32,
    /// Specificity component `a` (id selectors), max across the rule's selectors.
    pub specificity_a: u16,
    /// Specificity component `b` (class / attribute / pseudo-class selectors).
    pub specificity_b: u16,
    /// Specificity component `c` (type / pseudo-element selectors).
    pub specificity_c: u16,
    /// Largest selector component count across the rule's selector list.
    pub complexity: u16,
    /// Declaration count in the rule (normal plus `!important`).
    pub declaration_count: u16,
    /// `!important` declaration count in the rule.
    pub important_count: u16,
    /// Style-rule nesting depth (0 = top level).
    pub nesting_depth: u8,
}

/// A style rule's declaration-block fingerprint and location, for cross-file
/// duplicate-block detection. Only rules with a meaningful number of
/// declarations are recorded (small blocks repeat legitimately). Internal
/// staging only: this is consumed in-process by the health layer to build the
/// grouped `duplicate_declaration_blocks` output and is never serialized.
#[derive(Debug, Clone)]
pub struct CssDeclarationBlock {
    /// xxh3 fingerprint over the rule's normalized (sorted, `!important`-tagged)
    /// declaration set.
    pub fingerprint: u64,
    /// 1-based line of the rule's first selector.
    pub line: u32,
    /// Declaration count in the rule (normal plus `!important`).
    pub declaration_count: u16,
}

/// Stylesheet-level structural CSS analytics, computed from the parsed CSS
/// syntax tree. Feeds `fallow health` penalty weights and located findings,
/// never a standalone CSS score.
#[derive(Debug, Clone, Default, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssAnalytics {
    /// Total declarations across every style rule (normal plus `!important`).
    pub total_declarations: u32,
    /// Total `!important` declarations across every style rule.
    pub important_declarations: u32,
    /// Number of style rules.
    pub rule_count: u32,
    /// Number of style rules with no declarations.
    pub empty_rule_count: u32,
    /// Deepest style-rule nesting depth observed (0 = no nesting).
    pub max_nesting_depth: u8,
    /// Rules that crossed the structural floor, in source order. Bounded; see
    /// [`Self::notable_truncated`]. The scalar aggregates above always reflect
    /// the full stylesheet regardless of truncation.
    pub notable_rules: Vec<CssRuleMetric>,
    /// `true` when more rules crossed the structural floor than `notable_rules`
    /// retains (compiled utility CSS can emit thousands of `!important` rules),
    /// so consumers can note that per-rule findings were capped.
    pub notable_truncated: bool,
    /// Distinct color VALUES in the stylesheet, sorted (a palette-size /
    /// design-token-sprawl signal). The parser canonicalizes notation, so the
    /// authored format is NOT preserved: `red`, `#f00`, `#ff0000`, and
    /// `rgb(255,0,0)` all collapse to one entry, and every legacy sRGB notation
    /// renders as hex. Notation-MIXING (hex vs rgb vs hsl) is therefore not
    /// detectable from this set; it would need a separate raw-token pass.
    pub colors: Vec<String>,
    /// Distinct `font-size` declaration values in the stylesheet, sorted.
    pub font_sizes: Vec<String>,
    /// Distinct `z-index` declaration values in the stylesheet, sorted.
    pub z_indexes: Vec<String>,
    /// Distinct `box-shadow` declaration values in the stylesheet, sorted. A
    /// high count signals an uncontrolled shadow scale (design-token sprawl).
    pub box_shadows: Vec<String>,
    /// Distinct `border-radius` declaration values in the stylesheet, sorted.
    pub border_radii: Vec<String>,
    /// Distinct `line-height` declaration values in the stylesheet, sorted.
    pub line_heights: Vec<String>,
    /// Distinct custom properties (`--x`) DEFINED in the stylesheet, sorted.
    pub defined_custom_properties: Vec<String>,
    /// Distinct custom properties REFERENCED via `var()` in the stylesheet.
    pub referenced_custom_properties: Vec<String>,
    /// Distinct `@keyframes` names DEFINED in the stylesheet, sorted.
    pub defined_keyframes: Vec<String>,
    /// Distinct `@keyframes` names REFERENCED via `animation` / `animation-name`.
    pub referenced_keyframes: Vec<String>,
    /// Distinct custom properties REGISTERED via an `@property` rule, sorted.
    pub registered_custom_properties: Vec<String>,
    /// Distinct cascade layers DECLARED (via `@layer a, b;` statements or named
    /// `@layer a { }` blocks), sorted.
    pub declared_layers: Vec<String>,
    /// Distinct cascade layers POPULATED by a named `@layer a { }` block, sorted.
    /// A layer declared but never populated (and not imported into) is a
    /// cleanup candidate.
    pub populated_layers: Vec<String>,
    /// Distinct font families DECLARED by an `@font-face` rule in the stylesheet,
    /// sorted. A declared family referenced by no `font-family` anywhere is a
    /// dead web-font payload (cleanup candidate).
    pub defined_font_faces: Vec<String>,
    /// Distinct font families REFERENCED via `font-family` / `font` in the
    /// stylesheet, sorted (generic keywords like `serif` excluded).
    pub referenced_font_families: Vec<String>,
    /// Per-rule declaration-block fingerprints for rules at or above the minimum
    /// block size, used to detect duplicate declaration blocks across the
    /// project. Internal staging consumed by the health layer; never serialized
    /// (the public output is the grouped `duplicate_declaration_blocks`).
    #[serde(skip)]
    #[cfg_attr(feature = "schema", schemars(skip))]
    pub declaration_blocks: Vec<CssDeclarationBlock>,
}

/// Which complexity metric a [`ComplexityContribution`] adds to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ComplexityMetric {
    /// `McCabe` cyclomatic complexity (independent execution paths).
    Cyclomatic,
    /// `SonarSource` cognitive complexity (structural + nesting penalty).
    Cognitive,
}

/// The syntactic construct that produced a single complexity increment.
///
/// Mirrors `SonarSource` cognitive-complexity vocabulary where it overlaps.
/// `Case` means a `case` label carrying a test; a bare `default` adds nothing
/// to cyclomatic complexity and so produces no contribution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ComplexityContributionKind {
    /// An `if` condition.
    If,
    /// A bare `else` branch (cognitive only).
    Else,
    /// An `else if` continuation (both metrics: cyclomatic +1, cognitive flat
    /// +1 with no nesting penalty).
    ElseIf,
    /// A `?:` conditional (ternary) expression.
    Ternary,
    /// A logical `&&` operator.
    LogicalAnd,
    /// A logical `||` operator.
    LogicalOr,
    /// A `??` nullish-coalescing operator.
    NullishCoalescing,
    /// A logical assignment operator (`&&=`, `||=`, `??=`); cyclomatic only.
    LogicalAssignment,
    /// An optional-chaining link (`?.`); cyclomatic only.
    OptionalChain,
    /// A `for` loop.
    For,
    /// A `for...in` loop.
    ForIn,
    /// A `for...of` loop.
    ForOf,
    /// A `while` loop.
    While,
    /// A `do...while` loop.
    DoWhile,
    /// A `switch` statement (cognitive only; each `case` adds cyclomatic).
    Switch,
    /// A `case` label carrying a test (cyclomatic only).
    Case,
    /// A `catch` clause.
    Catch,
    /// A labeled `break` (cognitive only).
    LabeledBreak,
    /// A labeled `continue` (cognitive only).
    LabeledContinue,
    /// Legacy JSX-depth contribution kind kept for schema compatibility. Current
    /// extraction records JSX nesting as descriptive `react_jsx_max_depth`
    /// context and does not emit this kind for layout depth.
    JsxDepth,
    /// React hook density (cognitive only). One contribution per hook call in a
    /// component body (`useState` / `useEffect` / `useMemo` / `useCallback` /
    /// custom `use*`); a hook-heavy component accrues cognitive load the same way
    /// branching does.
    HookDensity,
    /// React prop count past the comfortable floor (cognitive only). A component
    /// destructuring many props is doing many things; the props beyond the floor
    /// fold into cognitive so a wide-interface component surfaces as a hotspot.
    PropCount,
}

/// A single complexity increment, located at its source line/column.
///
/// `weight` is the amount this construct added to `metric`; for nested
/// cognitive increments `weight == 1 + nesting`. Consumers that render inline
/// (the VS Code editor breakdown) group contributions by `line` and sum the
/// weights, deferring the per-kind list to a hover.
#[derive(Debug, Clone, serde::Serialize, bitcode::Encode, bitcode::Decode)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ComplexityContribution {
    /// 1-based line number where the construct begins.
    pub line: u32,
    /// 0-based byte column where the construct begins.
    pub col: u32,
    /// Which metric this increment contributes to.
    pub metric: ComplexityMetric,
    /// The syntactic construct responsible for the increment.
    pub kind: ComplexityContributionKind,
    /// The amount added to `metric` at this site (`1 + nesting` for nested
    /// cognitive increments, otherwise `1`).
    pub weight: u16,
    /// The nesting depth at the increment site (`0` when not nested). Lets a
    /// consumer explain a cognitive `+3` as "+1 base, +2 nesting".
    pub nesting: u16,
}

/// The kind of feature flag pattern detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub enum FlagUseKind {
    /// `process.env.FEATURE_X` pattern.
    EnvVar,
    /// SDK function call like `useFlag('name')`.
    SdkCall,
    /// Config object access like `config.features.x`.
    ConfigObject,
}

/// A feature flag use site.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct FlagUse {
    /// Flag identifier.
    pub flag_name: String,
    /// Detection kind.
    pub kind: FlagUseKind,
    /// 1-based line number.
    pub line: u32,
    /// 0-based byte column offset.
    pub col: u32,
    /// Start byte offset of the guarded block.
    pub guard_span_start: Option<u32>,
    /// End byte offset of the guarded block.
    pub guard_span_end: Option<u32>,
    /// SDK/provider name.
    pub sdk_name: Option<String>,
}

const _: () = assert!(std::mem::size_of::<FlagUse>() <= 96);

/// A dynamic import with a partially resolved pattern.
#[derive(Debug, Clone)]
pub struct DynamicImportPattern {
    /// Static prefix of the import path (e.g., "./locales/"). May contain glob characters.
    pub prefix: String,
    /// Static suffix of the import path (e.g., ".json"), if any.
    pub suffix: Option<String>,
    /// Source span in the original file.
    pub span: Span,
}

/// Visibility tag from JSDoc/TSDoc comments that suppresses unused-export detection.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
#[repr(u8)]
pub enum VisibilityTag {
    /// No visibility tag present.
    #[default]
    None = 0,
    /// `@public` or `@api public` -- part of the public API surface.
    Public = 1,
    /// `@internal` -- exported for internal use (sister packages, build tools).
    Internal = 2,
    /// `@beta` -- public but unstable, may change without notice.
    Beta = 3,
    /// `@alpha` -- early preview, may change drastically without notice.
    Alpha = 4,
    /// `@expected-unused` -- intentionally unused, should warn when it becomes used.
    ExpectedUnused = 5,
}

impl VisibilityTag {
    /// Whether this tag permanently suppresses unused-export detection.
    /// `ExpectedUnused` is handled separately (conditionally suppresses,
    /// reports stale when the export becomes used).
    pub const fn suppresses_unused(self) -> bool {
        matches!(
            self,
            Self::Public | Self::Internal | Self::Beta | Self::Alpha
        )
    }

    /// For serde `skip_serializing_if`.
    pub fn is_none(&self) -> bool {
        matches!(self, Self::None)
    }
}

/// An export declaration.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExportInfo {
    /// The exported name (named or default).
    pub name: ExportName,
    /// The local binding name, if different from the exported name.
    pub local_name: Option<String>,
    /// Whether this is a type-only export (`export type`).
    pub is_type_only: bool,
    /// Whether this export is registered through a runtime side effect at module load time.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub is_side_effect_used: bool,
    /// Visibility tag from JSDoc/TSDoc comment.
    #[serde(default, skip_serializing_if = "VisibilityTag::is_none")]
    pub visibility: VisibilityTag,
    /// Human-authored reason on `@expected-unused -- <reason>`, when present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expected_unused_reason: Option<String>,
    /// Source span of the export declaration.
    #[serde(serialize_with = "serialize_span")]
    pub span: Span,
    /// Members of this export (for enums, classes, and namespaces).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub members: Vec<MemberInfo>,
    /// The local name of the parent class from `extends` clause, if any.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub super_class: Option<String>,
}

/// Additional heritage metadata for an exported class.
#[derive(
    Debug,
    Clone,
    serde::Serialize,
    serde::Deserialize,
    bitcode::Encode,
    bitcode::Decode,
    PartialEq,
    Eq,
)]
pub struct ClassHeritageInfo {
    /// Export name (`default` for default-exported classes).
    pub export_name: String,
    /// Parent class name from the `extends` clause, if any.
    pub super_class: Option<String>,
    /// Interface names from the class `implements` clause.
    pub implements: Vec<String>,
    /// Typed instance bindings used to resolve member-access chains in external templates.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub instance_bindings: Vec<(String, String)>,
}

/// A module-scope declaration that can be used as a TypeScript type.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct LocalTypeDeclaration {
    /// Local declaration name.
    pub name: String,
    /// Declaration identifier span.
    #[serde(serialize_with = "serialize_span")]
    pub span: Span,
}

/// A reference from an exported symbol's public signature to a type name.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct PublicSignatureTypeReference {
    /// Exported symbol whose signature contains the reference.
    pub export_name: String,
    /// Referenced type name. Qualified names are reduced to their root identifier.
    pub type_name: String,
    /// Reference span.
    #[serde(serialize_with = "serialize_span")]
    pub span: Span,
}

/// A member of an enum, class, or namespace.
#[derive(Debug, Clone, serde::Serialize)]
pub struct MemberInfo {
    /// Member name.
    pub name: String,
    /// The kind of member (enum, class method/property, or namespace member).
    pub kind: MemberKind,
    /// Source span of the member declaration.
    #[serde(serialize_with = "serialize_span")]
    pub span: Span,
    /// Whether this member has decorators (e.g., `@Column()`, `@Inject()`).
    /// Decorated members are used by frameworks at runtime and should not be
    /// flagged as unused class members, unless every decorator on the member
    /// is opted out via `FallowConfig.ignore_decorators`.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub has_decorator: bool,
    /// Full dotted path of each decorator on this member, in source order.
    /// `@step("x")` stores `"step"`; `@ns.foo` stores `"ns.foo"`. Empty for
    /// undecorated members, Angular signal-initializer properties (which set
    /// `has_decorator` without a literal decorator AST node), and decorators
    /// whose expression is not an identifier ladder (the entry is the empty
    /// string in that case, treated as never-matching by the predicate).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub decorator_names: Vec<String>,
    /// True when this is a static class method that returns a fresh instance
    /// of the same class: either via `return new this()` / `return new
    /// <SameClassName>()` in the body's last statement, or via a declared
    /// return type matching the class name. Consumers calling such a static
    /// method receive an instance, so the call result's member accesses are
    /// credited against the class. See issues #346, #387.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub is_instance_returning_static: bool,
    /// True when this is an instance class method whose call result is an
    /// instance of the same class. Qualifies when the declared return type
    /// matches the class name (`setX(): EventBuilder { ... }`) or when the
    /// body's last statement is `return this`. The analyze layer walks fluent
    /// chains (`Class.factory().setX().setY()`) only through methods carrying
    /// this flag, so the chain stops at a non-self-returning method like
    /// `.build()`. See issue #387.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub is_self_returning: bool,
}

/// The kind of member.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, bitcode::Encode, bitcode::Decode)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum MemberKind {
    /// A TypeScript enum member.
    EnumMember,
    /// A class method.
    ClassMethod,
    /// A class property.
    ClassProperty,
    /// A member exported from a TypeScript namespace.
    NamespaceMember,
    /// A member declared by a store object (Pinia `state` / `getters` /
    /// `actions` key, or a setup-store returned key). Cross-graph dead-member
    /// detection: a store member never accessed by any consumer project-wide.
    StoreMember,
}

/// A static member access expression (e.g., `Status.Active`, `MyClass.create()`).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, bitcode::Encode, bitcode::Decode)]
pub struct MemberAccess {
    /// The identifier being accessed (the import name).
    pub object: String,
    /// The member being accessed.
    pub member: String,
}

/// A statically flattenable callee path invoked in a module (e.g. `execSync`,
/// `child_process.exec`, `console.log`). One entry per unique `callee_path`
/// per module; the span anchors the first occurrence. Consumed by the
/// `boundaries.calls.forbidden` detector.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct CalleeUse {
    /// The dotted or bare callee path as written at the call site.
    pub callee_path: String,
    /// Start byte offset of the first call site using this path.
    pub span_start: u32,
}

/// A `"use client"` / `"use server"` directive string written as an expression
/// statement in `program.body` (NOT the leading prologue), so the RSC bundler
/// silently ignores it. One entry per offending occurrence. Consumed by the
/// `misplaced-directive` detector.
#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub struct MisplacedDirectiveSite {
    /// `true` for `"use server"`, `false` for `"use client"`.
    pub is_server: bool,
    /// Start byte offset of the misplaced directive statement.
    pub span_start: u32,
}

/// Which side of a dependency-injection link a call site represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub enum DiRole {
    /// `provide(KEY, value)` / `app.provide(KEY, value)` / `setContext(KEY, value)`.
    Provide,
    /// `inject(KEY)` / `getContext(KEY)`.
    Inject,
}

/// Which framework's DI API a call site came from (drives the finding message).
#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub enum DiFramework {
    /// Vue `provide` / `inject` (from `vue` / `@vue/runtime-core`).
    Vue,
    /// Svelte `setContext` / `getContext` (from `svelte`).
    Svelte,
    /// Angular `inject(TOKEN)` / `@Inject(TOKEN)` (from `@angular/core`),
    /// matched against `{ provide: TOKEN, ... }` provider objects.
    Angular,
}

/// A Vue `provide`/`inject` or Svelte `setContext`/`getContext` call site keyed
/// by an identifier symbol. The `key_local` is resolved at analyze time through
/// the consuming module's import/export tables to a canonical defining-site
/// export key, so a provide and an inject of the same shared symbol unify even
/// across barrel re-exports. Consumed by the `unprovided-inject` detector.
#[derive(Debug, Clone, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub struct DiKeySite {
    /// The key identifier as written at the call site.
    pub key_local: String,
    /// Whether this is a provide or an inject.
    pub role: DiRole,
    /// Which framework's API this came from.
    pub framework: DiFramework,
    /// Start byte offset of the call expression (anchors the finding).
    pub span_start: u32,
}

/// A Vue `<script setup>` `defineProps` declared prop, harvested from the
/// runtime object form (`defineProps({ foo: {...} })`) or the inline TS literal
/// form (`defineProps<{ foo: T }>()`). `used_in_script` / `used_in_template`
/// are set during extraction; the `unused-component-prop` detector flags a prop
/// where neither is true. See `harvest_define_props` in `sfc.rs`.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct ComponentProp {
    /// The declared prop name.
    pub name: String,
    /// The template/script-visible local binding name: the destructure alias for
    /// `const { name: alias } = defineProps()`, otherwise the prop name itself.
    /// A renamed prop is read through this local, so usage must be checked against
    /// it, not the declared name.
    pub local: String,
    /// Start byte offset of the prop declaration (anchors the finding).
    pub span_start: u32,
    /// Whether this prop is referenced in the component's `<script>` (a
    /// destructured local binding with a resolved reference, or a `props.<name>`
    /// member access). For React, this is set-in-body: a resolved reference to the
    /// destructured local anywhere in the component function body.
    pub used_in_script: bool,
    /// Whether this prop name is referenced in the component's `<template>`.
    /// Set by `apply_template_usage` when the template scanner credits the name.
    /// Always false for React (no template; React uses `used_in_script`).
    pub used_in_template: bool,
    /// The enclosing component name. Empty for Vue SFCs (one component per file,
    /// the file stem is the component, set by the detector). For React this is the
    /// component function/arrow name a prop was declared on, so the detector can
    /// emit the right `component_name` and apply the per-component abstain ladder
    /// (a file can declare several React components).
    pub component: String,
    /// React-only: `true` when the destructured prop local is referenced at least
    /// once OUTSIDE a child-JSX attribute value expression (a substantive
    /// consumption: a hook arg, a host-element child, a non-JSX-attr read). When
    /// `used_in_script` is true but this is false, the prop is referenced ONLY as
    /// the root of forwarded child attribute values, i.e. a pure pass-through.
    /// Always `false` for Vue (no forward-vs-consume distinction is computed).
    pub used_outside_forward: bool,
}

/// A Vue `<script setup>` `defineEmits` declared event, harvested from the type
/// tuple-call form (`defineEmits<{ (e: 'foo'): void }>()`), the type object form
/// (`defineEmits<{ foo: [x: string] }>()`), or the runtime array form
/// (`defineEmits(['foo'])`). `used` is set during extraction when the bound emit
/// name is called as `emit('<name>')`. The `unused-component-emit` detector flags
/// an event where `used` is false. See `harvest_define_emits` in `sfc_props.rs`.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
pub struct ComponentEmit {
    /// The declared emit event name.
    pub name: String,
    /// Start byte offset of the emit declaration (anchors the finding).
    pub span_start: u32,
    /// Whether this event is emitted via `emit('<name>')` somewhere in the
    /// component's `<script>`.
    pub used: bool,
}

/// A Svelte custom event dispatched via `dispatch('<name>')`, where `dispatch`
/// is the binding from a `const dispatch = createEventDispatcher()` call. Only
/// literal-first-arg dispatches are recorded; a `dispatch(<nonLiteral>)` sets
/// `ModuleInfo::has_dynamic_dispatch` instead. Consumed by the
/// `unused-svelte-event` detector, which flags an event dispatched here but
/// listened to nowhere project-wide (the cross-file dead-output direction). The
/// span is a byte offset (not an `oxc_span::Span`) so the type round-trips
/// through the bitcode cache directly, mirroring `ComponentEmit::span_start`.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
pub struct DispatchedEvent {
    /// The dispatched event name (the literal first argument).
    pub name: String,
    /// Start byte offset of the `dispatch(...)` call (anchors the finding).
    pub span_start: u32,
}

/// A declared Angular component/directive input, harvested from an `@Input()`
/// decorator or a signal `input()` / `input.required()` / `model()` initializer
/// on an Angular-decorated class. Consumed by the `unused-component-input`
/// detector, which flags an input read nowhere in its own component (neither the
/// template nor the class body). The span is stored as a byte offset (not an
/// `oxc_span::Span`) so the type is cheap to mirror onto the cache, matching
/// `ComponentEmit::span_start`. `ModuleInfo` is not serialized, so no serde
/// attrs are derived here. `bitcode` derives let the type be mirrored directly
/// onto `CachedModule` (the same pattern as `ComponentEmit`).
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
pub struct AngularInputMember {
    /// The declared input name (the property key).
    pub name: String,
    /// Start byte offset of the property key (anchors the finding).
    pub span_start: u32,
}

/// A declared Angular component/directive output, harvested from an `@Output()`
/// decorator or a signal `output()` / `outputFromObservable()` initializer on an
/// Angular-decorated class. Consumed by the `unused-component-output` detector,
/// which flags an output emitted nowhere in its own component. A `model()` is an
/// input and a framework-driven output, so it is recorded ONLY as an input and
/// never appears here (the implicit `update:` emit is framework-managed). The
/// span is a byte offset for the same reason as `AngularInputMember`.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
pub struct AngularOutputMember {
    /// The declared output name (the property key).
    pub name: String,
    /// Start byte offset of the property key (anchors the finding).
    pub span_start: u32,
}

/// A declared Angular `@Component` and its `selector` value(s), harvested from a
/// `@Component({ selector: '...' })` decorator. Consumed by the Angular arm of
/// the `unrendered-component` detector, which flags a component whose every
/// element selector is used in NO template project-wide (and that is not
/// referenced by class name anywhere, e.g. routed / bootstrapped / dynamically
/// rendered). A multi-selector string (`'app-foo, [appBar]'`) is split into the
/// `selectors` list. The span is stored as a byte offset (not an
/// `oxc_span::Span`) so the type round-trips through the bitcode cache directly,
/// mirroring `AngularInputMember::span_start`. `@Directive` is intentionally NOT
/// harvested here (directives have no template render). `ModuleInfo` is not
/// serialized, so no serde attrs are derived.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
pub struct AngularComponentSelector {
    /// The declared selector strings for this component, split on `,`. A purely
    /// element-selector component has only `app-foo`-shaped entries; attribute
    /// (`[appFoo]`) and class (`.foo`) selectors are retained verbatim so the
    /// detector can abstain when ANY non-element selector is present.
    pub selectors: Vec<String>,
    /// Start byte offset of the component class declaration (anchors the
    /// finding).
    pub span_start: u32,
    /// The component class name (used to credit routed / bootstrapped / dynamic
    /// class-name references project-wide).
    pub class_name: String,
}

/// A key returned from a SvelteKit route `load()` function's terminal return
/// object literal. Harvested from `+page.{ts,server.ts,js,server.js}` files
/// exporting a `load` function. Consumed by the `unused-load-data-key` detector,
/// which flags a key read by no consumer. The span is stored as byte offsets
/// (not an `oxc_span::Span`) so the type round-trips through the bitcode cache
/// directly, mirroring `DiKeySite::span_start` / `ComponentEmit::span_start`.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode, PartialEq, Eq)]
pub struct LoadReturnKey {
    /// The returned-object property key name.
    pub name: String,
    /// Start byte offset of the key (anchors the finding).
    pub span_start: u32,
    /// End byte offset of the key.
    pub span_end: u32,
}

/// The syntactic shape of an identified React component definition. Drives the
/// abstain ladder later phases apply: a `forwardRef` / `memo` wrapper whose
/// props come from an imported interface fallow cannot resolve must abstain
/// (ADR-001), not guess.
#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub enum ComponentFunctionKind {
    /// A `function Foo() { return <.../> }` declaration.
    FnDecl,
    /// A `const Foo = () => <.../>` arrow (or function-expression) binding.
    Arrow,
    /// A `const Foo = forwardRef((props, ref) => <.../>)` wrapper.
    ForwardRefWrapper,
    /// A `const Foo = memo((props) => <.../>)` wrapper.
    MemoWrapper,
}

/// An identified React component: a function/arrow whose body returns JSX.
/// Captured by `visit_jsx_element`'s enclosing-component tracking. The
/// `unused-component-prop` (React arm) and complexity-fold phases consume this;
/// the abstain flags keep zero-FP on the cases ADR-001 cannot resolve.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct ComponentFunction {
    /// The component name (the binding or declaration identifier).
    pub name: String,
    /// Start byte offset of the component definition (anchors findings).
    pub span_start: u32,
    /// The syntactic shape of the definition.
    pub kind: ComponentFunctionKind,
    /// Whether the component is exported from its module (a named export, a
    /// `export default`, or re-exported in the same module). Public-API
    /// components abstain in the prop phase.
    pub is_exported: bool,
    /// `true` when the component's props are not statically harvestable: a
    /// rest/spread in the signature (`{ ...rest }`), props passed wholesale to a
    /// hook/helper, or a `forwardRef` / `memo` wrapper whose props come from an
    /// imported interface generic fallow cannot resolve (ADR-001). The prop
    /// phase abstains on the whole component when set.
    pub has_unharvestable_props: bool,
    /// `true` when the component body calls `cloneElement` / `React.cloneElement`.
    /// `cloneElement` injects props by reflection, so the static forward-set is
    /// incomplete; the prop-drilling phase abstains on any chain through this
    /// component (ADR-001, zero-FP).
    pub uses_clone_element: bool,
    /// `true` when the component renders a `*.Provider` member-expression tag
    /// (`<FooContext.Provider>`). A context provider in the subtree means the
    /// drilling may be a deliberate non-context choice (or the prop is about to
    /// be provided); the prop-drilling phase downgrades/abstains.
    pub renders_provider: bool,
    /// `true` when the component passes a function as a child render value
    /// (render-props / children-as-function: `<Foo>{() => ...}</Foo>` or
    /// `<Foo render={() => ...}/>`). The forwarded shape is dynamic; the
    /// prop-drilling phase abstains on chains through this component.
    pub has_children_as_function: bool,
    /// `true` when the component body is pure structural indirection: a single
    /// statement returning exactly one capitalized/member-expression JSX element
    /// (no host wrapper, no extra children, optionally a fragment wrapping a
    /// single element) that forwards props via a bare spread of the component's
    /// own props binding / rest local (`<Child {...props}/>`), with NO named
    /// attributes alongside the spread and NO self-render. The cross-component
    /// `thin-wrapper` phase joins this with hook-density / cyclomatic checks and
    /// the resolved single render edge to flag a component that is a candidate
    /// for inlining. Computed from the component's own AST only, so it caches
    /// byte-identity-safe (ADR-001).
    pub is_pure_passthrough: bool,
}

/// The kind of a React hook call. `Custom` covers any `use*`-named call that is
/// not one of the built-in hooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, bitcode::Encode, bitcode::Decode)]
pub enum HookUseKind {
    /// `useState(...)`.
    UseState,
    /// `useEffect(...)`.
    UseEffect,
    /// `useMemo(...)`.
    UseMemo,
    /// `useCallback(...)`.
    UseCallback,
    /// Any other `use*`-named call (a custom hook).
    Custom,
}

/// A React hook call site inside a component. Consumed by the complexity-fold
/// phase (hook density) and surfaced as descriptive hotspot context.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct HookUse {
    /// The hook kind.
    pub kind: HookUseKind,
    /// The dependency-array arity, recorded ONLY when a literal array is present
    /// at the dependency-array position (`[a, b]` -> `Some(2)`, `[]` ->
    /// `Some(0)`). `None` when the call has no dependency array argument or the
    /// argument is not a literal array (ADR-001: do not guess).
    pub dep_array_arity: Option<u32>,
    /// Start byte offset of the hook call (anchors findings).
    pub span_start: u32,
}

/// A render edge: one component rendering another (a capitalized or
/// member-expression JSX tag). Captured at extraction time with the child's
/// written name; resolution of `child_component_name` to a `FileId`/export is
/// deferred to graph build via the existing import map.
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct RenderEdge {
    /// The name of the component that renders the child (the enclosing
    /// component). Empty when the JSX is not inside an identified component (a
    /// top-level render expression).
    pub parent_component: String,
    /// The rendered child component name as written (`Foo` or the full
    /// member-expression path `Foo.Bar`).
    pub child_component_name: String,
    /// The attribute (prop) names passed at the render site, in source order.
    pub attr_names: Vec<String>,
    /// `true` when the render site contains a JSX spread (`{...x}`), so the
    /// passed-prop set is not statically complete.
    pub has_spread: bool,
    /// The forwarded attributes at this render site: each pairs the child
    /// attribute NAME with the identifier ROOT of its value expression
    /// (`userName={user.name}` -> `{ attr: "userName", root: "user" }`;
    /// `value={x}` -> `{ attr: "value", root: "x" }`). ONLY plain identifier or
    /// member-root access values are recorded (`{x}`, `{x.y}`, `{x.y.z}`); a value
    /// that is a call, an arrow/function, a conditional, a JSX element, or any
    /// other complex expression is NOT recorded here (its root would not be a pure
    /// forward) and sets `has_complex_forward` instead. The prop-drilling chain
    /// walk uses this pairing to map "this component forwards prop P" to "the
    /// child receives it as attribute A".
    pub forward_attrs: Vec<ForwardAttr>,
    /// `true` when at least one attribute value at this render site is a complex
    /// expression (a call, an arrow/function render-prop, a conditional, a JSX
    /// element-as-prop, a template literal, etc.) whose identifier root was NOT
    /// recorded in `forward_attrs`. The prop-drilling phase abstains on a chain
    /// whose forwarded prop flows through such a value (ADR-001, zero-FP).
    pub has_complex_forward: bool,
}

/// One forwarded JSX attribute: the child attribute name plus the identifier
/// root of its value expression. See [`RenderEdge::forward_attrs`].
#[derive(Debug, Clone, bitcode::Encode, bitcode::Decode)]
pub struct ForwardAttr {
    /// The child attribute (prop) name as written (`userName`).
    pub attr: String,
    /// The identifier root of the attribute value expression (`user` for
    /// `userName={user.name}`).
    pub root: String,
}

#[expect(
    clippy::trivially_copy_pass_by_ref,
    reason = "serde serialize_with requires &T"
)]
fn serialize_span<S: serde::Serializer>(span: &Span, serializer: S) -> Result<S::Ok, S::Error> {
    use serde::ser::SerializeMap;
    let mut map = serializer.serialize_map(Some(2))?;
    map.serialize_entry("start", &span.start)?;
    map.serialize_entry("end", &span.end)?;
    map.end()
}

/// Export identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)]
pub enum ExportName {
    /// A named export (e.g., `export const foo`).
    Named(String),
    /// The default export.
    Default,
}

impl ExportName {
    /// Compare against a string without allocating (avoids `to_string()`).
    #[must_use]
    pub fn matches_str(&self, s: &str) -> bool {
        match self {
            Self::Named(n) => n == s,
            Self::Default => s == "default",
        }
    }
}

impl std::fmt::Display for ExportName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Named(n) => write!(f, "{n}"),
            Self::Default => write!(f, "default"),
        }
    }
}

/// An import declaration.
#[derive(Debug, Clone)]
pub struct ImportInfo {
    /// The import specifier (e.g., `./utils` or `react`).
    pub source: String,
    /// How the symbol is imported (named, default, namespace, or side-effect).
    pub imported_name: ImportedName,
    /// The local binding name in the importing module.
    pub local_name: String,
    /// Whether this is a type-only import (`import type`).
    pub is_type_only: bool,
    /// Whether this import originated from a CSS-context.
    pub from_style: bool,
    /// Source span of the import declaration.
    pub span: Span,
    /// Span of the source string literal used by the LSP to highlight the specifier.
    pub source_span: Span,
}

/// How a symbol is imported.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportedName {
    /// A named import (e.g., `import { foo }`).
    Named(String),
    /// A default import (e.g., `import React`).
    Default,
    /// A namespace import (e.g., `import * as utils`).
    Namespace,
    /// A side-effect import (e.g., `import './styles.css'`).
    SideEffect,
}

#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ExportInfo>() == 136);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ImportInfo>() == 96);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ExportName>() == 24);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ImportedName>() == 24);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<MemberAccess>() == 48);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<SinkSite>() == 216);
#[cfg(target_pointer_width = "64")]
const _: () = assert!(std::mem::size_of::<ModuleInfo>() == 1256);

/// A re-export declaration.
#[derive(Debug, Clone)]
pub struct ReExportInfo {
    /// The module being re-exported from.
    pub source: String,
    /// The name imported from the source module (or `*` for star re-exports).
    pub imported_name: String,
    /// The name exported from this module.
    pub exported_name: String,
    /// Whether this is a type-only re-export.
    pub is_type_only: bool,
    /// Source span of the re-export declaration on this module.
    pub span: oxc_span::Span,
}

/// A dynamic `import()` call.
#[derive(Debug, Clone)]
pub struct DynamicImportInfo {
    /// The import specifier.
    pub source: String,
    /// Source span of the `import()` expression.
    pub span: Span,
    /// Names destructured from the dynamic import result.
    /// Non-empty means `const { a, b } = await import(...)` -> Named imports.
    /// Empty means simple `import(...)` or `const x = await import(...)` -> Namespace.
    pub destructured_names: Vec<String>,
    /// The local variable name for `const x = await import(...)`.
    /// Used for namespace import narrowing via member access tracking.
    pub local_name: Option<String>,
    /// True when this dynamic import was synthesised by fallow rather than appearing in user source.
    pub is_speculative: bool,
}

/// A `require()` call.
#[derive(Debug, Clone)]
pub struct RequireCallInfo {
    /// The require specifier.
    pub source: String,
    /// Source span of the `require()` call.
    pub span: Span,
    /// Source span of the specifier string-literal argument (including its
    /// quotes), e.g. the `'./x'` in `require('./x')`. Used to anchor an
    /// `unresolved-import` diagnostic squiggly under the specifier rather than
    /// the `require` keyword. `Span::default()` when the argument is not a
    /// plain string literal.
    pub source_span: Span,
    /// Names destructured from the `require()` result.
    pub destructured_names: Vec<String>,
    /// The local variable name for `const x = require(...)`.
    pub local_name: Option<String>,
}

/// Result of parsing all files, including incremental cache statistics.
pub struct ParseResult {
    /// Extracted module information for all successfully parsed files.
    pub modules: Vec<ModuleInfo>,
    /// Number of files whose parse results were loaded from cache (unchanged).
    pub cache_hits: usize,
    /// Number of files that required a full parse (new or changed).
    pub cache_misses: usize,
    /// Summed wall-clock time of the actual AST parses across all rayon workers.
    pub parse_cpu_ms: f64,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn span() -> Span {
        Span::new(0, 1)
    }

    macro_rules! assert_released {
        ($values:expr) => {{
            assert!($values.is_empty());
            assert_eq!($values.capacity(), 0);
        }};
    }

    #[test]
    fn public_env_var_includes_public_ci_metadata() {
        for name in ["TAG_REF", "GITHUB_SHA", "CI_COMMIT_BRANCH", "APP_MODE"] {
            assert!(is_public_env_var(name), "{name} should be public metadata");
        }
    }

    #[test]
    fn public_env_var_keeps_secret_shaped_names_source_backed() {
        for name in ["GITHUB_TOKEN", "REFRESH_TOKEN", "API_KEY", "SECRET_SHA"] {
            assert!(
                !is_public_env_var(name),
                "{name} should remain secret-shaped"
            );
        }
    }

    #[test]
    fn line_offsets_empty_string() {
        assert_eq!(compute_line_offsets(""), vec![0]);
    }

    #[test]
    #[expect(
        clippy::too_many_lines,
        reason = "exhaustive field-by-field construction + release assertions for every ModuleInfo field"
    )]
    fn release_resolution_payload_drops_copied_vectors_only() {
        let mut module = ModuleInfo {
            file_id: FileId(7),
            exports: vec![ExportInfo {
                name: ExportName::Named("kept".to_string()),
                local_name: None,
                is_type_only: false,
                is_side_effect_used: false,
                visibility: VisibilityTag::None,
                expected_unused_reason: None,
                span: span(),
                members: Vec::new(),
                super_class: None,
            }],
            imports: vec![ImportInfo {
                source: "node:child_process".to_string(),
                imported_name: ImportedName::Default,
                local_name: "childProcess".to_string(),
                is_type_only: false,
                from_style: false,
                span: span(),
                source_span: span(),
            }],
            re_exports: vec![ReExportInfo {
                source: "./kept".to_string(),
                imported_name: "kept".to_string(),
                exported_name: "kept".to_string(),
                is_type_only: false,
                span: span(),
            }],
            dynamic_imports: vec![DynamicImportInfo {
                source: "./dynamic".to_string(),
                span: span(),
                destructured_names: vec!["value".to_string()],
                local_name: None,
                is_speculative: false,
            }],
            dynamic_import_patterns: vec![DynamicImportPattern {
                prefix: "./pages/".to_string(),
                suffix: Some(".tsx".to_string()),
                span: span(),
            }],
            require_calls: vec![RequireCallInfo {
                source: "./required".to_string(),
                span: span(),
                source_span: span(),
                destructured_names: Vec::new(),
                local_name: Some("required".to_string()),
            }],
            package_path_references: vec!["react".to_string()],
            member_accesses: vec![MemberAccess {
                object: "Status".to_string(),
                member: "Active".to_string(),
            }],
            whole_object_uses: vec!["Status".to_string()],
            has_cjs_exports: true,
            has_angular_component_template_url: true,
            content_hash: 42,
            suppressions: Vec::new(),
            unknown_suppression_kinds: Vec::new(),
            unused_import_bindings: vec!["unused".to_string()],
            type_referenced_import_bindings: vec!["TypeOnly".to_string()],
            value_referenced_import_bindings: vec!["Value".to_string()],
            line_offsets: vec![0, 8],
            complexity: vec![FunctionComplexity {
                name: "work".to_string(),
                line: 1,
                col: 0,
                cyclomatic: 2,
                cognitive: 3,
                line_count: 4,
                param_count: 1,
                react_hook_count: 0,
                react_jsx_max_depth: 0,
                react_prop_count: 0,
                source_hash: Some("hash".to_string()),
                contributions: Vec::new(),
            }],
            flag_uses: vec![FlagUse {
                flag_name: "FEATURE_X".to_string(),
                kind: FlagUseKind::EnvVar,
                line: 1,
                col: 0,
                guard_span_start: None,
                guard_span_end: None,
                sdk_name: None,
            }],
            class_heritage: vec![ClassHeritageInfo {
                export_name: "Child".to_string(),
                super_class: Some("Parent".to_string()),
                implements: vec!["Contract".to_string()],
                instance_bindings: Vec::new(),
            }],
            injection_tokens: vec![("TOKEN".to_string(), "Contract".to_string())],
            local_type_declarations: vec![LocalTypeDeclaration {
                name: "Contract".to_string(),
                span: span(),
            }],
            public_signature_type_references: vec![PublicSignatureTypeReference {
                export_name: "kept".to_string(),
                type_name: "Contract".to_string(),
                span: span(),
            }],
            namespace_object_aliases: vec![NamespaceObjectAlias {
                via_export_name: "api".to_string(),
                suffix: "read".to_string(),
                namespace_local: "ns".to_string(),
            }],
            iconify_prefixes: vec!["hero".to_string()],
            iconify_icon_names: vec!["hero-home".to_string()],
            auto_import_candidates: vec!["useState".to_string()],
            directives: vec!["use client".to_string()],
            client_only_dynamic_import_spans: Vec::new(),
            security_sinks: Vec::new(),
            security_sinks_skipped: 1,
            security_unresolved_callee_sites: Vec::new(),
            tainted_bindings: Vec::new(),
            sanitized_sink_args: Vec::new(),
            security_control_sites: Vec::new(),
            callee_uses: Vec::new(),
            misplaced_directives: Vec::new(),
            inline_server_action_exports: Vec::new(),
            di_key_sites: Vec::new(),
            has_dynamic_provide: false,
            referenced_import_bindings: Vec::new(),
            component_props: Vec::new(),
            has_props_attrs_fallthrough: false,
            has_define_expose: false,
            has_define_model: false,
            has_unharvestable_props: false,
            component_emits: Vec::new(),
            angular_inputs: Vec::new(),
            angular_outputs: Vec::new(),
            angular_component_selectors: Vec::new(),
            angular_used_selectors: Vec::new(),
            angular_entry_component_refs: Vec::new(),
            has_dynamic_component_render: false,
            has_unharvestable_emits: false,
            has_dynamic_emit: false,
            has_emit_whole_object_use: false,
            load_return_keys: Vec::new(),
            has_unharvestable_load: false,
            has_load_data_whole_use: false,
            has_page_data_store_whole_use: false,
            component_functions: Vec::new(),
            react_props: Vec::new(),
            hook_uses: Vec::new(),
            render_edges: Vec::new(),
            svelte_dispatched_events: Vec::new(),
            svelte_listened_events: Vec::new(),
            has_dynamic_dispatch: false,
        };

        module.release_resolution_payload();

        assert_eq!(module.file_id, FileId(7));
        assert_eq!(module.content_hash, 42);
        assert_eq!(module.line_offsets, vec![0, 8]);
        assert_eq!(module.imports.len(), 1);
        assert_eq!(module.exports.len(), 1);
        assert_eq!(module.re_exports.len(), 1);
        assert_eq!(module.dynamic_import_patterns.len(), 1);
        assert_eq!(module.member_accesses.len(), 1);
        assert_eq!(module.complexity.len(), 1);
        assert_eq!(module.flag_uses.len(), 1);
        assert_eq!(module.class_heritage.len(), 1);
        assert_eq!(module.injection_tokens.len(), 1);
        assert_eq!(module.local_type_declarations.len(), 1);
        assert_eq!(module.public_signature_type_references.len(), 1);
        assert_eq!(module.iconify_prefixes.len(), 1);
        assert_eq!(module.iconify_icon_names.len(), 1);
        assert_eq!(module.directives.len(), 1);
        assert_eq!(module.security_sinks_skipped, 1);
        assert_released!(module.dynamic_imports);
        assert_released!(module.require_calls);
        assert_released!(module.package_path_references);
        assert_released!(module.whole_object_uses);
        assert_released!(module.unused_import_bindings);
        assert_released!(module.type_referenced_import_bindings);
        assert_released!(module.value_referenced_import_bindings);
        assert_released!(module.namespace_object_aliases);
        assert_released!(module.auto_import_candidates);
        assert_eq!(
            module.referenced_import_bindings,
            vec!["childProcess".to_string()]
        );
    }

    #[test]
    fn sink_shape_bitcode_roundtrip() {
        for shape in [
            SinkShape::Call,
            SinkShape::MemberCall,
            SinkShape::MemberAssign,
            SinkShape::TaggedTemplate,
            SinkShape::JsxAttr,
            SinkShape::NewExpression,
            SinkShape::SecretLiteral,
        ] {
            let encoded = bitcode::encode(&shape);
            let decoded: SinkShape = bitcode::decode(&encoded).expect("decode sink shape");
            assert_eq!(shape, decoded);
        }
    }

    #[test]
    fn sink_arg_kind_bitcode_roundtrip() {
        for kind in [
            SinkArgKind::TemplateWithSubst,
            SinkArgKind::Concat,
            SinkArgKind::Object,
            SinkArgKind::Call,
            SinkArgKind::Literal,
            SinkArgKind::NoArg,
            SinkArgKind::Other,
        ] {
            let encoded = bitcode::encode(&kind);
            let decoded: SinkArgKind = bitcode::decode(&encoded).expect("decode sink arg kind");
            assert_eq!(kind, decoded);
        }
    }

    #[test]
    fn security_url_shape_bitcode_roundtrip() {
        for shape in [
            SecurityUrlShape::FixedOriginDynamicPath,
            SecurityUrlShape::DynamicOrigin,
        ] {
            let encoded = bitcode::encode(&shape);
            let decoded: SecurityUrlShape =
                bitcode::decode(&encoded).expect("decode security url shape");
            assert_eq!(shape, decoded);
        }
    }

    #[test]
    fn sink_site_bitcode_roundtrip() {
        let site = SinkSite {
            sink_shape: SinkShape::MemberAssign,
            callee_path: "el.innerHTML".to_string(),
            arg_index: 0,
            arg_is_non_literal: true,
            arg_kind: SinkArgKind::Other,
            arg_literal: Some(SinkLiteralValue::Integer(511)),
            regex_pattern: None,
            object_properties: vec![SinkObjectProperty {
                key: "origin".to_string(),
                value: SinkLiteralValue::String("*".to_string()),
            }],
            object_property_keys: vec!["origin".to_string()],
            object_property_keys_complete: true,
            arg_idents: vec!["userInput".to_string()],
            arg_source_paths: vec!["req.body.email".to_string(), "req.body".to_string()],
            span_start: 10,
            span_end: 20,
            url_arg_literal: Some("https://api.example.com".to_string()),
            url_shape: Some(SecurityUrlShape::FixedOriginDynamicPath),
        };
        let encoded = bitcode::encode(&site);
        let decoded: SinkSite = bitcode::decode(&encoded).expect("decode sink site");
        assert_eq!(decoded.sink_shape, site.sink_shape);
        assert_eq!(decoded.callee_path, site.callee_path);
        assert_eq!(decoded.arg_index, site.arg_index);
        assert_eq!(decoded.arg_is_non_literal, site.arg_is_non_literal);
        assert_eq!(decoded.arg_kind, site.arg_kind);
        assert_eq!(decoded.arg_literal, site.arg_literal);
        assert_eq!(decoded.object_properties, site.object_properties);
        assert_eq!(decoded.object_property_keys, site.object_property_keys);
        assert_eq!(
            decoded.object_property_keys_complete,
            site.object_property_keys_complete
        );
        assert_eq!(decoded.arg_idents, site.arg_idents);
        assert_eq!(decoded.arg_source_paths, site.arg_source_paths);
        assert_eq!(decoded.url_shape, site.url_shape);
        assert_eq!(decoded.span(), site.span());
    }

    #[test]
    fn line_offsets_single_line_no_newline() {
        assert_eq!(compute_line_offsets("hello"), vec![0]);
    }

    #[test]
    fn line_offsets_single_line_with_newline() {
        assert_eq!(compute_line_offsets("hello\n"), vec![0, 6]);
    }

    #[test]
    fn line_offsets_multiple_lines() {
        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
    }

    #[test]
    fn line_offsets_trailing_newline() {
        assert_eq!(compute_line_offsets("abc\ndef\n"), vec![0, 4, 8]);
    }

    #[test]
    fn line_offsets_consecutive_newlines() {
        assert_eq!(compute_line_offsets("\n\n\n"), vec![0, 1, 2, 3]);
    }

    #[test]
    fn line_offsets_multibyte_utf8() {
        assert_eq!(compute_line_offsets("á\n"), vec![0, 3]);
    }

    #[test]
    fn line_col_offset_zero() {
        let offsets = compute_line_offsets("abc\ndef\nghi");
        let (line, col) = byte_offset_to_line_col(&offsets, 0);
        assert_eq!((line, col), (1, 0));
    }

    #[test]
    fn line_col_middle_of_first_line() {
        let offsets = compute_line_offsets("abc\ndef\nghi");
        let (line, col) = byte_offset_to_line_col(&offsets, 2);
        assert_eq!((line, col), (1, 2));
    }

    #[test]
    fn line_col_start_of_second_line() {
        let offsets = compute_line_offsets("abc\ndef\nghi");
        let (line, col) = byte_offset_to_line_col(&offsets, 4);
        assert_eq!((line, col), (2, 0));
    }

    #[test]
    fn line_col_middle_of_second_line() {
        let offsets = compute_line_offsets("abc\ndef\nghi");
        let (line, col) = byte_offset_to_line_col(&offsets, 5);
        assert_eq!((line, col), (2, 1));
    }

    #[test]
    fn line_col_start_of_third_line() {
        let offsets = compute_line_offsets("abc\ndef\nghi");
        let (line, col) = byte_offset_to_line_col(&offsets, 8);
        assert_eq!((line, col), (3, 0));
    }

    #[test]
    fn line_col_end_of_file() {
        let offsets = compute_line_offsets("abc\ndef\nghi");
        let (line, col) = byte_offset_to_line_col(&offsets, 10);
        assert_eq!((line, col), (3, 2));
    }

    #[test]
    fn line_col_single_line() {
        let offsets = compute_line_offsets("hello");
        let (line, col) = byte_offset_to_line_col(&offsets, 3);
        assert_eq!((line, col), (1, 3));
    }

    #[test]
    fn line_col_at_newline_byte() {
        let offsets = compute_line_offsets("abc\ndef");
        let (line, col) = byte_offset_to_line_col(&offsets, 3);
        assert_eq!((line, col), (1, 3));
    }

    #[test]
    fn export_name_matches_str_named() {
        let name = ExportName::Named("foo".to_string());
        assert!(name.matches_str("foo"));
        assert!(!name.matches_str("bar"));
        assert!(!name.matches_str("default"));
    }

    #[test]
    fn export_name_matches_str_default() {
        let name = ExportName::Default;
        assert!(name.matches_str("default"));
        assert!(!name.matches_str("foo"));
    }

    #[test]
    fn export_name_display_named() {
        let name = ExportName::Named("myExport".to_string());
        assert_eq!(name.to_string(), "myExport");
    }

    #[test]
    fn export_name_display_default() {
        let name = ExportName::Default;
        assert_eq!(name.to_string(), "default");
    }

    #[test]
    fn export_name_equality_named() {
        let a = ExportName::Named("foo".to_string());
        let b = ExportName::Named("foo".to_string());
        let c = ExportName::Named("bar".to_string());
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn export_name_equality_default() {
        let a = ExportName::Default;
        let b = ExportName::Default;
        assert_eq!(a, b);
    }

    #[test]
    fn export_name_named_not_equal_to_default() {
        let named = ExportName::Named("default".to_string());
        let default = ExportName::Default;
        assert_ne!(named, default);
    }

    #[test]
    fn export_name_hash_consistency() {
        use std::collections::hash_map::DefaultHasher;
        use std::hash::{Hash, Hasher};

        let mut h1 = DefaultHasher::new();
        let mut h2 = DefaultHasher::new();
        ExportName::Named("foo".to_string()).hash(&mut h1);
        ExportName::Named("foo".to_string()).hash(&mut h2);
        assert_eq!(h1.finish(), h2.finish());
    }

    #[test]
    fn export_name_matches_str_empty_string() {
        let name = ExportName::Named(String::new());
        assert!(name.matches_str(""));
        assert!(!name.matches_str("foo"));
    }

    #[test]
    fn export_name_default_does_not_match_empty() {
        let name = ExportName::Default;
        assert!(!name.matches_str(""));
    }

    #[test]
    fn imported_name_equality() {
        assert_eq!(
            ImportedName::Named("foo".to_string()),
            ImportedName::Named("foo".to_string())
        );
        assert_ne!(
            ImportedName::Named("foo".to_string()),
            ImportedName::Named("bar".to_string())
        );
        assert_eq!(ImportedName::Default, ImportedName::Default);
        assert_eq!(ImportedName::Namespace, ImportedName::Namespace);
        assert_eq!(ImportedName::SideEffect, ImportedName::SideEffect);
        assert_ne!(ImportedName::Default, ImportedName::Namespace);
        assert_ne!(
            ImportedName::Named("default".to_string()),
            ImportedName::Default
        );
    }

    #[test]
    fn member_kind_equality() {
        assert_eq!(MemberKind::EnumMember, MemberKind::EnumMember);
        assert_eq!(MemberKind::ClassMethod, MemberKind::ClassMethod);
        assert_eq!(MemberKind::ClassProperty, MemberKind::ClassProperty);
        assert_eq!(MemberKind::NamespaceMember, MemberKind::NamespaceMember);
        assert_ne!(MemberKind::EnumMember, MemberKind::ClassMethod);
        assert_ne!(MemberKind::ClassMethod, MemberKind::ClassProperty);
        assert_ne!(MemberKind::NamespaceMember, MemberKind::EnumMember);
    }

    #[test]
    fn member_kind_bitcode_roundtrip() {
        let kinds = [
            MemberKind::EnumMember,
            MemberKind::ClassMethod,
            MemberKind::ClassProperty,
            MemberKind::NamespaceMember,
        ];
        for kind in &kinds {
            let bytes = bitcode::encode(kind);
            let decoded: MemberKind = bitcode::decode(&bytes).unwrap();
            assert_eq!(&decoded, kind);
        }
    }

    #[test]
    fn member_access_bitcode_roundtrip() {
        let access = MemberAccess {
            object: "Status".to_string(),
            member: "Active".to_string(),
        };
        let bytes = bitcode::encode(&access);
        let decoded: MemberAccess = bitcode::decode(&bytes).unwrap();
        assert_eq!(decoded.object, "Status");
        assert_eq!(decoded.member, "Active");
    }

    #[test]
    fn line_offsets_crlf_only_counts_lf() {
        let offsets = compute_line_offsets("ab\r\ncd");
        assert_eq!(offsets, vec![0, 4]);
    }

    #[test]
    fn line_col_empty_file_offset_zero() {
        let offsets = compute_line_offsets("");
        let (line, col) = byte_offset_to_line_col(&offsets, 0);
        assert_eq!((line, col), (1, 0));
    }

    #[test]
    fn function_complexity_bitcode_roundtrip() {
        let fc = FunctionComplexity {
            name: "processData".to_string(),
            line: 42,
            col: 4,
            cyclomatic: 15,
            cognitive: 25,
            line_count: 80,
            param_count: 3,
            react_hook_count: 0,
            react_jsx_max_depth: 0,
            react_prop_count: 0,
            source_hash: Some("0123456789abcdef".to_string()),
            contributions: vec![
                ComplexityContribution {
                    line: 43,
                    col: 8,
                    metric: ComplexityMetric::Cyclomatic,
                    kind: ComplexityContributionKind::If,
                    weight: 1,
                    nesting: 0,
                },
                ComplexityContribution {
                    line: 45,
                    col: 12,
                    metric: ComplexityMetric::Cognitive,
                    kind: ComplexityContributionKind::ElseIf,
                    weight: 3,
                    nesting: 2,
                },
            ],
        };
        let bytes = bitcode::encode(&fc);
        let decoded: FunctionComplexity = bitcode::decode(&bytes).unwrap();
        assert_eq!(decoded.name, "processData");
        assert_eq!(decoded.line, 42);
        assert_eq!(decoded.col, 4);
        assert_eq!(decoded.cyclomatic, 15);
        assert_eq!(decoded.cognitive, 25);
        assert_eq!(decoded.line_count, 80);
        assert_eq!(decoded.source_hash.as_deref(), Some("0123456789abcdef"));
        assert_eq!(decoded.contributions.len(), 2);
        assert_eq!(
            decoded.contributions[1].kind,
            ComplexityContributionKind::ElseIf
        );
        assert_eq!(decoded.contributions[1].weight, 3);
        assert_eq!(decoded.contributions[1].nesting, 2);
        assert_eq!(decoded.contributions[1].metric, ComplexityMetric::Cognitive);
    }
}