lanekeep-config 0.8.1

Configuration loading and canonicalized hashing for lanekeep.
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
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
3563
3564
3565
3566
3567
3568
3569
3570
3571
3572
3573
3574
3575
3576
3577
3578
3579
3580
3581
3582
3583
3584
3585
3586
3587
3588
3589
3590
3591
3592
3593
3594
3595
3596
3597
3598
3599
3600
3601
3602
3603
3604
3605
3606
3607
3608
3609
3610
3611
3612
3613
3614
3615
3616
3617
3618
3619
3620
3621
3622
3623
3624
3625
3626
3627
3628
3629
3630
3631
3632
3633
3634
3635
3636
3637
3638
3639
3640
3641
3642
3643
3644
3645
3646
3647
3648
3649
3650
3651
3652
3653
3654
3655
3656
3657
3658
3659
3660
3661
3662
3663
3664
3665
3666
3667
3668
3669
3670
3671
3672
3673
3674
3675
3676
3677
3678
3679
3680
3681
3682
3683
3684
3685
3686
3687
3688
3689
3690
3691
3692
3693
3694
3695
3696
3697
3698
3699
3700
3701
3702
3703
3704
3705
3706
3707
3708
3709
3710
3711
3712
3713
3714
3715
3716
3717
3718
3719
3720
3721
3722
3723
3724
3725
3726
3727
3728
3729
3730
3731
3732
3733
3734
3735
3736
3737
3738
3739
3740
3741
3742
3743
3744
3745
3746
3747
3748
3749
3750
3751
3752
3753
3754
3755
3756
3757
3758
3759
3760
3761
3762
3763
3764
3765
3766
3767
3768
3769
3770
3771
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
4962
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
4975
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
5047
5048
5049
5050
5051
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
5063
5064
5065
5066
5067
5068
5069
5070
5071
5072
5073
5074
5075
5076
5077
5078
5079
5080
5081
5082
5083
5084
5085
5086
5087
5088
5089
5090
5091
5092
5093
5094
5095
5096
5097
5098
5099
5100
5101
5102
5103
5104
5105
5106
5107
5108
5109
5110
5111
5112
5113
5114
5115
5116
5117
5118
5119
5120
5121
5122
5123
5124
5125
5126
5127
5128
5129
5130
5131
5132
5133
5134
5135
5136
5137
5138
5139
5140
5141
5142
5143
5144
5145
5146
5147
5148
5149
5150
5151
5152
5153
5154
5155
5156
5157
5158
5159
5160
5161
5162
5163
5164
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
5182
5183
5184
5185
5186
5187
5188
5189
5190
5191
5192
5193
5194
5195
5196
5197
5198
5199
5200
5201
5202
5203
5204
5205
5206
5207
5208
5209
5210
5211
5212
5213
5214
5215
5216
5217
5218
5219
5220
5221
5222
5223
5224
5225
5226
5227
5228
5229
5230
5231
5232
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
5244
5245
5246
5247
5248
5249
5250
5251
5252
5253
5254
5255
5256
5257
5258
5259
5260
5261
5262
5263
5264
5265
5266
5267
5268
5269
5270
5271
5272
5273
5274
5275
5276
5277
5278
5279
5280
5281
5282
5283
5284
5285
5286
5287
5288
5289
5290
5291
5292
5293
5294
5295
5296
5297
5298
5299
5300
5301
5302
5303
5304
5305
5306
5307
5308
5309
5310
5311
5312
5313
5314
5315
5316
5317
5318
5319
5320
5321
5322
5323
5324
5325
5326
5327
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
5366
5367
5368
5369
5370
5371
5372
5373
5374
5375
5376
5377
5378
5379
5380
5381
5382
5383
5384
5385
5386
5387
5388
5389
5390
5391
5392
5393
5394
5395
5396
5397
5398
5399
5400
5401
5402
5403
5404
5405
5406
5407
5408
5409
5410
5411
5412
5413
5414
5415
5416
5417
5418
5419
5420
5421
5422
5423
5424
5425
5426
5427
5428
5429
5430
5431
5432
5433
5434
5435
5436
5437
5438
5439
5440
5441
5442
5443
5444
5445
5446
5447
5448
5449
5450
5451
5452
5453
5454
5455
5456
5457
5458
5459
5460
5461
5462
5463
5464
5465
5466
5467
5468
5469
5470
5471
5472
5473
5474
5475
5476
5477
5478
5479
5480
5481
5482
5483
5484
5485
5486
//! Configuration loading and canonicalized hashing for lanekeep.
//!
//! Loads `lanekeep.config.ts` or `lanekeep.json`, resolves the rule graph, and derives the
//! hashes feeding the cache key.
//!
//! # How the config is read
//!
//! A `lanekeep.config.ts` is a TypeScript module, so reading it means running it. A
//! synthetic entry module imports the config's default export into a global, and a second
//! evaluation hands back `JSON.stringify` of the parts that are data.
//!
//! Going through JSON rather than reaching into engine values is deliberate. It keeps
//! every value crossing the boundary plainly serializable, it makes the whole extraction
//! one testable string, and it sidesteps threading engine lifetimes through this crate.
//!
//! The one thing it cannot carry is a function, and `check` is a function. So the
//! extraction separately records whether each rule has a callable `check` and `reduce`.
//! Without that, a rule whose handler was misspelled would load cleanly and silently never
//! fire — the worst failure this tool can have, because it looks exactly like passing.
//!
//! A `lanekeep.json` is not a program, and is read as what it is: `src/json.rs` parses,
//! validates and resolves it in Rust, and only a rule reference naming a *TypeScript* rule
//! reaches the sandbox — because that rule's own declaration is the only place its `id`,
//! `query` and `card` exist. The note above `entry_source` in this file records what
//! that cost.

use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration;

use lanekeep_core::{Examples, Gates, Namespace, RuleCard, RuleId, Severity};
use lanekeep_js::{Limits, ResolveError, RuleRoot, RunClock, Sandbox};
use lanekeep_wasm::{RuleSet, WasmEngine, WasmRuntime};
use serde::Deserialize;
use thiserror::Error;

/// A 32-byte content hash.
pub type Hash = [u8; 32];

mod json;

pub use json::{ResolvedRule, RuleReference};

/// Render a hash the way it appears in diagnostics and cache paths.
#[must_use]
pub fn hex(hash: &Hash) -> String {
    use std::fmt::Write as _;
    hash.iter()
        .fold(String::with_capacity(64), |mut out, byte| {
            let _ = write!(out, "{byte:02x}");
            out
        })
}

/// A rule as the config declares it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleSpec {
    /// Zero-based position in the config's `rules` array.
    ///
    /// This is how the engine reaches the handler: the rule object lives in the loaded
    /// config, and indexing into it is what lets a function cross the boundary without
    /// ever being extracted as a value.
    ///
    /// **It is the position in the config and the position in the entry module's array, and
    /// those have to stay one number.** A component-backed rule has no entry in that array —
    /// its handlers are not JavaScript — so `json::rules_module` emits a `null` placeholder to
    /// hold its place rather than closing the gap. Numbering the array separately would leave
    /// every rule after a component pointing at its neighbor's handler: the call succeeds, and
    /// the violations are attributed to the wrong rule.
    ///
    /// **It is therefore not a position in [`Config::rules`], and is not unique across it.** A
    /// component hosts a list of rules, so one entry in the config's array — one placeholder —
    /// can produce several `RuleSpec`s, and every one of them carries the position of the
    /// *reference*. Which of the component's own rules a spec is lives on
    /// [`ComponentRule::index`], and the two numberings answer different questions: this one
    /// names a slot in the entry module, that one names a rule inside a compiled program.
    pub index: usize,
    /// Namespaced identifier.
    pub id: RuleId,
    /// Which languages' grammars the query compiles against, and which files the rule runs on.
    ///
    /// A rule runs on a file only when the file's own language is one of these, and it is
    /// then parsed with *that* grammar. Running every rule against every file with a single
    /// declared grammar is what used to turn a `.tsx` file into a tree of `ERROR` nodes —
    /// silently, since a query simply matches nothing inside one.
    pub languages: Vec<String>,
    /// Severity as the rule declares it, before config overrides.
    pub severity: Severity,
    /// The rule card.
    pub card: RuleCard,
    /// Language id → query source, one entry per language the rule targets.
    ///
    /// The exact cover — every declared language present, nothing extra — is enforced by
    /// `build_rule`; the engine compiles each entry against that language's grammar.
    pub queries: BTreeMap<String, String>,
    /// Pre-parse gates.
    pub gates: Gates,
    /// A per-invocation budget overriding the default.
    pub timeout: Option<Duration>,
    /// Whether the rule has a `reduce` phase.
    pub has_reduce: bool,
    /// The compiled component this rule's handlers live in, or `None` for a TypeScript rule.
    ///
    /// **This is what sends a rule to one engine or the other.** `lanekeep-engine` runs a rule
    /// with `None` through `lanekeep-js` and a rule with `Some` through `lanekeep-wasm`, in the
    /// same run over the same corpus — the decision is a property of the rule and is made here,
    /// where a rule is described, rather than by the engine guessing from anything else.
    ///
    /// Every other field of a component-backed rule is the component's own answer to
    /// `metadata`, read once here at config load. There is no config syntax carrying an `id`, a
    /// `query` or a card beside a `.wasm` reference, and there deliberately never was: a second
    /// description of a rule is drift that has to be kept in step with the first.
    pub component: Option<ComponentRule>,
}

/// Where a component-backed rule's code is, and what it is configured with.
///
/// **One value rather than two fields, because the two cannot be independently true.** A rule
/// backed by a component is always configured — with `null` when the config named it with no
/// options, which is the shape `crates/lanekeep-wasm/wit/world.wit` declares so that a guest
/// has one code path rather than two — and a rule that is not backed by a component has no
/// `configure` to reach. Splitting them would make "a component with nothing to configure it
/// with" and "options belonging to no component" representable, and both are states nothing
/// downstream knows what to do with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentRule {
    /// Where the bytes came from: a path confined to the rules root, or `lanekeep/<name>` for
    /// a built-in embedded in the binary.
    ///
    /// Kept for diagnostics and for the order [`ComponentBytes`] are folded into
    /// `ruleset_hash` in. Nothing reads the file again — see [`ComponentRule::bytes`].
    ///
    /// **A built-in's is a specifier rather than a path, and cannot collide with one.** A
    /// confined path is absolute, because `RuleRoot::confine` canonicalizes; `lanekeep/no-unwrap`
    /// is relative. So a project that happens to have `lanekeep/no-unwrap.wasm` inside its rules
    /// root sorts and dedups separately, as two different rules should.
    pub path: PathBuf,
    /// Which of the component's rules this is: an index into what its `rules` export lists.
    ///
    /// **A component hosts a list, so naming one is a position and not merely a file.** Every
    /// export but `rules` takes this index, so it is what tells `configure`, `metadata`,
    /// `check` and `reduce` which rule they are being asked about. A component hosting one
    /// rule is `0`, which is what every reference resolved to before a component could host
    /// more than one.
    ///
    /// It is folded into `ruleset_hash` beside the component's identity rather than being
    /// carried only for execution: two rules of one component share every byte of code, so the
    /// index is the whole of what distinguishes the programs they run. Without it, "rule 0 and
    /// rule 1 of this component" and "rule 0 of this component, twice" are one cache key.
    pub index: u32,
    /// What `configure` is called with, as JSON — `"null"` for a rule named with no options.
    ///
    /// A string rather than a `serde_json::Value` because that is what crosses the boundary:
    /// a component cannot close over a host-supplied value the way a JavaScript factory does,
    /// so its options arrive as data. Serializing once here also fixes the bytes, which
    /// matters because they are what every worker's `configure` is handed.
    pub options: String,
    /// The component itself, read exactly once.
    ///
    /// **The rule that was described has to be the rule that runs.** The bytes used to be read
    /// three times in a run — once to ask the component what it is, once to hash it, once to
    /// execute it — and a file that changed between those reads would give metadata from one,
    /// a cache key from a second and handlers from a third, with nothing to notice. That is
    /// the same property the TypeScript path already has for free: `hash_ruleset` folds what
    /// `RuleLoader` actually consumed, not a second read of the same paths.
    ///
    /// So they are read once, here, and carried: `metadata` is read from them, `ruleset_hash`
    /// folds them, and `lanekeep-engine` loads the component from them rather than from the
    /// path beside them.
    ///
    /// Behind an [`std::sync::Arc`], because a `RuleSpec` is cloned per rule when the engine
    /// prepares and a per-rule copy of a megabyte is a cost with nothing to buy it.
    pub bytes: ComponentBytes,
    /// The component's sidecar source map, or `None` for one that ships without one.
    ///
    /// **Carried beside the bytes for the same reason they are carried at all**: the component
    /// that was described has to be the component that runs, and the map is only correct for the
    /// bundle it was generated from. Reading it a second time later, from a path, would let a
    /// file that changed in between explain the positions of a program it does not describe.
    ///
    /// **Not a `ruleset_hash` input, and that is a decision rather than an omission.** A map
    /// changes exactly one thing: where a *thrown* rule error is reported. It cannot move a
    /// violation — a violation's position comes from the parse tree by way of a node handle — and
    /// every failure it touches cancels the run, so no cache entry is ever written by a run whose
    /// output it affected. Two runs differing only in their maps produce byte-identical output for
    /// every file that completes.
    pub source_map: Option<ComponentBytes>,
    /// Whether these exact bytes were folded into the `ruleset_hash` of the `Config` this
    /// rule sits in.
    ///
    /// `true` for every `ComponentRule` `describe_components` builds — the only constructor
    /// in this crate, and its output is exactly what `build` folds into `ruleset_hash` a few
    /// lines later, over the very `rules` this value ends up attached to. Private, so nothing
    /// outside this crate can construct one that claims coverage it does not have: the only
    /// other way to get a `ComponentRule` is [`ComponentRule::uncounted`], which is honest
    /// about the alternative.
    ///
    /// This is `Engine::caching`'s one input for the question its field doc calls "asking
    /// where the field came from" — a `RuleSpec` an embedder or a test attaches after
    /// `lanekeep_config::load` returns carries a component whose bytes reached no hash, and
    /// `lanekeep-engine` reads this flag to refuse the cache for exactly that run.
    counted_in_ruleset_hash: bool,
}

impl ComponentRule {
    /// Whether these bytes are folded into the `ruleset_hash` of the `Config` they arrived
    /// with — see the field.
    #[must_use]
    pub const fn counted_in_ruleset_hash(&self) -> bool {
        self.counted_in_ruleset_hash
    }

    /// Build a `ComponentRule` outside `lanekeep_config::load`.
    ///
    /// **Whatever this produces is not folded into any `Config`'s `ruleset_hash`,** because
    /// nothing here computes one — that happens exactly once, inside `load`, over whichever
    /// rules were in `Config.rules` at the moment it returned. This is for an embedder, or a
    /// test, that attaches a component to a `RuleSpec` afterward: `lanekeep-engine`'s own
    /// component tests are exactly that, which is why `Engine::caching` refuses the cache for
    /// a run carrying one of these.
    #[must_use]
    pub fn uncounted(
        path: PathBuf,
        index: u32,
        options: String,
        bytes: impl Into<ComponentBytes>,
    ) -> Self {
        Self {
            path,
            index,
            options,
            bytes: bytes.into(),
            // No map, because there is no honest way to take one here: a caller attaching a
            // component after `load` has returned has no component-to-map pairing this crate
            // could check, and a mispaired map reports arbitrary lines of real files. The cost
            // is a stack in the space the guest was compiled to, which is what an embedder
            // driving a component directly already gets.
            source_map: None,
            counted_in_ruleset_hash: false,
        }
    }
}

/// A component's bytes, shared rather than copied.
///
/// A newtype for one reason: [`RuleSpec`] derives `Debug`, and a bare byte slice renders every
/// byte of a forty-kilobyte artifact into any assertion message that prints a rule. This
/// prints what a reader can act on — how many bytes there are — and the equality that
/// `Config`'s own `PartialEq` needs is still over the content.
#[derive(Clone, PartialEq, Eq)]
pub struct ComponentBytes(std::sync::Arc<[u8]>);

impl ComponentBytes {
    /// The bytes.
    #[must_use]
    pub fn as_slice(&self) -> &[u8] {
        &self.0
    }
}

impl From<Vec<u8>> for ComponentBytes {
    fn from(bytes: Vec<u8>) -> Self {
        Self(bytes.into())
    }
}

impl std::fmt::Debug for ComponentBytes {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ComponentBytes")
            .field("len", &self.0.len())
            .finish()
    }
}

/// Policy for suppression directives: which shapes of valid directive a project accepts.
///
/// All three keys default off, so an existing config changes nothing. A policy violation is
/// reported as an ordinary `lanekeep/suppression` violation at the directive's own position;
/// the directive still silences what it names.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SuppressionPolicy {
    /// A valid directive with no `expires:` is reported.
    pub require_expiry: bool,
    /// An expiry more than this many days after the run's `today` is reported.
    pub max_expiry_days: Option<u32>,
    /// Any whole-file directive is reported.
    pub forbid_file_scope: bool,
}

/// A loaded, validated configuration.
#[expect(
    clippy::struct_field_names,
    reason = "`ruleset_hash` and `config_hash` are the names docs/architecture.md §8.1 \
              gives these two cache-key inputs. Renaming them to satisfy the lint would \
              make the code and the specification disagree about the same thing, which \
              costs more than the repetition saves."
)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
    /// Globs selecting files to check.
    pub include: Vec<String>,
    /// Globs excluding files from the selection.
    pub exclude: Vec<String>,
    /// Rules, in the order the config listed them.
    pub rules: Vec<RuleSpec>,
    /// Budgets, with defaults filled in.
    pub limits: Limits,
    /// The project's policy for which shapes of valid directive it accepts.
    pub suppressions: SuppressionPolicy,
    /// Hash of every module in the rule import graph.
    pub ruleset_hash: Hash,
    /// Hash of the configuration values.
    pub config_hash: Hash,
}

/// Why a configuration could not be loaded.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ConfigError {
    /// The config file does not exist or sits outside the project.
    #[error("cannot load config `{path}`: {detail}")]
    Unreadable {
        /// The path as given.
        path: String,
        /// What went wrong.
        detail: String,
    },

    /// The config module threw, failed to parse, or breached a limit.
    #[error("config `{path}` failed to evaluate\n{detail}")]
    Evaluation {
        /// The path as given.
        path: String,
        /// The sandbox's account of it.
        detail: String,
    },

    /// The config evaluated but is not shaped like a config.
    #[error("config `{path}` is not valid: {detail}")]
    Shape {
        /// The path as given.
        path: String,
        /// What is wrong.
        detail: String,
    },

    /// A rule in the config is not usable.
    #[error("rule {position} in `{path}` is not valid: {detail}")]
    Rule {
        /// One-based position in the `rules` array, so an unnamed rule can still be found.
        position: usize,
        /// The path as given.
        path: String,
        /// What is wrong.
        detail: String,
    },
}

/// The shape `JSON.stringify` hands back. Deliberately permissive — every field is checked
/// afterwards, so a malformed config produces a diagnostic naming the field rather than a
/// deserialization error naming a line of JSON the user never wrote.
#[derive(Debug, Deserialize)]
struct RawConfig {
    #[serde(default)]
    include: Vec<String>,
    #[serde(default)]
    exclude: Vec<String>,
    #[serde(default)]
    namespaces: Vec<String>,
    #[serde(default)]
    severity: BTreeMap<String, String>,
    #[serde(default)]
    timeouts: RawTimeouts,
    #[serde(default)]
    suppressions: RawSuppressions,
    #[serde(default)]
    rules: Vec<RawRule>,
}

#[derive(Debug, Default, Deserialize)]
struct RawTimeouts {
    rule: Option<u64>,
    global: Option<u64>,
}

/// The `suppressions` block as written — permissive, like [`RawTimeouts`], because the
/// validation happens in `build`, where a malformed value becomes a diagnostic naming the
/// field rather than a deserialization error naming a line of JSON the user never wrote.
///
/// Keys are camelCase in both config formats, matching the schema and the TypeScript
/// interface `lanekeep-types-gen` renders.
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawSuppressions {
    #[serde(default)]
    require_expiry: bool,
    #[serde(default)]
    max_expiry_days: Option<u32>,
    #[serde(default)]
    forbid_file_scope: bool,
}

#[derive(Debug, Deserialize)]
struct RawRule {
    id: Option<String>,
    language: Option<RawLanguages>,
    severity: Option<String>,
    card: Option<RawCard>,
    query: Option<RawQueries>,
    #[serde(default)]
    gates: Gates,
    timeout: Option<u64>,
    has_check: bool,
    has_reduce: bool,
}

/// `language: 'tsx'` and `language: ['typescript', 'tsx']` are both ordinary things to write.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawLanguages {
    One(String),
    Many(Vec<String>),
}

impl RawLanguages {
    fn into_vec(self) -> Vec<String> {
        match self {
            Self::One(language) => vec![language],
            Self::Many(languages) => languages,
        }
    }
}

/// The tree-sitter query a rule declares, in either of the two authoring shapes.
///
/// `One` is the sugar: one query string for every language the rule targets. `Many` maps a
/// language to its own query, which is what lets one rule span grammars that do not share
/// node vocabulary. Both normalize to one entry per declared language in `build_rule`, where
/// the exact cover is enforced.
///
/// Deserialized by hand rather than with `#[serde(untagged)]`, because untagged buffers the
/// value and, on a mismatch, reports `data did not match any variant of untagged enum
/// RawQueries` — a message naming a private Rust type, with the field and the expected shape
/// gone. A `query` is the field an author gets wrong most now that it holds two shapes, so
/// its refusal has to say what a query may be.
#[derive(Debug, Clone, PartialEq, Eq)]
enum RawQueries {
    One(String),
    Many(BTreeMap<String, String>),
}

impl<'de> Deserialize<'de> for RawQueries {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        use serde::de::Error;

        match serde_json::Value::deserialize(deserializer)? {
            serde_json::Value::String(query) => Ok(Self::One(query)),
            serde_json::Value::Object(entries) => {
                let mut queries = BTreeMap::new();
                for (language, query) in entries {
                    let serde_json::Value::String(query) = query else {
                        return Err(D::Error::custom(format!(
                            "`query` for `{language}` must be a string, not {}",
                            json_kind(&query)
                        )));
                    };
                    queries.insert(language, query);
                }
                Ok(Self::Many(queries))
            }
            other => Err(D::Error::custom(format!(
                "`query` must be a string, or an object mapping each language to its own \
                 query, not {}",
                json_kind(&other)
            ))),
        }
    }
}

/// What a JSON value is, for a refusal that names the shape it got.
const fn json_kind(value: &serde_json::Value) -> &'static str {
    match value {
        serde_json::Value::Null => "null",
        serde_json::Value::Bool(_) => "a boolean",
        serde_json::Value::Number(_) => "a number",
        serde_json::Value::String(_) => "a string",
        serde_json::Value::Array(_) => "an array",
        serde_json::Value::Object(_) => "an object",
    }
}

#[derive(Debug, Deserialize)]
struct RawCard {
    message: Option<String>,
    remediation: Option<String>,
    examples: Option<RawExamples>,
}

#[derive(Debug, Deserialize)]
struct RawExamples {
    bad: Option<String>,
    good: Option<String>,
}

/// The name of the synthetic entry module.
///
/// It has to sit inside the rules root, because the resolver treats a module's name as its
/// path when resolving that module's imports.
const ENTRY: &str = "__lanekeep_entry__.js";

/// The script that reduces the config to JSON.
///
/// `has_check` and `has_reduce` are recorded here rather than inferred later, because
/// `JSON.stringify` drops functions and there is no way to tell afterwards whether a rule
/// had a handler or a typo.
const EXTRACT: &str = r"
    (() => {
        const c = globalThis.__lanekeepConfig;
        if (c === null || typeof c !== 'object') return JSON.stringify(null);
        const rules = Array.isArray(c.rules) ? c.rules : [];
        return JSON.stringify({
            include: c.include ?? [],
            namespaces: c.namespaces ?? [],
            exclude: c.exclude ?? [],
            severity: c.severity ?? {},
            timeouts: c.timeouts ?? {},
            suppressions: c.suppressions ?? {},
            rules: rules.map((r) => ({
                id: r?.id ?? null,
                language: r?.language ?? null,
                severity: r?.severity ?? null,
                card: r?.card ?? null,
                query: r?.query ?? null,
                gates: r?.gates ?? {},
                timeout: r?.timeout ?? null,
                has_check: typeof r?.check === 'function',
                has_reduce: typeof r?.reduce === 'function',
            })),
        });
    })()
";

/// The entry module the loader evaluates, and — for a JSON config — everything about it
/// that never needed evaluating.
///
/// # The two formats no longer share a mechanism, and what holds them together now
///
/// They used to. A JSON config was compiled into the same module a TypeScript one is
/// imported by, so extraction, validation, hashing and the cache key never learned which
/// format they came from, and `json.rs`'s header said outright that this is why "the two
/// cannot drift." That mechanism is gone from the JSON path: `lanekeep.json` is parsed,
/// validated and resolved in Rust, and its `include`, `exclude`, `namespaces`, `severity`
/// and `timeouts` never become JavaScript at all.
///
/// **That is what the un-coupling costs.** Two code paths can drift where one could not.
/// Three things substitute for the mechanism, and they are named here rather than left
/// implied, because two of them are conventions and only one is enforced.
///
/// *Enforced.* `json::parse` builds the **shared** `RawConfig` with an exhaustive struct
/// literal, so a field added to it is a compile error on the JSON side rather than a setting
/// that quietly stops being carried. This is the one guard that is stronger than what it
/// replaced — the same omission from the old entry module's `format!` string compiled.
///
/// *Convention.* The two paths still converge at [`build`], the only place a `Config` is
/// constructed, a severity override applied, a card validated or a hash taken, so a
/// divergence has to be introduced upstream of a single function rather than anywhere.
///
/// *Convention.* The cache-key properties §8.1 depends on are asserted against **both** paths
/// in this file's tests, deliberately in matched pairs. Nothing enforces that a new property
/// gets both halves; the pairing is named in the tests so that dropping one is visible.
///
/// # Why `lanekeep-js` is still a dependency of this crate
///
/// Because `lanekeep.config.ts` is still evaluated, and will be until the last rule has
/// migrated to a component — the accepted ADR's condition 8. Nothing here is a step toward
/// deleting the sandbox on this crate's own schedule.
///
/// The JSON path also still reaches the sandbox, for one thing and not for configuration: a
/// reference naming a TypeScript rule is imported so its `defineRule` object can be read.
/// That is rule execution, which is the part condition 8 keeps. Nothing else crosses, which
/// `json::tests::no_configuration_data_reaches_the_entry_module` holds the line on.
///
/// # What unblocks removing QuickJS, and what does not
///
/// Un-coupling this path is one of condition 8's two preconditions. **The other is open and
/// this change does not answer it**: the ADR's §7.6 asks what a programmable
/// `lanekeep.config.ts` means once there is no JavaScript sandbox — arbitrary composition
/// logic, a shared preset imported as a module and spread into another config, per
/// `docs/architecture.md` §9. At least three shapes are plausible and no measurement picks
/// between them: configuration stops being programmable and becomes JSON-only; configuration
/// becomes its own component with a config-shaped WIT world; or a minimal JavaScript
/// evaluator is deliberately retained for configuration alone, decoupled from rule
/// execution. It is a decision about what lanekeep's configuration language should be, and
/// nobody has made it.
///
/// This function reading JSON without a sandbox is *not* that decision, and must not be read
/// as evidence for the first shape. It says a config format that was never programmable does
/// not need an evaluator, which was true before this change too.
fn entry_source(
    root: &RuleRoot,
    config_path: &Path,
    display: &str,
) -> Result<(String, Option<json::Parsed>), ConfigError> {
    if json::is_json(config_path) {
        let parsed = json::parse(config_path, root.path(), root.builtin_components())?;
        let source = json::rules_module(&parsed.rules);
        return Ok((source, Some(parsed)));
    }

    let specifier =
        relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
            path: display.to_owned(),
            detail: "the config file must sit inside the rules root".to_owned(),
        })?;
    Ok((
        format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n"),
        None,
    ))
}

/// Evaluate the config module into a sandbox, leaving the rule objects reachable.
///
/// Separate from [`load`] because every worker needs the ruleset present in its own engine
/// — a rule's `check` is a function, and a function cannot be moved between runtimes. Each
/// worker therefore evaluates the same modules rather than receiving extracted values.
///
/// # Errors
///
/// Returns [`ConfigError`] when the config sits outside the rules root or fails to
/// evaluate.
pub fn evaluate_into(
    sandbox: &Sandbox,
    root: &RuleRoot,
    config_path: &Path,
) -> Result<(), ConfigError> {
    let display = config_path.display().to_string();
    let entry = root.path().join(ENTRY);
    let (source, _) = entry_source(root, config_path, &display)?;

    sandbox
        .eval_module(&entry.display().to_string(), &source)
        .map_err(|e| ConfigError::Evaluation {
            path: display,
            detail: e.to_string(),
        })
}

/// Load and validate a configuration.
///
/// # Errors
///
/// Returns [`ConfigError`] when the file cannot be read, the module fails to evaluate, or
/// the result is not shaped like a config.
pub fn load(sandbox: &Sandbox, root: &RuleRoot, config_path: &Path) -> Result<Config, ConfigError> {
    load_with(sandbox, root, config_path, LoadOptions::default())
}

/// What a load needs beyond the config file, for a caller that has more to say than [`load`]
/// can carry.
///
/// A struct rather than two more parameters, for the reason `lanekeep-cli`'s `CheckOptions`
/// gives: `artifacts` and the config path are both `&Path`, and adjacent parameters of one
/// type are the shape that gets silently transposed at a call site.
#[derive(Debug, Clone, Copy, Default)]
pub struct LoadOptions<'a> {
    /// A project root under which compiled components may be cached, or `None` for a load with
    /// nowhere to write.
    ///
    /// **This is the difference between a component costing something per config load and
    /// costing nothing.** With `None`, `describe_components` compiles every component from
    /// scratch to ask it what it is, throws the compilation away, and the engine compiles the
    /// same bytes again at prepare time. Measured on the release binary, one 26 KB Rust
    /// component added ~58 ms to a `lanekeep rules` that checks no files at all, and two added
    /// ~116 ms — against a §15 warm-run budget of 25 ms for the whole invocation. Config load
    /// runs per LSP request, per MCP tool call and per `--watch` iteration, so this is paid on
    /// every one of them.
    ///
    /// **Those figures are for a component of a few tens of kilobytes and do not generalize.**
    /// The shared TypeScript built-ins are one 12.4 MiB artifact, and compiling it is about six
    /// seconds — a hundredfold, not a factor. `docs/architecture.md` §15 has the table. So the
    /// choice between `Some` and `None` is a question about seconds rather than milliseconds for
    /// any caller whose config names one of those four rules, which is why
    /// `RuleTester::for_built_in` would be unusable without a root and why `lanekeep-testkit`
    /// names one.
    ///
    /// Given a root, both loads write and map artifacts under the same [`COMPONENT_CACHE_PATH`],
    /// keyed on the specifier and the bytes — so the first run compiles once instead of twice and
    /// every later run maps what that run wrote. The two loaders agree because both build their
    /// `wasmtime::Engine` with `WasmEngine::new`; an artifact a different build wrote fails to
    /// deserialize and is discarded rather than trusted.
    ///
    /// Named by the caller rather than inferred, because a rules root is the project root only by
    /// the CLI's choice, and guessing would make loading a config write somewhere nobody asked
    /// for. `lanekeep-testkit` anchors a rules root at a temporary fixture directory and names
    /// *that* — which is the shape this field is for: the caller knows it owns the directory and
    /// removes it, and this function could not have known either.
    ///
    /// [`COMPONENT_CACHE_PATH`]: lanekeep_wasm::COMPONENT_CACHE_PATH
    pub artifacts: Option<&'a Path>,

    /// Overrides `timeouts.global` from the config file, for a caller holding a more specific
    /// statement — `--timeout`, which a user typed on this run.
    ///
    /// **It has to arrive here rather than be applied to the returned [`Config`], because config
    /// load is itself a phase that runs guest code.** `describe_components` instantiates,
    /// `configure`s and calls `metadata` on every component under a clock of its own, and that
    /// clock is built before this function returns. A caller that loaded first and assigned to
    /// `Config::limits` afterwards would leave that phase governed by the config file's number
    /// while the message a breach prints tells the user to raise it with `--timeout` — advice
    /// that could not work. `AGENTS.md` records the original instance of exactly this shape.
    pub global_timeout: Option<Duration>,
}

/// [`load`], with everything a caller knows that the config file does not.
///
/// See [`LoadOptions`] for what each field buys and why it has to be known before the load
/// rather than applied to the [`Config`] it returns.
///
/// # Errors
///
/// As [`load`].
pub fn load_with(
    sandbox: &Sandbox,
    root: &RuleRoot,
    config_path: &Path,
    options: LoadOptions<'_>,
) -> Result<Config, ConfigError> {
    let display = config_path.display().to_string();

    let entry = root.path().join(ENTRY);
    let (source, parsed) = entry_source(root, config_path, &display)?;
    sandbox
        .eval_module(&entry.display().to_string(), &source)
        .map_err(|e| ConfigError::Evaluation {
            path: display.clone(),
            detail: e.to_string(),
        })?;

    let json: String = sandbox.eval(EXTRACT).map_err(|e| ConfigError::Evaluation {
        path: display.clone(),
        detail: e.to_string(),
    })?;

    let extracted: Option<RawConfig> =
        serde_json::from_str(&json).map_err(|e| ConfigError::Shape {
            path: display.clone(),
            detail: e.to_string(),
        })?;
    let extracted = extracted.ok_or_else(|| ConfigError::Shape {
        path: display.clone(),
        detail: "the default export is not an object — did you forget `export default`?".to_owned(),
    })?;

    // A JSON config supplies its own data; exactly one field comes back from the sandbox,
    // and it is spelled out rather than merged, so a field added to `RawConfig` cannot
    // quietly start being read from the wrong side.
    let (raw, resolved) = match parsed {
        Some(parsed) => (
            RawConfig {
                rules: extracted.rules,
                ..parsed.config
            },
            parsed.rules,
        ),
        None => (extracted, Vec::new()),
    };

    build(sandbox, root, raw, &display, &resolved, options)
}

fn build(
    sandbox: &Sandbox,
    root: &RuleRoot,
    raw: RawConfig,
    display: &str,
    resolved: &[ResolvedRule],
    options: LoadOptions<'_>,
) -> Result<Config, ConfigError> {
    let overrides = parse_severity_overrides(&raw.severity, display)?;

    // Namespaces this project claims, beyond the two lanekeep defines. Validated for shape
    // here so a malformed one is reported against `namespaces` rather than against whichever
    // rule happened to use it first.
    let mut declared = BTreeSet::new();
    for namespace in &raw.namespaces {
        RuleId::namespace_from_str(namespace).map_err(|e| ConfigError::Shape {
            path: display.to_owned(),
            detail: format!("`namespaces` contains an invalid entry: {e}"),
        })?;
        if namespace == Namespace::LANEKEEP {
            return Err(ConfigError::Shape {
                path: display.to_owned(),
                detail: "`lanekeep` is reserved for rules shipped with lanekeep — a rule's \
                         origin should be readable from its ID"
                    .to_owned(),
            });
        }
        declared.insert(namespace.clone());
    }

    // The budgets, worked out before anything runs under them. `describe_components` executes
    // guest code — instantiation, `configure`, `metadata` — and a component asked what it is
    // under a budget the config did not set is a limit that was parsed and then dropped, which
    // `AGENTS.md` records as the shape of the `--timeout` bug: accepted, validated, ignored.
    //
    // **The caller's override is folded in *here*, not applied to the `Config` this returns.**
    // That is the same bug in a new phase, and it was live for the length of this branch: the CLI
    // loaded the config, then assigned `--timeout` to `loaded.limits`, one statement after the
    // phase it was meant to govern had already finished. A component whose `configure` overran
    // failed with a message ending "raise it with `--timeout`", and raising it changed nothing.
    // Resolving it before `describe_components` is what makes one number govern both phases.
    let mut limits = Limits::default();
    if let Some(ms) = raw.timeouts.rule {
        limits = limits.with_rule_timeout(Duration::from_millis(ms));
    }
    if let Some(ms) = raw.timeouts.global {
        limits = limits.with_global_timeout(Duration::from_millis(ms));
    }
    if let Some(global) = options.global_timeout {
        limits = limits.with_global_timeout(global);
    }

    // The suppression policy, validated once here — the single construction point both
    // config formats converge on, which is what makes a `suppressions` block written in
    // either format behave identically. Reached by `hash_config` below, because anything a
    // config can say has to reach one of the two hashes on purpose (`AGENTS.md`).
    let suppressions = parse_suppressions(&raw.suppressions, display)?;

    // Every component in the config, asked what it is. Once, here, before a `RuleSpec` exists
    // — not per worker: instantiation is 82 to 96 times the cost of not instantiating, which
    // is why `lanekeep_wasm::WasmRuntime::rule` defers it, and reading metadata through a
    // worker's runtime would undo that for every rule in the set.
    let mut described = describe_components(root, resolved, display, limits, options.artifacts)?;

    let mut rules = Vec::with_capacity(raw.rules.len());
    for (index, rule) in raw.rules.into_iter().enumerate() {
        // A component's entry in `raw.rules` is the placeholder `rules_module` emitted for it,
        // carrying nothing; what describes it is its own `metadata`. The two lists are indexed
        // alike by construction, which is the whole reason the placeholder is there.
        //
        // **One reference, one placeholder, and any number of rules.** A component hosts a list,
        // so a single entry in the config's array can produce several `RuleSpec`s — every one of
        // them carrying `index + 1` as its position, because that is where the *reference* sits
        // and the entry module has exactly one slot for it. A TypeScript rule after a component
        // therefore keeps its own position whatever the component turned out to hold, which is
        // what `RuleSpec::index` has to be true of.
        match described.get_mut(index).and_then(Option::take) {
            Some(hosted) => {
                for rule in hosted {
                    rules.push(build_rule(
                        rule.raw,
                        index + 1,
                        display,
                        &overrides,
                        &declared,
                        Some(rule.component),
                    )?);
                }
            }
            None => rules.push(build_rule(
                rule,
                index + 1,
                display,
                &overrides,
                &declared,
                None,
            )?),
        }
    }

    // Every description has to have been claimed by a rule. One left over means the entry
    // module's array and the config's rule list came out different lengths, and the loop above
    // would then have dropped a component rule without saying so — a configured rule that
    // silently checks nothing is the failure this tool exists not to produce. Unreachable while
    // `rules_module` emits one array entry per reference, which is exactly why it is asserted
    // rather than assumed: the placeholder is what makes it true, and a future edit that
    // removed it would find this instead of a wrong answer.
    if let Some(position) = described.iter().position(Option::is_some) {
        return Err(ConfigError::Rule {
            position: position + 1,
            path: display.to_owned(),
            detail: "this component reached no rule — the entry module's rule array and the \
                     config's rule list are not the same length"
                .to_owned(),
        });
    }

    // The components, in the order the config listed them, taken back off the rules that were
    // just built — so what is hashed is what was described and what will run, rather than a
    // fresh look at the same paths.
    let components: Vec<&ComponentRule> = rules
        .iter()
        .filter_map(|rule| rule.component.as_ref())
        .collect();

    let ruleset_hash = hash_ruleset(sandbox, &components);
    let config_hash = hash_config(
        &raw.include,
        &raw.exclude,
        &overrides,
        &limits,
        resolved,
        &suppressions,
    );

    Ok(Config {
        include: raw.include,
        exclude: raw.exclude,
        rules,
        limits,
        suppressions,
        ruleset_hash,
        config_hash,
    })
}

fn parse_severity_overrides(
    raw: &BTreeMap<String, String>,
    display: &str,
) -> Result<BTreeMap<RuleId, Severity>, ConfigError> {
    raw.iter()
        .map(|(id, severity)| {
            let id = id.parse::<RuleId>().map_err(|e| ConfigError::Shape {
                path: display.to_owned(),
                detail: format!("in `severity`: {e}"),
            })?;
            let severity = severity
                .parse::<Severity>()
                .map_err(|e| ConfigError::Shape {
                    path: display.to_owned(),
                    detail: format!("in `severity` for `{id}`: {e}"),
                })?;
            Ok((id, severity))
        })
        .collect()
}

/// Validate the `suppressions` block into the policy the engine enforces.
///
/// A single place, reached by both config formats through `build` — the one function every
/// `Config` is constructed by. `maxExpiryDays` of zero would forbid every expiry the day it
/// was set; a horizon has to reach at least tomorrow.
fn parse_suppressions(
    raw: &RawSuppressions,
    display: &str,
) -> Result<SuppressionPolicy, ConfigError> {
    if raw.max_expiry_days == Some(0) {
        return Err(ConfigError::Shape {
            path: display.to_owned(),
            detail: "in `suppressions`: `maxExpiryDays` must be at least 1".to_owned(),
        });
    }
    Ok(SuppressionPolicy {
        require_expiry: raw.require_expiry,
        max_expiry_days: raw.max_expiry_days,
        forbid_file_scope: raw.forbid_file_scope,
    })
}

/// Ask every component the config names which rules it hosts, and what each of them is.
///
/// One entry per resolved reference, `Some` for a component and `None` for anything else, so
/// the answer is indexed by the config's own rule position — the same numbering
/// `json::rules_module`'s placeholder preserves.
///
/// # One reference, a list of rules
///
/// **A component hosts a list and a reference names the component, so the entry is a `Vec`.**
/// Every export but `rules` takes an index into that list, so describing a component means
/// enumerating it first and then asking about each rule by position. A component hosting one
/// rule — every component this repository shipped before this — produces a one-element list and
/// reads exactly as it did.
///
/// A reference's options reach *every* rule the component hosts, because a reference names the
/// component and there is no syntax naming one rule inside it. That is the right shape for the
/// case that exists — a component built to host a family of related rules, configured as a
/// family — and it is not the shape a built-in wants, where `lanekeep/no-default-export` has to
/// mean one rule of a shared artifact. That is a *resolution* question rather than a
/// description one: it is answered by what `json::classify` hands back, not here.
///
/// # Once for the run, and deliberately not through a worker's runtime
///
/// Every component is compiled, instantiated, configured and asked about each of its rules
/// here. That is the cost `lanekeep_wasm::WasmRuntime::rule` exists to avoid paying per worker
/// — #96's spike measured eager instantiation at 82 to 96 times the lazy arrangement — and it
/// is paid exactly once, before any worker exists, because a rule that cannot describe itself
/// cannot be run at all. Nothing built here outlives this function: the engine, the rule set
/// and the runtime are dropped on the way out, and what survives is the metadata and the path.
///
/// **The enumeration costs one instantiation per component that the description then repeats,**
/// because `RuleSet::add` takes an index and cannot discover one — `rules` is an export, so
/// asking needs a store and an instance, and a rule set holds neither. The throwaway instance
/// lives in a runtime of its own, built and dropped inside the loop, so that at most one
/// instance beyond the description's own is resident at a time rather than one per component.
///
/// # What each answer is for
///
/// `metadata` fills every field of the `RuleSpec` a TypeScript rule fills from its own
/// `defineRule` call, and it goes through `build_rule` exactly as an extracted TypeScript rule
/// does — so a component's id, namespace, card, query and severity are validated by the same
/// code, and a component cannot smuggle past a check a TypeScript rule has to satisfy.
///
/// `has-check` and `has-reduce` are asked rather than assumed, which closes the one place a
/// component used to be taken at its config's word about a question it can answer itself.
///
/// `configure` is not called here and is not skipped: `RuleSet::add` records the options and
/// `WasmRuntime::rule` hands them over on the way to the instance `metadata` is read from. So a
/// component that refuses its options fails at config load, naming the rule and carrying the
/// guest's own message, and the same call happens again on every worker that later builds an
/// instance of its own.
///
/// `rules` is the one export asked *before* configuration, and it is why the world splits it
/// from `metadata` rather than returning a list of those. A factory rule's card and query come
/// from applying the factory to its options, so metadata has to be read after `configure`; but
/// configuring rule *i* means knowing that *i* exists. A rule's id cannot depend on its
/// options — the id is how a config names the rule in the first place — so the ids enumerate
/// first and everything else follows configuration.
///
/// # Confinement, before a byte is read — and a built-in has nothing to confine
///
/// A built-in component is embedded in this binary. There is no path in the config, no file on
/// disk and nothing to canonicalize, so the paragraphs below are about a `.wasm` *path*
/// reference and only about that. That is not a weaker check for built-ins; it is the absence
/// of the thing the check exists to constrain, and it is the same reason a built-in module
/// cannot be shadowed by a project file.
///
/// A rule reference is a string in a config file and a component is *executed*, so where it is
/// allowed to point is a trust boundary rather than a convenience. `json::classify` joins the
/// specifier against the rules root and normalizes it, which is purely lexical and does not
/// confine anything: `Path::join` lets an absolute specifier replace the root outright, and no
/// lexical rule can see through a symlink.
///
/// [`RuleRoot::confine`] is the check, and it is the containment half of the one a module
/// import goes through rather than a second set written here: the lexical test that refuses
/// `../../evil.wasm` whatever is on disk, then the canonicalization that refuses a symlink
/// pointing out of the root. It runs before [`std::fs::read`], so a reference that escapes is
/// refused without its bytes ever being loaded, let alone compiled or instantiated.
///
/// **Containment is all of it, and a module import is held to more.** `RuleRoot::resolve`
/// additionally refuses *any* absolute specifier a rule writes, as a bare specifier, before
/// containment is considered at all — so `import '/etc/passwd'` and
/// `import '/inside/the/root/x'` are both refused, and only the first would be refused here.
/// An absolute `.wasm` path that lands inside the rules root is therefore accepted. That is not
/// an escape and nothing about the trust boundary turns on it; it is written down because the
/// two paths are otherwise easy to read as identical, and the next person to compare them
/// should find the difference recorded rather than discover it.
///
/// # One read
///
/// The bytes are read here and carried on [`ComponentRule`]. `metadata` is read from them,
/// `hash_ruleset` folds them and `lanekeep-engine` executes them, so the rule that was
/// described is the rule that runs. Reading three times would let a file that changed in
/// between describe one rule, key another and run a third.
fn describe_components(
    root: &RuleRoot,
    resolved: &[ResolvedRule],
    display: &str,
    limits: Limits,
    artifacts: Option<&Path>,
) -> Result<Vec<Option<Vec<Described>>>, ConfigError> {
    let mut described: Vec<Option<Vec<Described>>> = resolved.iter().map(|_| None).collect();
    if !resolved.iter().any(|rule| rule.reference.is_component()) {
        return Ok(described);
    }

    let fail = |position: usize, detail: String| ConfigError::Rule {
        position: position + 1,
        path: display.to_owned(),
        detail,
    };
    let broken = |detail: String| ConfigError::Shape {
        path: display.to_owned(),
        detail,
    };

    let engine = WasmEngine::new().map_err(|e| broken(e.to_string()))?;
    let mut set = RuleSet::new(&engine).map_err(|e| broken(e.to_string()))?;
    // With the on-disk artifact cache when the caller named a project root, and without one
    // otherwise. A rules root is not a project root — `lanekeep-testkit` anchors one at a
    // temporary fixture directory — so guessing a location to write `.lanekeep/components` into
    // would make loading a config write somewhere nobody asked for. Naming it is
    // `LoadOptions::artifacts`, passed through `load_with`; the CLI names the project it was
    // pointed at, and `lanekeep-testkit` names the throwaway project it created and removes.
    //
    // It matters because without one this compiles every component only to throw the
    // compilation away, and the engine compiles the same bytes again at prepare time — on every
    // config load, and config load runs per LSP request, per MCP tool call and per `--watch`
    // iteration. With one, both loads map the same artifact.
    //
    // **The cost of taking the uncached arm is set by the largest component named, and the two
    // sizes that ship differ by two orders of magnitude.** This sentence used to say "~58 ms per
    // component" without qualification; that figure was measured against components of about
    // 26 KB and is still right for them — a run naming only `lanekeep/no-unwrap` is 80 ms cold
    // and its `.cwasm` 356 KB. `typescript-builtins.wasm` is 12.4 MiB, and compiling it is
    // **about six seconds** (`docs/architecture.md` §15's table: 6,115 ms cold for one rule of
    // it against 32 ms for a module rule). Paid twice, on every load, by every caller that takes
    // this arm — which is `lanekeep_config::load` and whoever calls it. `lanekeep-testkit` used
    // to be the example here and no longer is: it names its own throwaway project, which it
    // created and removes, and takes the cached arm.
    let loader = artifacts.map_or_else(
        lanekeep_wasm::ComponentLoader::without_cache,
        lanekeep_wasm::ComponentLoader::for_project_root,
    );

    // **Compilation first, and outside the clock that starts below.** Reading and compiling a
    // component is host work: it is bounded by the machine and by whether `.lanekeep/components`
    // is warm, and by nothing a rule or a config did. Charging it to the run budget made a cold
    // run and a warm run over identical input take different exits — a 12.4 MiB JavaScript
    // component is seconds to compile and microseconds to map — which puts the compile cache
    // into the determinism tuple, where `(bytes, path, ruleset, config, tracked reads)` has no
    // term for it.
    //
    // It has a budget of its own rather than none, and its own diagnostic: the global budget's
    // message ends "narrow what is being checked", which is advice that cannot work against a
    // fixed compile cost.

    let compiled = compile_components(
        root,
        resolved,
        &engine,
        &loader,
        COMPILE_BUDGET_PER_COMPONENT,
    )
    .map_err(|(position, detail)| fail(position, detail))?;

    // The one clock, started before any guest code runs and shared by the enumeration and the
    // description. Two clocks would give each phase the whole global budget, so a config load
    // could take twice what the user set and report neither overrun — and the split above does
    // not make a second one, because it takes *host* work out of this one rather than putting
    // guest work under another.
    let clock = RunClock::start(limits.global_timeout);

    let mut added = Vec::new();
    for entry in &compiled {
        let position = entry.position;
        let rule = &resolved[position];
        let options = &entry.options;

        let ids = hosted_rules(&engine, limits, &clock, &entry.admitted)
            .map_err(|e| fail(position, e.to_string()))?;

        // A component hosting nothing is a configured rule that can never report, which is the
        // failure this tool exists not to produce — and it is silent, because an empty list
        // reads downstream exactly like a reference nobody wrote. Refused where the reference
        // is, so the diagnostic names the entry. The check is a pure helper so it is testable
        // without building a component that answers `rules()` with nothing.
        if let Err(detail) = no_rules_detail(&ids, &rule.specifier) {
            return Err(fail(position, detail));
        }

        let wanted = contributed(&ids, entry.only, &rule.specifier)
            .map_err(|detail| fail(position, detail))?;

        for (index, id) in wanted {
            // The rule's own id rather than the specifier, because a slot's name is what a
            // diagnostic shows a reader and one specifier now stands for several rules.
            let slot = set
                .add(&id, &entry.admitted, index, options.clone())
                .map_err(|e| fail(position, e.to_string()))?;

            added.push((
                position,
                slot,
                // The id the component *enumerated*, carried to where its `metadata` is read.
                // Two exports answer this question and nothing had ever compared them: a guest
                // whose `rules()` and `metadata()` disagree registers a slot under one id and
                // builds a `RuleSpec` with another, so a suppression comment naming the id a
                // user was shown would silently match nothing.
                id,
                ComponentRule {
                    path: entry.origin.clone(),
                    index,
                    options: options.clone(),
                    // An `Arc` clone: the rules of one component share the read, which is what
                    // makes "read once" per reference rather than per rule.
                    bytes: entry.bytes.clone(),
                    // And the map beside them, so `lanekeep-engine` loads this component with
                    // the map this description was made against rather than looking for one.
                    source_map: entry.source_map.clone(),
                    // The one constructor whose output `build` folds into `ruleset_hash` —
                    // see the field.
                    counted_in_ruleset_hash: true,
                },
            ));
        }
    }

    let mut runtime = WasmRuntime::for_rules(engine, std::sync::Arc::new(set), limits, clock);

    for (position, slot, enumerated, component) in added {
        let metadata = runtime
            .metadata(slot)
            .map_err(|e| fail(position, e.to_string()))?;

        // The guest's two accounts of itself, compared once, here. `rules()` is what a rule was
        // registered under and `metadata().id` is what it reports as; the world declares them
        // separately because a rule's id has to be knowable before it is configured, and
        // separate answers can differ. Everything downstream trusts one or the other without
        // being in a position to notice.
        if metadata.id != enumerated {
            return Err(fail(
                position,
                format!(
                    "`{}` enumerates a rule as `{enumerated}` and that rule's metadata calls \
                     it `{}` — a component has to answer its own id the same way twice",
                    rule_specifier(resolved, position),
                    metadata.id
                ),
            ));
        }
        let has_check = runtime
            .has_check(slot)
            .map_err(|e| fail(position, e.to_string()))?;
        let has_reduce = runtime
            .has_reduce(slot)
            .map_err(|e| fail(position, e.to_string()))?;

        if let Some(entry) = described.get_mut(position) {
            entry.get_or_insert_with(Vec::new).push(Described {
                raw: raw_rule_from(metadata, has_check, has_reduce),
                component,
            });
        }
    }

    Ok(described)
}

/// The detail string for refusing a component whose `rules()` answered nothing.
///
/// An empty list is a configured rule that can never report — and it is silent, because an
/// empty list reads downstream exactly like a reference nobody wrote. Lifted out of
/// [`describe_components`] so the refusal is unit-testable without a `.wasm` fixture: the
/// question is whether an empty id list and a specifier produce the refusal, nothing a
/// component has to run to answer. The caller wraps the detail in [`ConfigError::Rule`].
fn no_rules_detail(ids: &[String], specifier: &str) -> Result<(), String> {
    if ids.is_empty() {
        return Err(format!(
            "`{specifier}` is a component that hosts no rules — there is nothing for this entry \
             to run"
        ));
    }
    Ok(())
}

/// How long one component may take to read, compile and admit.
///
/// **Not the global run budget, and deliberately not user-configurable.** Compilation is host
/// work — it scales with the machine and with whether `.lanekeep/components` is warm, and with
/// nothing a rule or a config did — so charging it to the budget that bounds *rule execution*
/// makes a cold run and a warm run over identical input take different exits. That is the
/// determinism invariant, and `(bytes, path, ruleset, config, tracked reads)` has no term for a
/// compile cache.
///
/// Bounded rather than unbounded, because "it is host work" is not "it may take forever": a
/// component that never finishes compiling is a hung tool, and a diagnostic naming compilation
/// is one a user can act on.
///
/// # Ten minutes, and the first number tried was sixty seconds
///
/// **This is a safety net and not a performance limit, and the difference is the whole
/// calibration.** Sixty seconds was picked as roughly ten times the 6.2 s a 12.4 MiB
/// StarlingMonkey component takes on an idle laptop — which is exactly the mistake of
/// calibrating a wall-clock bound on the fastest machine in the loop. Measured 2026-08-10, the
/// `lanekeep-cli` tests cross-compiled for `x86_64-unknown-linux-gnu` and run on Linux under
/// QEMU with eight test binaries at once: **one component took 231.3 s to compile**, tripped
/// this, and turned twenty-odd unrelated CLI tests red with a message about compilation. The
/// same test alone on the same machine finished in 23.6 s.
///
/// So a bound whose whole purpose is "this cannot possibly be legitimate" has to sit above the
/// slowest *legitimate* case, and emulated or heavily contended hardware is an order of
/// magnitude slower than an idle laptop rather than a factor of two. Ten minutes per component
/// is above everything observed and still far below the point at which a user would conclude
/// the process had crashed.
///
/// **The comparison and the message are tested; the wall-clock value is not, and cannot be.**
/// [`compile_overrun`] is a pure function over an elapsed `Duration` and a count, so a test
/// drives the arithmetic and the wording with synthetic microseconds — which is what makes this
/// branch reachable without a fixture that spends ten minutes getting there. "No fixture can
/// afford it, so none of it can be tested" was the reasoning this constant shipped with for one
/// round, and it was a false dichotomy: only an *end-to-end* test needs a real slow compile.
///
/// What no test asserts is that ten minutes is the right number of minutes — that is a judgment
/// against measurements, recorded above.
///
/// **It cannot preempt a single compilation**, and nothing here pretends otherwise: wasmtime's
/// compile is synchronous with no interrupt, so this is checked between components. A config
/// naming one component that hangs is bounded by nothing here. What it does bound is the
/// aggregate, which is the case that scales — and the check is written per component so that a
/// config with twenty of them is not held to one component's budget.
const COMPILE_BUDGET_PER_COMPONENT: Duration = Duration::from_mins(10);

/// One config entry's component, read and compiled, before any guest code runs.
///
/// Carried by value between the two passes so the compilation is done once. `admitted` is the
/// only thing `RuleSet::add` accepts, and it is what makes the import check unavoidable.
struct Compiled {
    /// The entry's position in `resolved`, for the diagnostic and for `described`.
    position: usize,
    /// Provenance for [`ComponentRule::path`].
    origin: PathBuf,
    /// The bytes this was compiled from, folded into `ruleset_hash`.
    bytes: ComponentBytes,
    /// The component's source map, carried to `ComponentRule` so the engine loads with it too.
    source_map: Option<ComponentBytes>,
    /// Which rule of the component this entry names, or every one of them.
    only: Option<u32>,
    /// The entry's options as JSON, serialized once so every worker gets the same bytes.
    options: String,
    /// The compiled, import-checked component.
    ///
    /// Behind an [`Arc`] because a shared component — the four migrated built-ins are one — is
    /// named once per rule *reference*, and the whole point of the load memo is to deserialize
    /// it once and hand the same [`lanekeep_wasm::Loaded`] to every reference. [`RuleSet::add`]
    /// then shares the instance on [`lanekeep_wasm::Loaded::identity`], which it already did; the
    /// work this avoids is the deserialize, not the instantiation.
    admitted: std::sync::Arc<lanekeep_wasm::Loaded>,
}

/// Read and compile every component the config names, before the run clock starts.
///
/// **The first of two passes, and the split is what keeps host work out of the run budget.**
/// See [`COMPILE_BUDGET_PER_COMPONENT`] for why, and for what bounds this instead.
///
/// # Errors
///
/// Returns `(position, detail)` — the caller knows the config's path and wraps it. A component
/// that cannot be read, that escapes the rules root, that this build does not have, or that the
/// loader refuses fails here, before anything is executed.
fn compile_components(
    root: &RuleRoot,
    resolved: &[ResolvedRule],
    engine: &std::sync::Arc<WasmEngine>,
    loader: &lanekeep_wasm::ComponentLoader,
    budget: Duration,
) -> Result<Vec<Compiled>, (usize, String)> {
    let started = std::time::Instant::now();
    let mut compiled = Vec::new();

    // **One deserialize per component, not one per rule reference.** A shared component — the
    // four migrated built-ins are one — is named once per rule, and deserializing the same
    // ~34 MB artifact four times is the §15 defect: the warm column grows faster than the rule
    // count. The loader is lock-free by design (`&self`, so parallel loads never contend), so
    // this memo lives here rather than behind a lock in the loader. It is keyed on the
    // component's content identity — `blake3::hash` of the bytes, the same digest
    // [`lanekeep_wasm::Loaded::identity`] carries and [`RuleSet::add`] already shares instances
    // on — and not on the name, because two different components can share a name across
    // configs. `RuleSet::add` then shares the instance, which it already did; the work this
    // skips is the deserialize.
    let mut memo: HashMap<[u8; 32], std::sync::Arc<lanekeep_wasm::Loaded>> = HashMap::new();

    for (position, rule) in resolved.iter().enumerate() {
        // Whether this reference is a component at all comes first, so a config of TypeScript
        // rules with one component in it does no work per rule that is thrown away. Extracting
        // the two byte sources into `component_bytes` put the serialization above this test for
        // a while, which was a small silent regression on the common shape.
        let Some(ComponentSource {
            origin,
            bytes,
            only,
            source_map,
        }) = component_bytes(root, rule).map_err(|detail| (position, detail))?
        else {
            continue;
        };

        // `null` for a rule named with no options, which is the world's own shape for it —
        // serialized once here so that every worker's `configure` is handed the same bytes.
        let options = rule
            .options
            .as_ref()
            .map_or_else(|| "null".to_owned(), json::literal);

        // The identity of these bytes — content rather than name, as above. Hashed here to look
        // the memo up *before* paying for a load, so a second reference to one component skips
        // `load_mapped` entirely. `load_mapped` hashes the same bytes again to name its
        // artifact, so the first reference pays two hashes; that is one hash per unique
        // component rather than one per reference, and a blake3 of 34 MB is milliseconds against
        // the seconds a deserialize costs.
        let identity = *blake3::hash(bytes.as_slice()).as_bytes();

        let admitted = if let Some(existing) = memo.get(&identity) {
            // The source map is a property of the component, not of the identity, and
            // [`RuleSet::add`] already collapses every reference of one identity to the first
            // one's map — so handing the first reference's `Loaded` to the rest is consistent
            // with the invariant rather than a new assumption about it.
            std::sync::Arc::clone(existing)
        } else {
            let fresh = std::sync::Arc::new(
                loader
                    .load_mapped(
                        engine,
                        &rule.specifier,
                        bytes.as_slice(),
                        source_map.as_ref().map(ComponentBytes::as_slice),
                    )
                    .map_err(|e| (position, e.to_string()))?,
            );
            memo.insert(identity, std::sync::Arc::clone(&fresh));
            fresh
        };

        compiled.push(Compiled {
            position,
            origin,
            bytes,
            source_map,
            only,
            options,
            admitted,
        });

        // Checked after each component rather than before, because a compilation cannot be
        // interrupted once it has started.
        if let Some(detail) = compile_overrun(started.elapsed(), compiled.len(), budget) {
            return Err((position, detail));
        }
    }

    Ok(compiled)
}

/// Whether the compilation pass has overrun its budget, and what to say if it has.
///
/// **A pure function so the budget can be tested at all.** Everything else about the pass needs a
/// real component and a real compiler; this is the arithmetic and the wording, and separating it
/// is what lets a test drive the comparison with synthetic microsecond `Duration`s instead of a
/// fixture that would have to spend ten minutes to reach the branch. The alternative on offer was
/// an end-to-end test costing exactly the budget, which is why the branch went untested for a
/// round — the dichotomy was false and this is the third thing this change has had to learn it
/// about.
///
/// The budget scales with how many components were asked for, so a config with twenty of them is
/// not held to one component's allowance. `saturating_mul` rather than `*`: `Duration`
/// multiplication panics on overflow, and the count comes from a config.
fn compile_overrun(elapsed: Duration, compiled: usize, budget: Duration) -> Option<String> {
    let allowed = budget.saturating_mul(u32::try_from(compiled).unwrap_or(u32::MAX));
    if elapsed <= allowed {
        return None;
    }

    Some(format!(
        "compiling the rule components took {elapsed:.1?}, past the {allowed:.1?} allowed for \
         {compiled} of them\n  \
         this is the cost of turning WebAssembly into machine code and not of running any rule, \
         so narrowing what is checked will not help\n  \
         a warm `.lanekeep/components` skips it entirely — if this recurs on every run, that \
         directory is not writable"
    ))
}

/// Which of a component's rules one config entry stands for, as `(index, id)`.
///
/// **A built-in names one rule; a `.wasm` path names the artifact.** `lanekeep/no-unwrap` is a
/// rule, and the fact that its artifact happens to host one is an accident of how it was built —
/// `lanekeep/no-default-export` names a rule of an artifact hosting four, and a reference
/// contributing every rule of that component would turn one config entry into four, each of them
/// configured with options meant for one. A path has no name to narrow by, so it contributes the
/// whole component, which is what a family of rules shipped together is.
///
/// # Errors
///
/// Returns the diagnostic detail, without a position: the caller knows which entry this was.
///
/// An index the component does not have is refused here rather than left to `RuleSet::add`,
/// whose message would be about a slot. What went wrong is that `lanekeep_rules`' table and the
/// artifact it names disagree, and nobody reading "index 3 is out of range" would go looking for
/// that.
///
/// Whether the *right* rule sits at that index is not answerable from here — a component's ids
/// are its own, and a fixture's need not look like a built-in's. `lanekeep-rules`'
/// `tests/component_rules.rs` makes that claim, against the real artifacts, in the gate.
fn contributed(
    ids: &[String],
    only: Option<u32>,
    specifier: &str,
) -> Result<Vec<(u32, String)>, String> {
    let Some(index) = only else {
        return ids
            .iter()
            .enumerate()
            .map(|(index, id)| {
                u32::try_from(index)
                    .map_err(|_| format!("`{specifier}` lists more rules than an index can name"))
                    .map(|index| (index, id.clone()))
            })
            .collect();
    };

    let declared = ids.get(index as usize).ok_or_else(|| {
        format!(
            "`{specifier}` is recorded at index {index} of a component hosting {} rule(s) — \
             the built-in table and the component disagree",
            ids.len()
        )
    })?;
    Ok(vec![(index, declared.clone())])
}

/// The specifier of the rule at a position, for a diagnostic raised after the loop that had it.
///
/// The description phase runs over `added` rather than over `resolved`, so the entry a failure
/// belongs to is reached by position. An empty string rather than a panic for a position that
/// is not there, which cannot happen — every position in `added` came from `resolved` — because
/// a diagnostic is not worth aborting a load over.
fn rule_specifier(resolved: &[ResolvedRule], position: usize) -> &str {
    resolved
        .get(position)
        .map_or("", |rule| rule.specifier.as_str())
}

/// Which rules a component hosts, by id, in the order it lists them.
///
/// **The one question that has to be asked before a rule set can be built.** `RuleSet::add`
/// takes an index into this list and cannot discover one for itself: `rules` is an export, so
/// asking needs a store and an instance, and a rule set deliberately holds neither.
///
/// A runtime of its own, built and dropped here. Two things follow. The instance is transient,
/// so the enumeration does not leave one resident per component beside the description's; and
/// this store is not the store the description runs in, so a component that traps while being
/// enumerated poisons nothing that outlives the failure — which costs nothing either way, since
/// every failure here aborts the load.
///
/// The clock is the caller's rather than a fresh one, so the global budget covers the
/// enumeration and the description together.
///
/// # Errors
///
/// [`lanekeep_wasm::WasmError`] if the world cannot be linked, the component cannot be
/// instantiated under the run's limits, or the guest traps while listing its rules.
fn hosted_rules(
    engine: &std::sync::Arc<WasmEngine>,
    limits: Limits,
    clock: &std::sync::Arc<RunClock>,
    admitted: &lanekeep_wasm::Loaded,
) -> Result<Vec<String>, lanekeep_wasm::WasmError> {
    let mut probe = WasmRuntime::new(
        std::sync::Arc::clone(engine),
        limits,
        std::sync::Arc::clone(clock),
    )?;
    let instance = probe.instantiate(admitted)?;
    probe.call_rules(&instance)
}

/// Where one reference's component bytes come from, or `None` if it names no component.
///
/// **The two sources of a component, in one place.** A built-in is embedded in this binary and a
/// project rule is a file inside the rules root, and everything downstream — admission, the rule
/// set, `metadata`, `ruleset_hash`, execution — treats them identically from here on. Keeping the
/// two arms together is what makes that reading true rather than approximately true: a difference
/// between them has to be written in this function, where it can be seen.
///
/// The first element is provenance for [`ComponentRule::path`] — a canonical path for a file, and
/// the `lanekeep/<name>` specifier for a built-in, which is relative and so can never collide
/// with one. The third says **which** of the component's rules the reference names: `Some(index)`
/// for a built-in, whose name is a rule's, and `None` for a path, which names the artifact and so
/// contributes every rule in it.
///
/// # A loose `.wasm` is reachable and is not a supported interface
///
/// The file arm below means a `lanekeep.json` naming `./rules/mine.wasm` loads and runs it, and
/// the containment tests beside it are real. It is nonetheless **not** a documented feature:
/// `schema/lanekeep.schema.json` describes built-ins and `./path.ts` only, and
/// `docs/authoring-rust-rules.md` is about the built-ins in this repository rather than about a
/// project shipping its own component.
///
/// That is a decision rather than an oversight, taken because supporting it means promising
/// something not yet true. A third-party component binds against `crates/lanekeep-wasm/wit`,
/// whose bytes are a *cache key* and not a stability promise — it changes without ceremony, and
/// this branch changed it twice — so a rule built against one lanekeep would silently target a
/// world the next one does not have. Advertising the path before there is a versioned world and
/// a published authoring story would be committing to an ABI nothing currently keeps.
///
/// The arm stays because built-ins and fixtures reach it by the same route, and narrowing it to
/// built-ins would put a difference between the two sources back into a function whose whole
/// purpose is that there is not one. Anyone deciding to support it should add the schema entry
/// and the authoring documentation in that change, and say what the world's stability is.
///
/// # Errors
///
/// Returns the diagnostic detail, without a position: the caller knows which rule this was and
/// wraps it. A built-in that the lookup does not know is *unreachable* while `json::classify`
/// asks the very lookup this reads — the reference is only that variant because the name
/// answered. It is refused rather than assumed away because the two calls are in different
/// crates, and a rules root rebuilt between them without its components would otherwise produce
/// a rule with no `check` rather than an explanation.
/// What a config entry's component reference resolved to, before anything is compiled.
///
/// A struct rather than a tuple because it grew a fourth member whose meaning is not readable
/// from its position — three of these are `Option`s or paths and the reader has to be told which
/// is which.
struct ComponentSource {
    /// Provenance for [`ComponentRule::path`]: a confined path, or `lanekeep/<name>`.
    origin: PathBuf,
    /// The component itself, read exactly once.
    bytes: ComponentBytes,
    /// Which of the component's rules this entry names, or every one of them.
    only: Option<u32>,
    /// The component's source map, if it ships one.
    source_map: Option<ComponentBytes>,
}

fn component_bytes(
    root: &RuleRoot,
    rule: &ResolvedRule,
) -> Result<Option<ComponentSource>, String> {
    match &rule.reference {
        // Embedded in this binary, so there is no path to confine and no file to read — and
        // nothing a project file could shadow, which is the guarantee a built-in module has too.
        RuleReference::BuiltinComponent(name) => {
            let (bytes, index) = root.builtin_component(name).ok_or_else(|| {
                format!(
                    "`lanekeep/{name}` was resolved as a built-in component and this build has \
                     no component by that name"
                )
            })?;
            Ok(Some(ComponentSource {
                origin: PathBuf::from(format!("lanekeep/{name}")),
                bytes: bytes.to_vec().into(),
                only: Some(index),
                // Asked with the same name and from the same table, so the map a component
                // gets is its own or none.
                source_map: root
                    .builtin_component_map(name)
                    .map(|map| map.to_vec().into()),
            }))
        }

        RuleReference::Component(path) => {
            // Confinement before the read, and before anything is compiled or run.
            //
            // The message is this crate's rather than the resolver's, because the resolver's is
            // written for an `import` and says so — "rule modules may only import from within
            // it" names nothing a user who wrote a `.wasm` path would recognize. The *check* is
            // the resolver's, which is the half that must not be duplicated.
            let confined = root.confine(&rule.specifier, path).map_err(|e| match e {
                ResolveError::EscapesRoot { .. } => format!(
                    "`{}` resolves outside the rules root, and a rule component must sit \
                     inside it",
                    rule.specifier
                ),
                ResolveError::Unreadable { detail, .. } => {
                    format!("cannot read `{}`: {detail}", path.display())
                }
                other => other.to_string(),
            })?;

            let bytes: ComponentBytes = std::fs::read(&confined)
                .map_err(|e| format!("cannot read `{}`: {e}", confined.display()))?
                .into();
            // No sidecar is read for a project component, deliberately. `<name>.wasm.map` is the
            // obvious convention and it would be a second file whose freshness against the first
            // nothing checks: a map left behind by a previous build reports positions in real
            // files that have nothing to do with the failure, and the two cannot be paired
            // without a digest neither of them carries. A built-in's map is embedded in this
            // binary beside its component, which is what makes that pairing hold there.
            Ok(Some(ComponentSource {
                origin: confined,
                bytes,
                only: None,
                source_map: None,
            }))
        }

        RuleReference::Builtin(_) | RuleReference::Module(_) => Ok(None),
    }
}

/// A component's own account of itself, in the shape [`build_rule`] validates.
struct Described {
    raw: RawRule,
    component: ComponentRule,
}

/// What a component answered, as the rule declaration the rest of this file already knows how
/// to check.
///
/// Deliberately a [`RawRule`] rather than a `RuleSpec`: converging on the same validation is
/// the point. A component that named an undeclared namespace, an empty query or an unusable
/// card is refused by the code that refuses a TypeScript rule for the same reasons, in the
/// same words.
fn raw_rule_from(
    metadata: lanekeep_wasm::bindings::types::RuleMetadata,
    has_check: bool,
    has_reduce: bool,
) -> RawRule {
    RawRule {
        id: Some(metadata.id),
        language: Some(RawLanguages::Many(metadata.languages)),
        severity: Some(metadata.severity),
        card: Some(RawCard {
            message: Some(metadata.card.message),
            remediation: Some(metadata.card.remediation),
            examples: Some(RawExamples {
                bad: Some(metadata.card.examples.bad),
                good: Some(metadata.card.examples.good),
            }),
        }),
        query: Some(RawQueries::Many(
            metadata
                .queries
                .into_iter()
                .map(|q| (q.language, q.query))
                .collect(),
        )),
        gates: Gates {
            path_matches: metadata.gates.path_matches,
            path_not_matches: metadata.gates.path_not_matches,
            file_contains: metadata.gates.file_contains,
            file_not_contains: metadata.gates.file_not_contains,
        },
        timeout: metadata.timeout,
        has_check,
        has_reduce,
    }
}

fn build_rule(
    raw: RawRule,
    position: usize,
    display: &str,
    overrides: &BTreeMap<RuleId, Severity>,
    declared: &BTreeSet<String>,
    component: Option<ComponentRule>,
) -> Result<RuleSpec, ConfigError> {
    let fail = |detail: String| ConfigError::Rule {
        position,
        path: display.to_owned(),
        detail,
    };

    let id = raw
        .id
        .ok_or_else(|| fail("missing `id`".to_owned()))?
        .parse::<RuleId>()
        .map_err(|e| fail(e.to_string()))?;

    // A namespace nobody declared is a typo, and this is the only layer that can tell.
    // Parsing accepts any well-formed namespace so a team can use its own; declaring it is
    // what keeps `lanekep/foo` from becoming a valid ID that quietly matches nothing.
    if !id.namespace().is_built_in() && !declared.contains(id.namespace().as_str()) {
        let mut known: Vec<String> = Namespace::built_ins()
            .iter()
            .map(|n| format!("`{n}`"))
            .collect();
        known.extend(declared.iter().map(|n| format!("`{n}`")));
        return Err(fail(format!(
            "rule namespace `{}` is not declared — add it to `namespaces` in the config, \
             or use one of {}",
            id.namespace(),
            known.join(", ")
        )));
    }

    // The check that JSON extraction exists to make possible. A rule whose handler is
    // missing or misspelled would otherwise load cleanly and never report, which is
    // indistinguishable from the code being fine.
    if !raw.has_check {
        return Err(fail(format!(
            "`{id}` has no `check` function — a rule without one can never report anything"
        )));
    }

    let card = raw
        .card
        .ok_or_else(|| fail(format!("`{id}` has no `card`")))?;
    let examples = card.examples.unwrap_or(RawExamples {
        bad: None,
        good: None,
    });
    let card = RuleCard {
        message: card.message.unwrap_or_default(),
        remediation: card.remediation.unwrap_or_default(),
        examples: Examples {
            bad: examples.bad.unwrap_or_default(),
            good: examples.good.unwrap_or_default(),
        },
    };
    card.validate()
        .map_err(|problems| fail(format!("`{id}` has an unusable card: {problems:?}")))?;

    let declared = raw
        .severity
        .map(|s| s.parse::<Severity>())
        .transpose()
        .map_err(|e| fail(format!("`{id}`: {e}")))?
        .unwrap_or(Severity::Error);

    // Both TypeScript dialects by default, because a rule written for TypeScript is meant for
    // the TypeScript in the project — and in any React codebase most of that lives in `.tsx`,
    // which the TypeScript grammar cannot parse.
    let languages = raw.language.map_or_else(
        || vec!["typescript".to_owned(), "tsx".to_owned()],
        RawLanguages::into_vec,
    );
    // An empty list is not "every language", it is *no file at all* — a rule runs only on a
    // file whose own language it names — and it is silent: the rule loads, matches nothing and
    // reports nothing, which is indistinguishable from the code being clean. The world declares
    // that the host refuses one at load (`crates/lanekeep-wasm/wit/world.wit`); this is that
    // refusal, and it covers a TypeScript rule writing `language: []` for the same reason.
    if languages.is_empty() {
        return Err(fail(format!(
            "`{id}` names no language — a rule runs only on files whose language it names, so \
             an empty list means it can never run"
        )));
    }

    let queries = match raw.query {
        None => return Err(fail(format!("`{id}` has no `query`"))),
        Some(RawQueries::One(query)) => {
            if query.trim().is_empty() {
                return Err(fail(format!("`{id}` has an empty `query`")));
            }
            languages
                .iter()
                .cloned()
                .map(|language| (language, query.clone()))
                .collect()
        }
        Some(RawQueries::Many(queries)) => {
            // The exact cover, shared word for word with the component gate
            // (`lanekeep-wasm`'s `validate_metadata`) through `lanekeep_core::query_cover`,
            // so the two paths cannot drift in what they accept or in how they say no. The
            // duplicate arm can never fire here — a `BTreeMap` cannot hold a language twice
            // — and lives in the shared check for the path that can, a component's
            // `list<query-for>`.
            lanekeep_core::query_cover::check(&languages, queries.keys().map(String::as_str))
                .map_err(|problem| fail(format!("`{id}` {}", problem.describe())))?;
            // Per-entry emptiness is this gate's alone, deliberately: probe fixtures answer
            // `metadata` with an empty query on purpose, so the host gate admits one and
            // the last gate before a rule runs — this one — refuses it.
            for (language, query) in &queries {
                if query.trim().is_empty() {
                    return Err(fail(format!(
                        "`{id}` has an empty `query` for `{language}`"
                    )));
                }
            }
            queries
        }
    };

    Ok(RuleSpec {
        index: position - 1,
        // Config severity wins over what the rule declares, per §9.
        severity: overrides.get(&id).copied().unwrap_or(declared),
        id,
        languages,
        card,
        queries,
        gates: raw.gates,
        timeout: raw.timeout.map(Duration::from_millis),
        has_reduce: raw.has_reduce,
        component,
    })
}

/// Hash the code every rule in this run is made of: modules the loader read, and components.
///
/// # A correction to the architecture
///
/// §8 says `ruleset_hash` must be over *canonicalized* rule definitions, so that
/// reformatting does not invalidate while editing a regex does. That was written when rules
/// were declarative data, where canonicalizing means normalizing a parsed value.
///
/// Rules are now TypeScript, and canonicalizing arbitrary TypeScript would mean shipping a
/// formatter and agreeing on its output forever. So this hashes module source bytes:
/// reformatting a rule *does* invalidate its cached results.
///
/// That is over-invalidation, which costs a recompute. The alternative error —
/// under-invalidating and serving results computed by code that no longer exists — is the
/// one §8 exists to prevent, and it is not symmetric with this one.
///
/// # Two kinds of rule code, and why both are folded here rather than one replacing the other
///
/// A component's bytes are the same input as a module's source: the code that decided the
/// answer. The plan for this change described the component fold as replacing the module walk,
/// which would be correct in a world where every rule is a component and is a silent
/// under-invalidation in this one — two built-ins are components and every other rule in this
/// tree is a module, so dropping the walk would take almost the whole ruleset out of the cache
/// key. So both are folded, and the module walk leaves when the last module does.
///
/// A component is hashed by its **bytes and not its path**. A resolved component path is
/// absolute, and putting it in would make the key depend on where the checkout sits — a cache
/// invalidated by moving a directory, for nothing. Which component a rule *names* is
/// `hash_config`'s to carry, through the specifier; this hash is about the code.
///
/// # A component is folded once, and each rule of it separately
///
/// **A component hosts a list of rules, so "the code" and "a rule" stopped being the same
/// thing.** Folding a component's bytes once per rule it hosts is not wrong, and it is two
/// other things that are: quadratic in the rule count — four rules on the 12.34 MiB
/// TypeScript component would fold 49 MiB — and unable to tell "two rules of one component"
/// from "one component named twice", because both are the same bytes twice.
///
/// So the fold is in two parts. Every **distinct component** contributes its bytes once, in a
/// fixed order; then every **rule** contributes which of those components it runs in, which of
/// that component's rules it is, and what it was configured with. The first part is the
/// programs, the second is what is being asked of them, and neither describes the other.
///
/// *Distinct* is by **content**: two references to one artifact by different paths are the
/// same program, and one path read twice across a rewrite is two. That is the same relation
/// `lanekeep_wasm::Loaded::identity` expresses as a blake3 digest, realized here by comparing
/// the bytes rather than by digesting them — the bytes are already in hand, and a digest pass
/// costs a walk over megabytes on a path that runs per LSP request, per MCP call and per
/// `--watch` iteration.
///
/// A rule names its component **by position in that sorted list** rather than by repeating its
/// identity. That is what keeps the two parts from being two descriptions of one thing: a
/// position says nothing about the bytes, so the component fold stays the only place the code
/// reaches the key, and `two_components_cannot_run_together_into_one` keeps testing the
/// delimiting it is about rather than being answered by a digest folded elsewhere.
///
/// Duplicates collapse in both parts: naming one component twice, at the same rule and with the
/// same options, is a configuration difference and not a different program.
///
/// # It folds bytes it is handed, and does not go and read them
///
/// **This is the same property the module half has, and it used to be the one thing the
/// component half did not.** `sandbox.loaded_modules()` is what the loader actually consumed,
/// so a module that changed after it was read still hashes as the source that produced the
/// answer. The component half used to take the *paths* and read them again — a second read,
/// several milliseconds after `describe_components` read the same files to ask them what they
/// are, and before `lanekeep-engine` read them a third time to run them. A file that changed
/// in between would describe one rule, key another and execute a third, and nothing would
/// notice. So the bytes arrive on [`ComponentRule`], read once, and this folds those.
///
/// **Absence is therefore no longer representable here, and that is stronger than the marker
/// it replaces rather than weaker.** This used to fold a present/absent byte, so that "the
/// component is missing" and "the component is there" could not hash alike — §8.2's rule that
/// a run which could not read a rule and one that could must not share a key. A component that
/// cannot be read now fails config load outright: there is no `Config`, so there is no key and
/// no run, which is what that rule was protecting against in the first place.
/// `a_component_that_is_not_there_is_refused_by_position` is where that lives now.
///
/// The bytes are still length-prefixed, and that is unrelated to the marker: a `.wasm` is
/// arbitrary binary and can contain whichever byte a separator would be, so without the length
/// two components could concatenate into one byte sequence.
/// `two_components_cannot_run_together_into_one` is what says so.
///
/// # `components` is empty for a TypeScript config, so this half is JSON-only today
///
/// Only a `lanekeep.json` produces a [`RuleReference::Component`], so a TypeScript config
/// builds no [`ComponentRule`] and there are no component bytes to miss. **The day it can name
/// one, this is the branch that silently stops covering them** — and the shape above is what
/// makes that harder to get wrong than it was: the bytes come from the rules that were built,
/// so whoever teaches the TypeScript path to name a component gets the fold for free rather
/// than having to remember a second list.
fn hash_ruleset(sandbox: &Sandbox, components: &[&ComponentRule]) -> Hash {
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"lanekeep-ruleset-v2");

    if let Some(loaded) = sandbox.loaded_modules() {
        // The map is ordered, so the hash does not depend on load order — which varies with
        // import structure and is not something the user changed.
        for (path, source) in loaded.borrow().iter() {
            hasher.update(path.to_string_lossy().as_bytes());
            hasher.update(&[0]);
            hasher.update(source.as_bytes());
            hasher.update(&[0]);
        }
    }

    // The distinct programs, in the order their bytes sort in — which is a fixed order that
    // depends on nothing outside the bytes themselves, so reordering a config's rules is not a
    // different ruleset. The path is not consulted at all, for either the order or the identity:
    // it is absolute, so it would throw a cache away for moving a checkout, and which component
    // a rule *names* is `hash_config`'s through the specifier.
    //
    // **The bytes are in the key because "read once" is per reference, not per path.**
    // `component_bytes` reads once per `ResolvedRule`, and nothing deduplicates `rules`, so a
    // config may legitimately name one file twice — `["./r.wasm", {"rule": "./r.wasm", "options":
    // {…}}]` is how a rule is used bare and configured in the same run. If the file is rewritten
    // between those two reads, two `ComponentRule`s carry one path and different bytes, and both
    // execute what they carry. Those are two programs and this folds both, which is the single
    // claim this whole function exists to make.
    //
    // Keying rather than preventing, deliberately. Caching the first read and reusing it would
    // close the window by changing which bytes the second rule *runs*, which is a semantic change
    // to fix a hashing bug — and it cannot make the read atomic either, since there is no
    // snapshot of a live filesystem to take. Hashing what actually ran is the property that was
    // claimed. In the ordinary case both entries have identical bytes and this collapses them.
    let mut distinct: Vec<&[u8]> = components
        .iter()
        .map(|component| component.bytes.as_slice())
        .collect();
    distinct.sort_unstable();
    distinct.dedup();

    hasher.update(b"components");
    length_prefixed(&mut hasher, &(distinct.len() as u64).to_le_bytes());
    for bytes in &distinct {
        length_prefixed(&mut hasher, bytes);
    }

    // What is being asked of those programs: for each rule, which one it runs in, which of that
    // one's rules it is, and what it was configured with. Sorted and deduplicated for the reason
    // the components are — the order a config lists its rules in is `hash_config`'s, and naming
    // the same rule of the same component twice with the same options is one program either way.
    //
    // The component is named by its position in `distinct` rather than by its bytes or a digest
    // of them, so that this fold says nothing about the code and the fold above stays the only
    // place the code reaches the key. Repeating an identity here would leave the component fold
    // provable-by-accident: the delimiting it exists for would be backed up by a second copy of
    // the same information, and the test that asserts it would pass with the delimiting gone.
    //
    // `Err` from the search is unreachable — every slice searched for came out of the very list
    // being searched — and is folded to its insertion point rather than unwrapped, because a
    // panic on a value derived from a user's config is not something this crate does.
    let mut rules: Vec<(usize, u32, &str)> = components
        .iter()
        .map(|component| {
            let bytes = component.bytes.as_slice();
            let at = match distinct.binary_search(&bytes) {
                Ok(at) | Err(at) => at,
            };
            (at, component.index, component.options.as_str())
        })
        .collect();
    rules.sort_unstable();
    rules.dedup();

    hasher.update(b"rules");
    length_prefixed(&mut hasher, &(rules.len() as u64).to_le_bytes());
    for (component, index, options) in rules {
        // Both fixed-width, so neither needs delimiting from the other or from the options
        // that follow.
        hasher.update(&(component as u64).to_le_bytes());
        hasher.update(&index.to_le_bytes());
        length_prefixed(&mut hasher, options.as_bytes());
    }

    *hasher.finalize().as_bytes()
}

/// Hash a variable-length field with its length in front.
///
/// `u64` rather than `usize`, because `usize::to_le_bytes` is four bytes on a 32-bit host
/// and eight on a 64-bit one, and a hash that depends on the width of the machine that
/// computed it is not deterministic. The saturating conversion is unreachable — it needs a
/// field larger than 16 exabytes — and is written this way because a panic on user input is
/// not something this crate does.
fn length_prefixed(hasher: &mut blake3::Hasher, bytes: &[u8]) {
    hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
    hasher.update(bytes);
}

/// Hash the configuration values.
///
/// Canonicalized properly, because these *are* structured data: the severity map is ordered
/// so writing the same entries in a different order hashes the same, and the budgets are
/// hashed as numbers rather than as whatever the user typed.
///
/// `resolved` is a JSON config's rule references and their options, and is empty for a
/// TypeScript one — where the same information lives inside the config module's own source
/// and reaches the key through `ruleset_hash` instead. `docs/architecture.md` §8.1 lists
/// options under this hash, and until the JSON path resolved its references in Rust there
/// was nowhere they could be read from: they were interpolated into the synthetic entry
/// module, which `Sandbox::eval_module` evaluates directly rather than through the loader,
/// so it is not among the modules `hash_ruleset` walks. Editing an option in a
/// `lanekeep.json` therefore invalidated nothing, and a warm run kept answering the previous
/// configuration.
fn hash_config(
    include: &[String],
    exclude: &[String],
    severity: &BTreeMap<RuleId, Severity>,
    limits: &Limits,
    resolved: &[ResolvedRule],
    suppressions: &SuppressionPolicy,
) -> Hash {
    let mut hasher = blake3::Hasher::new();
    hasher.update(b"lanekeep-config-v1");

    for (label, globs) in [
        (b"include".as_slice(), include),
        (b"exclude".as_slice(), exclude),
    ] {
        hasher.update(label);
        // Include and exclude are order-insensitive in effect, so hashing them in the
        // order written would invalidate on a reordering that changes nothing.
        let mut sorted: Vec<&String> = globs.iter().collect();
        sorted.sort();
        for glob in sorted {
            hasher.update(glob.as_bytes());
            hasher.update(&[0]);
        }
    }

    hasher.update(b"severity");
    for (id, level) in severity {
        hasher.update(id.to_string().as_bytes());
        hasher.update(&[0]);
        hasher.update(level.as_str().as_bytes());
        hasher.update(&[0]);
    }

    hasher.update(b"limits");
    for value in [
        limits.rule_timeout.as_millis(),
        limits.global_timeout.as_millis(),
        limits.memory_bytes as u128,
    ] {
        hasher.update(&value.to_le_bytes());
    }

    // The suppression policy, folded as the structured data it is: presence and value of
    // `max_expiry_days`, not the JSON a user happened to write. This is the sixth input, on
    // purpose — `AGENTS.md` records the shape of the alternative: a value a config can say
    // that reaches no hash is a warm run answering the previous configuration.
    hasher.update(b"suppressions");
    hasher.update(&[u8::from(suppressions.require_expiry)]);
    match suppressions.max_expiry_days {
        Some(days) => {
            hasher.update(&[1]);
            hasher.update(&days.to_le_bytes());
        }
        None => {
            hasher.update(&[0]);
        }
    }
    hasher.update(&[u8::from(suppressions.forbid_file_scope)]);

    // In the order written, which over-invalidates on a reordering that changes nothing —
    // rules are sorted by ID before they are reported, so their position is not an input to
    // any result. That is the same asymmetry `hash_ruleset` documents: a recompute costs
    // time, and serving a result computed under a different configuration costs correctness.
    hasher.update(b"rules");
    for rule in resolved {
        length_prefixed(&mut hasher, rule.specifier.as_bytes());
        // An explicit discriminant for which form the config wrote, because `"x"` and
        // `{"rule": "x"}` are different configurations — one uses a rule as it comes, the
        // other configures it with `null`, and a factory reading `options?.strict` behaves
        // differently under the two. Omitting the tag would leave them distinguished only by
        // the incidental fact that an absent field and a serialized `null` are different
        // lengths, which is true and is not something to depend on.
        if let Some(options) = &rule.options {
            hasher.update(&[1]);
            length_prefixed(&mut hasher, json::literal(options).as_bytes());
        } else {
            hasher.update(&[0]);
        }
    }

    *hasher.finalize().as_bytes()
}

/// A `./`-relative specifier from the root to a file inside it.
fn relative_specifier(root: &Path, file: &Path) -> Option<String> {
    let file = file.canonicalize().ok()?;
    let relative = file.strip_prefix(root).ok()?;
    let joined = relative
        .components()
        .map(|c| c.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/");
    Some(format!("./{joined}"))
}

/// Build a sandbox able to load configuration from a rules root.
///
/// # Errors
///
/// Returns [`ConfigError::Unreadable`] if the sandbox cannot be constructed.
pub fn sandbox_for(
    root: &RuleRoot,
    typescript: std::sync::Arc<dyn lanekeep_js::Language>,
    javascript: std::sync::Arc<dyn lanekeep_js::Language>,
) -> Result<Sandbox, ConfigError> {
    let limits = Limits::default();
    Sandbox::with_modules(
        limits,
        RunClock::start(limits.global_timeout),
        root.clone(),
        typescript,
        javascript,
    )
    .map_err(|e| ConfigError::Unreadable {
        path: root.path().display().to_string(),
        detail: e.to_string(),
    })
}

/// Where a config file is expected, relative to a project root.
#[must_use]
pub fn default_config_paths(project_root: &Path) -> Vec<PathBuf> {
    [
        // First, so a project holding both is not silently checked against the other one.
        "lanekeep.json",
        "lanekeep.config.ts",
        "lanekeep.config.js",
        "lanekeep.config.mjs",
    ]
    .iter()
    .map(|name| project_root.join(name))
    .collect()
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::sync::Arc;

    use lanekeep_lang_js::{JavaScript, TypeScript};

    use super::*;

    struct Fixture {
        dir: PathBuf,
    }

    impl Fixture {
        fn new(name: &str, files: &[(&str, &str)]) -> Self {
            let dir = std::env::temp_dir().join(format!("lanekeep-config-{name}"));
            let _ = fs::remove_dir_all(&dir);
            fs::create_dir_all(&dir).expect("creates dir");
            let fixture = Self { dir };
            fixture.write_all(files);
            fixture
        }

        fn write_all(&self, files: &[(&str, &str)]) {
            for (path, contents) in files {
                let full = self.dir.join(path);
                if let Some(parent) = full.parent() {
                    fs::create_dir_all(parent).expect("creates parent");
                }
                fs::write(&full, contents).expect("writes");
            }
        }

        fn load_config(&self) -> Result<Config, ConfigError> {
            self.load_named("lanekeep.config.ts")
        }

        fn load_json(&self) -> Result<Config, ConfigError> {
            self.load_named("lanekeep.json")
        }

        fn load_named(&self, name: &str) -> Result<Config, ConfigError> {
            load_from(&self.dir, name)
        }

        /// A sandbox over this fixture, with nothing loaded into it.
        ///
        /// For the component half of `ruleset_hash`, whose tests want a fold over bytes rather
        /// than over rules: the files they name are a few bytes long and are not components at
        /// all, which is what lets them assert on separators, ordering and absence without
        /// building a real artifact apiece. Going through `load` would refuse every one of them
        /// long before the hash was reached.
        fn empty_sandbox(&self) -> Sandbox {
            let root = RuleRoot::new(&self.dir).expect("canonicalizes");
            sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox")
        }

        /// Copy one of `lanekeep-wasm`'s committed fixture components into this fixture.
        ///
        /// By path at run time rather than `include_bytes!`, because `lanekeep-wasm` excludes
        /// its whole `tests/` tree from the published package — a compile-time include would
        /// make this crate fail to build for anyone who vendored it, where a copy that is only
        /// reached by a test fails nowhere else.
        fn write_component(&self, at: &str, fixture: &str) {
            let from = Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("../lanekeep-wasm/tests/fixtures")
                .join(format!("{fixture}.wasm"));
            let full = self.dir.join(at);
            if let Some(parent) = full.parent() {
                fs::create_dir_all(parent).expect("creates parent");
            }
            fs::copy(&from, &full).expect("the fixture ships");
        }

        /// A component-backed rule over a file inside this fixture.
        ///
        /// **The bytes are read when this is called, not when the hash is taken**, which is
        /// the property `hash_ruleset` now has and is why every test below that edits a file
        /// calls this again afterwards. Reading at hash time is exactly the bug that shape
        /// removes: the hash would then be over a read nobody else made.
        fn component(&self, name: &str) -> ComponentRule {
            self.component_at(name, 0)
        }

        /// The same, naming one of a multi-rule component's rules.
        ///
        /// Separate from [`Fixture::component`] rather than a parameter on it, because rule `0`
        /// is what every test that is not about the index means, and spelling a `0` at a dozen
        /// call sites would make the index look like something those tests had chosen.
        fn component_at(&self, name: &str, index: u32) -> ComponentRule {
            let path = self.dir.join(name);
            // `expect`, not `unwrap_or_default`: a mistyped name would otherwise become empty
            // bytes, and two tests here compare hashes that would then be equal for the wrong
            // reason — `the_ruleset_hash_ignores_where_a_component_sits` and
            // `..._ignores_the_order_and_the_repetition_of_a_component` both assert *equality*,
            // so they pass vacuously against two empty files. The engine's `backed_by` says the
            // same thing for the same reason.
            let bytes = fs::read(&path).expect("the component file is where the test put it");
            ComponentRule {
                path,
                index,
                options: "null".to_owned(),
                bytes: bytes.into(),
                // No map: these fixtures are hand-written `.wasm` bytes with nothing beside
                // them, and a map is not a `ruleset_hash` input, so it is outside every claim
                // these tests make.
                source_map: None,
                // Irrelevant to what is under test here — these tests drive `hash_ruleset`
                // directly rather than through `Engine::caching` — but `true` is the honest
                // answer: a real `load` is what every one of these fixtures simulates.
                counted_in_ruleset_hash: true,
            }
        }
    }

    impl Drop for Fixture {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.dir);
        }
    }

    /// Load a config with the rules root at a chosen directory.
    ///
    /// Separate from [`Fixture::load_named`] because the confinement tests need a root that is
    /// *inside* the fixture, so that something the fixture wrote is genuinely outside it.
    fn load_from(dir: &Path, name: &str) -> Result<Config, ConfigError> {
        let root = RuleRoot::new(dir).expect("canonicalizes");
        let sandbox =
            sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
        load(&sandbox, &root, &dir.join(name))
    }

    /// The same, with a built-in component table installed.
    ///
    /// Separate rather than a parameter on every caller, because "no built-in ships as a
    /// component" is what the rest of this suite means and should keep saying.
    fn load_with_components(
        dir: &Path,
        name: &str,
        components: lanekeep_js::BuiltinComponent,
    ) -> Result<Config, ConfigError> {
        let root = RuleRoot::new(dir)
            .expect("canonicalizes")
            .with_builtin_components(components);
        let sandbox =
            sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
        load(&sandbox, &root, &dir.join(name))
    }

    /// The `metadata` fixture's bytes, served as though they were embedded in the binary.
    ///
    /// Read at run time rather than `include_bytes!`, for the reason [`Fixture::write_component`]
    /// records: `lanekeep-wasm` excludes its whole `tests/` tree from the published package, and
    /// a compile-time include would put a path that does not exist for a vendored checkout into
    /// this crate's source. A `OnceLock` is what turns a run-time read into the `&'static [u8]`
    /// a [`lanekeep_js::BuiltinComponent`] has to return.
    fn built_in_component_bytes() -> &'static [u8] {
        static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
        BYTES.get_or_init(|| {
            fs::read(
                Path::new(env!("CARGO_MANIFEST_DIR"))
                    .join("../lanekeep-wasm/tests/fixtures/metadata.wasm"),
            )
            .expect("the fixture ships")
        })
    }

    /// The `two-rules` fixture's bytes, on the same terms as [`built_in_component_bytes`].
    fn shared_component_bytes() -> &'static [u8] {
        static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
        BYTES.get_or_init(|| {
            fs::read(
                Path::new(env!("CARGO_MANIFEST_DIR"))
                    .join("../lanekeep-wasm/tests/fixtures/two-rules.wasm"),
            )
            .expect("the fixture ships")
        })
    }

    /// The `js-globals` fixture's bytes: 12.4 MiB of StarlingMonkey hosting five rules.
    ///
    /// The only component in the tree whose *compilation* costs enough to be measured against a
    /// budget, which is what `compiling_a_component_is_not_charged_to_the_run_budget` needs.
    fn big_component_bytes() -> &'static [u8] {
        static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
        BYTES.get_or_init(|| {
            fs::read(
                Path::new(env!("CARGO_MANIFEST_DIR"))
                    .join("../lanekeep-wasm/tests/fixtures/js-globals.wasm"),
            )
            .expect("the fixture ships")
        })
    }

    /// The `two-faced` fixture's bytes: a component whose `rules()` and `metadata()` disagree.
    fn two_faced_component_bytes() -> &'static [u8] {
        static BYTES: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
        BYTES.get_or_init(|| {
            fs::read(
                Path::new(env!("CARGO_MANIFEST_DIR"))
                    .join("../lanekeep-wasm/tests/fixtures/two-faced.wasm"),
            )
            .expect("the fixture ships")
        })
    }

    /// A built-in table standing in for `lanekeep_rules`.
    ///
    /// A stub rather than the real table: which rules have migrated is not what these tests are
    /// about, and naming one would make the next migration edit assertions unrelated to it.
    ///
    /// Three entries, and the last two are the shape this crate has to get right: one artifact,
    /// two names, a different index each. `shared-second` is the case a lookup returning only
    /// bytes cannot express — it is the *second* rule of a component whose first rule is a
    /// perfectly good one to run by mistake.
    fn built_in_components(name: &str) -> Option<(&'static [u8], u32)> {
        match name {
            "metadata" => Some((built_in_component_bytes(), 0)),
            "shared-first" => Some((shared_component_bytes(), 0)),
            "shared-second" => Some((shared_component_bytes(), 1)),
            // An index past the end of what that component hosts, for the disagreement a
            // drifted table would produce.
            "shared-missing" => Some((shared_component_bytes(), 7)),
            // A component that answers its own id differently from its two exports.
            "two-faced" => Some((two_faced_component_bytes(), 0)),
            // The big one, at `probe/context` — index 1 of five, and the rule of that fixture
            // with an ordinary `check`.
            "big" => Some((big_component_bytes(), 1)),
            _ => None,
        }
    }

    /// A minimal, valid rule module.
    fn rule(id: &str) -> String {
        format!(
            "import {{ defineRule }} from 'lanekeep';\n\
             export default defineRule({{\n\
               id: '{id}',\n\
               query: '(identifier) @id',\n\
               card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
               check(ctx, m) {{ ctx.report(m.id); }},\n\
             }});\n"
        )
    }

    /// A rule factory: what `{ "rule": ..., "options": ... }` and `noRestrictedImports({...})`
    /// both name. The options are captured and ignored; what matters here is that a value
    /// reached the rule.
    fn factory_rule(id: &str) -> String {
        format!(
            "import {{ defineRule }} from 'lanekeep';\n\
             export default (options) => defineRule({{\n\
               id: '{id}',\n\
               query: '(identifier) @id',\n\
               card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
               check(ctx, m) {{ ctx.report(m.id); }},\n\
             }});\n"
        )
    }

    fn config_with(body: &str) -> String {
        format!(
            "import {{ defineConfig }} from 'lanekeep';\n\
             import rule from './rule';\n\
             export default defineConfig({{ {body} }});\n"
        )
    }

    #[test]
    fn loads_a_valid_config() {
        let fixture = Fixture::new(
            "valid",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with(
                        "include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], rules: [rule]",
                    ),
                ),
            ],
        );

        let config = fixture.load_config().expect("loads");
        assert_eq!(config.include, ["src/**/*.ts"]);
        assert_eq!(config.exclude, ["**/*.test.ts"]);
        assert_eq!(config.rules.len(), 1);
        assert_eq!(config.rules[0].id.to_string(), "local/example");
        assert_eq!(config.rules[0].card.message, "no");
        assert!(!config.rules[0].has_reduce);
    }

    /// A team can group its rules under its own namespace, which `local/` alone does not
    /// allow — everything project-authored ends up in one bucket regardless of who wrote it.
    #[test]
    fn a_declared_namespace_is_accepted() {
        let fixture = Fixture::new(
            "declared-namespace",
            &[
                ("rule.ts", &rule("pera/no-numeric-sizes")),
                (
                    "lanekeep.config.ts",
                    &config_with("namespaces: ['pera'], rules: [rule]"),
                ),
            ],
        );

        let config = fixture.load_config().expect("loads");
        assert_eq!(config.rules[0].id.to_string(), "pera/no-numeric-sizes");
        assert!(!config.rules[0].id.is_built_in());
    }

    /// And the property that made a closed set worth having in the first place: a namespace
    /// nobody declared is a typo, and it fails at load rather than becoming a valid ID that
    /// silently matches nothing.
    #[test]
    fn an_undeclared_namespace_is_rejected() {
        let fixture = Fixture::new(
            "undeclared-namespace",
            &[
                ("rule.ts", &rule("lanekep/no-default-export")),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("an undeclared namespace should be refused")
            .to_string();
        assert!(error.contains("lanekep"), "{error}");
        assert!(
            error.contains("namespaces"),
            "should say how to fix it: {error}"
        );
    }

    /// `lanekeep/` stays reserved, so a rule's origin is readable from its ID alone.
    #[test]
    fn the_lanekeep_namespace_cannot_be_claimed() {
        let fixture = Fixture::new(
            "reserved-namespace",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with("namespaces: ['lanekeep'], rules: [rule]"),
                ),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("claiming the reserved namespace should be refused")
            .to_string();
        assert!(error.contains("reserved"), "{error}");
    }

    /// A rule with no language of its own targets both TypeScript dialects, because in a
    /// React codebase most TypeScript is `.tsx`.
    #[test]
    fn a_rule_defaults_to_both_typescript_dialects() {
        let fixture = Fixture::new(
            "default-languages",
            &[
                ("rule.ts", &rule("local/example")),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let config = fixture.load_config().expect("loads");
        assert_eq!(config.rules[0].languages, ["typescript", "tsx"]);
    }

    /// One or several, both spelled the way a rule author would write them.
    #[test]
    fn a_rule_may_declare_one_language_or_several() {
        for (declaration, expected) in [
            ("language: 'tsx',", vec!["tsx"]),
            (
                "language: ['typescript', 'tsx'],",
                vec!["typescript", "tsx"],
            ),
        ] {
            let module = format!(
                "import {{ defineRule }} from 'lanekeep';\n\
                 export default defineRule({{\n\
                   id: 'local/example',\n\
                 {declaration}\n\
                   query: '(identifier) @id',\n\
                   card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
                   check(ctx, m) {{ ctx.report(m.id); }},\n\
                 }});\n"
            );
            let fixture = Fixture::new(
                "language-forms",
                &[
                    ("rule.ts", &module),
                    ("lanekeep.config.ts", &config_with("rules: [rule]")),
                ],
            );

            let config = fixture.load_config().expect("loads");
            assert_eq!(config.rules[0].languages, expected, "{declaration}");
        }
    }

    #[test]
    fn a_rule_without_a_check_function_is_rejected() {
        // The failure JSON extraction exists to catch. Without this the rule loads, never
        // fires, and looks exactly like the code being clean.
        //
        // The handler is named `onMatch` rather than a misspelling of `check`, because the
        // spell checker flags a real typo in source even inside a fixture — and allowing it
        // globally to keep the joke would be a poor trade. What matters is that `check` is
        // absent, not how it came to be.
        let fixture = Fixture::new(
            "no-check",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/typo',\n\
                       query: '(identifier) @id',\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       onMatch(ctx, m) {},\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let err = fixture.load_config().expect_err("must be rejected");
        let rendered = err.to_string();
        assert!(rendered.contains("check"), "{rendered}");
        assert!(rendered.contains("never report"), "{rendered}");
    }

    #[test]
    fn a_rule_with_a_bare_id_is_rejected() {
        let fixture = Fixture::new(
            "bare-id",
            &[
                ("rule.ts", &rule("example")),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );
        let rendered = fixture
            .load_config()
            .expect_err("must be rejected")
            .to_string();
        assert!(rendered.contains("namespace"), "{rendered}");
    }

    #[test]
    fn a_rule_with_an_unusable_card_is_rejected() {
        let fixture = Fixture::new(
            "bad-card",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/empty',\n\
                       query: '(identifier) @id',\n\
                       card: { message: '', remediation: '', examples: { bad: '', good: '' } },\n\
                       check() {},\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );
        assert!(fixture.load_config().is_err());
    }

    #[test]
    fn a_missing_default_export_says_so() {
        // The engine catches this at link time, before extraction runs, and its message is
        // better than a generic one would be — it names the module and the missing export.
        let fixture = Fixture::new(
            "no-default",
            &[
                ("rule.ts", &rule("local/x")),
                ("lanekeep.config.ts", "export const notDefault = 1;\n"),
            ],
        );
        let rendered = fixture
            .load_config()
            .expect_err("must be rejected")
            .to_string();
        assert!(rendered.contains("default"), "{rendered}");
    }

    #[test]
    fn a_default_export_that_is_not_an_object_says_so() {
        // This one does reach our own check: the export exists, so the engine is happy,
        // and only the shape is wrong.
        let fixture = Fixture::new(
            "default-not-object",
            &[
                ("rule.ts", &rule("local/x")),
                ("lanekeep.config.ts", "export default 42;\n"),
            ],
        );
        let rendered = fixture
            .load_config()
            .expect_err("must be rejected")
            .to_string();
        assert!(rendered.contains("export default"), "{rendered}");
    }

    #[test]
    fn config_severity_overrides_what_the_rule_declares() {
        let fixture = Fixture::new(
            "severity",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with("rules: [rule], severity: { 'local/example': 'warn' }"),
                ),
            ],
        );
        let config = fixture.load_config().expect("loads");
        assert_eq!(config.rules[0].severity, Severity::Warn);
    }

    #[test]
    fn timeouts_fall_back_to_the_defaults() {
        let fixture = Fixture::new(
            "timeouts-default",
            &[
                ("rule.ts", &rule("local/example")),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );
        let config = fixture.load_config().expect("loads");
        assert_eq!(config.limits, Limits::default());
    }

    #[test]
    fn timeouts_can_be_overridden() {
        let fixture = Fixture::new(
            "timeouts-set",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with("rules: [rule], timeouts: { rule: 2000, global: 30000 }"),
                ),
            ],
        );
        let config = fixture.load_config().expect("loads");
        assert_eq!(config.limits.rule_timeout, Duration::from_secs(2));
        assert_eq!(config.limits.global_timeout, Duration::from_secs(30));
    }

    // --- components -----------------------------------------------------------------

    #[test]
    fn a_component_reference_resolves_to_a_spec_carrying_its_own_metadata() {
        // Every field below comes from the component's own `metadata` export and from
        // nowhere else — there is no config syntax carrying any of it, which is the whole
        // reason the export exists.
        let fixture = Fixture::new("component-metadata", &[]);
        fixture.write_component("rules/metadata.wasm", "metadata");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["./rules/metadata.wasm"]}"#,
        )]);

        let config = fixture
            .load_json()
            .expect("a component reference is resolvable");

        let rule = &config.rules[0];
        assert_eq!(rule.id.to_string(), "fixture/metadata");
        assert_eq!(
            rule.queries.get("rust"),
            Some(&"(call_expression) @call".to_owned())
        );
        assert_eq!(rule.languages, ["rust"]);
        assert_eq!(rule.card.message, "a fixture");
        assert_eq!(rule.card.remediation, "do the other thing");
        // All four, and the fixture sets all four to different values on purpose. `raw_rule_from`
        // assigns them from a plain struct literal, so a dropped or swapped field is not a type
        // error — asserting two of the four leaves the other two mapped by nothing, and both
        // mutations pass. This is the shape of the Task 1 finding recurring one layer up.
        assert_eq!(rule.gates.path_matches, ["src/**/*.rs"]);
        assert_eq!(rule.gates.path_not_matches, ["**/generated/**"]);
        assert_eq!(rule.gates.file_contains, ["call"]);
        assert_eq!(rule.gates.file_not_contains, ["skip"]);
        assert_eq!(rule.timeout, Some(Duration::from_millis(1500)));
        assert!(
            !rule.has_reduce,
            "the fixture answers `has-reduce` with false, and the config must take that \
             answer rather than assuming one"
        );
        let component = rule
            .component
            .as_ref()
            .expect("the bytes travel with the rule");
        assert_eq!(
            component.bytes.as_slice(),
            fs::read(fixture.dir.join("rules/metadata.wasm"))
                .expect("the fixture is there")
                .as_slice(),
            "the rule carries the component it was described from"
        );
        // This crate is the only one that can truthfully answer this: `describe_components`'s
        // output is exactly what `build` folds into `ruleset_hash`, a few lines below where the
        // rule this test just built came from. `Engine::caching` (`lanekeep-engine`) trusts this
        // flag rather than re-deriving it, so a `false` here would silently take every
        // component-backed run's cache off — and nothing outside this crate can tell, because a
        // hand-built `ComponentRule` looks identical otherwise. Paired with
        // `an_uncounted_component_is_not_counted_in_ruleset_hash` below, this closes both
        // mutants of `ComponentRule::counted_in_ruleset_hash` inside this crate's own suite: this
        // one alone only kills `replace ... with false`, since nothing here is `false` for a
        // mutant hardcoding `true` to disagree with.
        assert!(
            component.counted_in_ruleset_hash(),
            "a component `load` resolved must be counted in `ruleset_hash`"
        );
    }

    #[test]
    fn all_four_gates_survive_extraction_from_a_typescript_module() {
        // A rule module declaring all four gates, each set to a different value on purpose.
        // `EXTRACT` passes a rule's `gates` object through and `Gates` deserializes it under
        // `rename_all = "camelCase"` — asserting two of the four would leave the other two
        // mapped by nothing, and both a dropped-EXTRACT-field and a dropped-deserialize-field
        // mutant would pass.
        let fixture = Fixture::new(
            "ts-module-gates",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/example',\n\
                       query: '(identifier) @id',\n\
                       gates: {\n\
                         pathMatches: ['src/**/*.rs'],\n\
                         pathNotMatches: ['**/generated/**'],\n\
                         fileContains: ['call'],\n\
                         fileNotContains: ['skip'],\n\
                       },\n\
                       card: { message: 'no', remediation: 'do this', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.id); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );
        let config = fixture.load_config().expect("loads");
        assert_eq!(config.rules[0].gates.path_matches, ["src/**/*.rs"]);
        assert_eq!(config.rules[0].gates.path_not_matches, ["**/generated/**"]);
        assert_eq!(config.rules[0].gates.file_contains, ["call"]);
        assert_eq!(config.rules[0].gates.file_not_contains, ["skip"]);
    }

    /// The two entry points differ in exactly one observable way, and what it is worth ranges
    /// from tens of milliseconds to seconds.
    ///
    /// `load` has nowhere to write, so it compiles each component only to discard the
    /// compilation, and the engine compiles the same bytes again at prepare time.
    /// [`load_with`] given a [`LoadOptions::artifacts`] root leaves a `.cwasm` under
    /// `COMPONENT_CACHE_PATH` that both this load and the engine's own loader map — measured at
    /// ~58 ms per component per load before, and at TypeScript parity after, against components
    /// of about 26 KB. The shared TypeScript component is 12.4 MiB and about six seconds, so the
    /// same difference is three orders of magnitude wider there.
    ///
    /// Asserted on the artifact rather than on a duration: a timing assertion on a loaded machine
    /// is a flake, and the file either exists or it does not. Both directions are asserted,
    /// because a change making *every* load write would pass a one-sided test while putting a
    /// cache directory somewhere its caller never named — which is what `load` must not do, since
    /// it is handed a rules root and a rules root is not a place to write. A caller that *does*
    /// own the directory says so: `lanekeep-testkit` names its own throwaway project, which is
    /// the difference between choosing a location and guessing one.
    #[test]
    fn only_a_load_given_a_project_root_caches_what_it_compiled() {
        let files = &[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["./rules/metadata.wasm"]}"#,
        )];

        let plain = Fixture::new("artifact-cache-absent", files);
        plain.write_component("rules/metadata.wasm", "metadata");
        plain.load_json().expect("the component resolves");
        assert!(
            !plain.dir.join(lanekeep_wasm::COMPONENT_CACHE_PATH).exists(),
            "`load` names no project root, so it must not write a cache directory into one"
        );

        let cached = Fixture::new("artifact-cache-present", files);
        cached.write_component("rules/metadata.wasm", "metadata");
        let root = RuleRoot::new(&cached.dir).expect("canonicalizes");
        let sandbox =
            sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
        load_with(
            &sandbox,
            &root,
            &cached.dir.join("lanekeep.json"),
            LoadOptions {
                artifacts: Some(&cached.dir),
                ..LoadOptions::default()
            },
        )
        .expect("the component resolves");

        let artifacts = cached.dir.join(lanekeep_wasm::COMPONENT_CACHE_PATH);
        let written: Vec<_> = fs::read_dir(&artifacts)
            .expect("the cache directory is there")
            .filter_map(|entry| entry.ok().map(|e| e.path()))
            .filter(|path| path.extension().is_some_and(|ext| ext == "cwasm"))
            .collect();
        assert_eq!(
            written.len(),
            1,
            "one component was described, so one artifact should be cached; found {written:?}"
        );
    }

    /// Compiling a component is not charged to the run budget.
    ///
    /// **The invariant this protects is determinism, not speed.** The global budget bounds rule
    /// execution; compiling WebAssembly to machine code is host work whose cost depends on the
    /// machine and on whether `.lanekeep/components` is warm. Charging it to that budget made a
    /// cold run and a warm run over *identical input* take different exits — 12.4 MiB is seconds
    /// to compile and microseconds to map — which puts the compile cache into
    /// `(bytes, path, ruleset, config, tracked reads)`, where it has no term.
    ///
    /// **The ratio is what makes this a test rather than a race.** `js-globals.wasm` is the
    /// 12.4 MiB StarlingMonkey fixture: measured 6.2 s to compile in a release build and about
    /// twice that in a debug one, against guest work here — five `rules()` entries, five
    /// `metadata` reads — of well under a millisecond. A 1 s budget therefore sits three orders
    /// of magnitude above what the clocked phase spends and an order of magnitude below what the
    /// unclocked one does, so no plausible machine makes this decide the wrong way. Before the
    /// split it failed; there is no arrangement of this fixture under which it passes by luck.
    ///
    /// No artifacts directory, deliberately: with one, the second run of this test would map
    /// rather than compile and the test would stop testing anything.
    ///
    /// Reached as a *built-in* rather than by path so that one of the fixture's five rules is
    /// described rather than all of them — `probe/cross` is `reduce`-only and `build_rule`
    /// rightly refuses a rule with neither pass, which is a different failure and would mask
    /// this one.
    #[test]
    fn compiling_a_component_is_not_charged_to_the_run_budget() {
        let fixture = Fixture::new("config-compile-unclocked", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.ts"], "namespaces": ["probe"],
                "timeouts": {"global": 1000},
                "rules": ["lanekeep/big"]}"#,
        )]);

        let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect("a 1 s budget bounds guest work, and compiling is not guest work");
        assert_eq!(
            config.rules.len(),
            1,
            "the component's rule has to have been described, or nothing was clocked at all"
        );
        assert_eq!(config.rules[0].id.to_string(), "probe/context");
    }

    /// The compilation budget's arithmetic, driven with synthetic durations.
    ///
    /// **The branch this covers went a whole round untested**, on the reasoning that a fixture
    /// slow enough to trip a ten-minute budget would cost ten minutes to run. That is true of an
    /// end-to-end test and false of the comparison, which is why the comparison is a pure
    /// function now: everything below runs in microseconds and would have failed against
    /// `if false && elapsed > allowed`, the mutation that produced no failures at all.
    ///
    /// The budget is a parameter here rather than the shipped constant, deliberately: what is
    /// under test is the comparison, and a test that took its expectations from
    /// `COMPILE_BUDGET_PER_COMPONENT` would agree with itself if that constant became zero. The
    /// shipped value is a judgment against measurements and is documented where it is declared.
    #[test]
    fn the_compile_budget_is_a_comparison_against_a_scaling_allowance() {
        /// A budget with no relationship to the shipped one, so nothing below can be satisfied
        /// by arithmetic that ignores its argument.
        const BUDGET: Duration = Duration::from_micros(250);

        // Under, at, and over — the boundary included, because `>` and `>=` are the two
        // plausible spellings and only one of them is written.
        assert_eq!(compile_overrun(Duration::ZERO, 1, BUDGET), None);
        assert_eq!(compile_overrun(BUDGET, 1, BUDGET), None);
        assert!(
            compile_overrun(BUDGET + Duration::from_micros(1), 1, BUDGET).is_some(),
            "a microsecond past the allowance is past it"
        );

        // And it scales, which is the whole reason the count is a parameter: a config with three
        // components is not held to one component's allowance.
        let three = BUDGET * 3;
        assert_eq!(compile_overrun(three, 3, BUDGET), None);
        assert!(compile_overrun(three, 2, BUDGET).is_some());
        assert!(
            compile_overrun(three + Duration::from_micros(1), 3, BUDGET).is_some(),
            "three components get three allowances and not a fourth"
        );

        // A count of zero has no allowance at all. Unreachable — the check runs after a
        // component was pushed — and asserted because "scales with the count" has to mean
        // something at the bottom of the range too.
        assert!(compile_overrun(Duration::from_micros(1), 0, BUDGET).is_some());
    }

    #[test]
    fn the_compile_budget_message_says_what_it_is_and_what_will_not_help() {
        const BUDGET: Duration = Duration::from_micros(250);

        let detail = compile_overrun(BUDGET * 9, 2, BUDGET)
            .expect("nine allowances against two components is an overrun");

        // The numbers a reader needs to tell "this machine is slow" from "this is a hang".
        assert!(
            detail.contains("compiling the rule components took"),
            "{detail}"
        );
        assert!(detail.contains("2 of them"), "{detail}");

        // And the two things that distinguish this diagnostic from the global budget's, which
        // is the reason it exists rather than being folded into that one: the global message
        // ends "narrow what is being checked", which cannot help against a fixed compile cost.
        assert!(
            detail.contains("not of running any rule"),
            "the message has to say this is not a rule's fault: {detail}"
        );
        assert!(
            detail.contains("narrowing what is checked will not help"),
            "and that the other budget's advice does not apply: {detail}"
        );
        assert!(
            detail.contains(".lanekeep/components"),
            "and where the remedy actually is: {detail}"
        );
    }

    /// And the pass *calls* it, which the two tests above cannot say.
    ///
    /// **The gap they leave is the one the reviewer's mutation actually sat in.** Disabling the
    /// comparison inside `compile_overrun` now fails them both; deleting the `if let` that calls
    /// it would not, because a pure function tested in isolation says nothing about whether
    /// anything reached it. The budget is a parameter for exactly this reason — a zero budget
    /// makes the first component an overrun, so the call site is reachable in milliseconds
    /// rather than in the ten minutes the shipped value would need.
    ///
    /// `world-shape.wasm` rather than the 12.4 MiB one: what is under test is that the check
    /// runs, and the smallest component that compiles at all is the fastest way to find out.
    #[test]
    fn the_compilation_pass_checks_its_budget() {
        let fixture = Fixture::new("config-compile-budget-call", &[]);
        fixture.write_component("rules/probe.wasm", "world-shape");

        let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
        let engine = WasmEngine::new().expect("the shipped configuration builds an engine");
        let loader = lanekeep_wasm::ComponentLoader::without_cache();
        let resolved = vec![ResolvedRule {
            specifier: "./rules/probe.wasm".to_owned(),
            // From the *canonical* root, which is what `json::classify` builds and what
            // `RuleRoot::confine` compares against — the fixture's own `dir` is not canonical on
            // macOS, where `/var` is a symlink to `/private/var`.
            reference: RuleReference::Component(root.path().join("rules/probe.wasm")),
            options: None,
        }];

        // A budget of zero: any elapsed time at all is past it, so the first component overruns.
        // `Compiled` holds a `wasmtime::Component` and so is not `Debug`, which `expect_err`
        // needs; matched rather than unwrapped.
        let Err((position, detail)) =
            compile_components(&root, &resolved, &engine, &loader, Duration::ZERO)
        else {
            panic!("no compilation finishes in zero time");
        };
        assert_eq!(position, 0, "the diagnostic names the entry that overran");
        assert!(
            detail.contains("compiling the rule components took"),
            "and it is the compilation diagnostic rather than a load failure: {detail}"
        );

        // The same pass under the shipped budget completes, so the failure above is the budget
        // and not the fixture — without this, a component that simply failed to load would
        // satisfy every assertion above.
        let compiled = compile_components(
            &root,
            &resolved,
            &engine,
            &loader,
            COMPILE_BUDGET_PER_COMPONENT,
        )
        .expect("the same component compiles fine under the shipped budget");
        assert_eq!(compiled.len(), 1);
    }

    /// Four references to one shared component deserialize it once, not once per reference.
    ///
    /// `docs/architecture.md` §15 names this as a defect rather than a property:
    /// [`compile_components`] calls [`ComponentLoader::load_mapped`] once per rule *reference*,
    /// so a config naming every rule of a shared component deserializes the same bytes
    /// repeatedly. The loader is deliberately lock-free — `&self` throughout, so parallel loads
    /// have no contention — so the dedup belongs here rather than in the loader, keyed on the
    /// component's content identity: the blake3 of its bytes, the same
    /// [`lanekeep_wasm::Loaded::identity`] [`RuleSet::add`] already shares instances on.
    #[test]
    fn four_references_to_one_component_load_it_once() {
        let fixture = Fixture::new("config-load-one-component", &[]);
        fixture.write_component("rules/shared.wasm", "world-shape");

        let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
        let engine = WasmEngine::new().expect("the shipped configuration builds an engine");
        let loader = lanekeep_wasm::ComponentLoader::without_cache();
        // Four references to the same component path — the shape of a config naming every rule
        // of one shared component, which is what the four migrated built-ins are.
        let path = root.path().join("rules/shared.wasm");
        let resolved: Vec<ResolvedRule> = (0..4)
            .map(|_| ResolvedRule {
                specifier: "./rules/shared.wasm".to_owned(),
                reference: RuleReference::Component(path.clone()),
                options: None,
            })
            .collect();

        let compiled = compile_components(
            &root,
            &resolved,
            &engine,
            &loader,
            COMPILE_BUDGET_PER_COMPONENT,
        )
        .expect("the shared component compiles");

        assert_eq!(compiled.len(), 4, "one Compiled per reference");
        assert_eq!(
            loader.compilations(),
            1,
            "one component compiled once, not once per reference"
        );
        assert_eq!(
            loader.embedded_loads(),
            1,
            "and deserialized once — the memo hands one Loaded to every reference"
        );
        assert!(
            Arc::ptr_eq(&compiled[0].admitted, &compiled[1].admitted),
            "the same Loaded is handed to the second reference"
        );
        assert!(
            Arc::ptr_eq(&compiled[0].admitted, &compiled[3].admitted),
            "and to every one after it"
        );
    }

    /// A caller's `--timeout` has to govern config load, because config load runs guest code.
    ///
    /// **Asserts the raise, which is the direction that can fail.** `AGENTS.md` records why: a
    /// test that only *lowers* a budget passes against a budget that is ignored, because the run
    /// completes either way and completion is what such a test asserts. Raising is different —
    /// the un-overridden load must fail first, so the override is the only thing that can make
    /// the second one succeed.
    ///
    /// This is the `--timeout` trap recurring in a phase that did not exist when it was first
    /// found. The flag used to be applied to the `Config` *after* `load` returned, one statement
    /// below a config load that had already instantiated, configured and read `metadata` from
    /// every component under the config file's number. A component whose `configure` overran
    /// failed with a message ending "raise it with `--timeout`", and raising it changed nothing.
    ///
    /// The 50 ms against a burn of roughly a third of a second is a ratio, not a deadline: a
    /// slower machine only makes the breach breach harder, and the raised case has seconds of
    /// room. Both halves go through `load_with` rather than the CLI, because the CLI is where the
    /// bug was and a test that reproduced its structure would inherit it.
    #[test]
    fn a_raised_global_timeout_governs_config_load_and_not_only_the_run() {
        let files: &[(&str, &str)] = &[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "timeouts": {"global": 50},
                "rules": [{"rule": "./rules/metadata.wasm", "options": {"burn": true}}]}"#,
        )];
        let fixture = Fixture::new("config-load-budget", files);
        fixture.write_component("rules/metadata.wasm", "metadata");
        let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
        let sandbox =
            sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
        let config_path = fixture.dir.join("lanekeep.json");

        // The config's own budget is far below what the fixture spends in `configure`, so the
        // phase breaches. Asserted on the message as well as on the failure, because a fixture
        // that failed to load for some unrelated reason would satisfy `is_err` and would make
        // the raise below prove nothing.
        let breached = load_with(&sandbox, &root, &config_path, LoadOptions::default())
            .expect_err("50 ms is far below what the fixture's `configure` spends");
        let text = breached.to_string();
        assert!(
            text.contains("budget"),
            "the breach must be the budget rather than something incidental, got: {text}"
        );

        // And raising it is what that message tells the user to do.
        load_with(
            &sandbox,
            &root,
            &config_path,
            LoadOptions {
                global_timeout: Some(Duration::from_secs(30)),
                ..LoadOptions::default()
            },
        )
        .expect("a raised budget must reach the phase that breached under the lower one");
    }

    #[test]
    fn a_built_in_that_ships_as_a_component_resolves_without_a_path() {
        // The same claim as the test above, for the reference a *user* writes. `lanekeep init`
        // scaffolds `"lanekeep/<name>"`, and two of the rules that spelling names are compiled
        // components — so this is the shape every real config takes, where a `.wasm` path is
        // the shape a project rule takes.
        let fixture = Fixture::new("builtin-component-load", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/metadata"]}"#,
        )]);

        let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect("a built-in component is resolvable by specifier");

        let rule = &config.rules[0];
        // Everything about the rule is the component's own answer, exactly as for a path
        // reference. Nothing in the config said any of it.
        assert_eq!(rule.id.to_string(), "fixture/metadata");
        assert_eq!(
            rule.queries.get("rust"),
            Some(&"(call_expression) @call".to_owned())
        );
        assert_eq!(rule.languages, ["rust"]);

        let component = rule
            .component
            .as_ref()
            .expect("a built-in component reaches the engine as a component");
        assert_eq!(
            component.bytes.as_slice(),
            built_in_component_bytes(),
            "the rule carries the embedded bytes it was described from"
        );
        // A specifier, not a path: there is no file, so there is nothing to canonicalize. It is
        // relative, which is what keeps it from ever colliding with a confined path — those are
        // absolute.
        assert_eq!(component.path, PathBuf::from("lanekeep/metadata"));
        assert!(
            !component.path.is_absolute(),
            "a built-in's provenance must not look like a resolved path"
        );
        assert!(
            component.counted_in_ruleset_hash(),
            "a built-in component `load` resolved must be counted in `ruleset_hash`"
        );
    }

    /// A built-in names **one** rule of its component, and it is the one the table recorded.
    ///
    /// **The defect this is written against is silent and it is the whole point of the change
    /// that introduced it.** A built-in reference used to contribute every rule its component
    /// hosts, which was indistinguishable from correct while every component hosted one. With a
    /// shared artifact it means `lanekeep/no-default-export` runs four rules — each of them
    /// configured with options meant for one — and a run that reports four rules' violations
    /// where one was configured looks like a thorough tool rather than a broken one.
    ///
    /// `shared-second` is the sharp case: index 1 of a component whose index 0 is a perfectly
    /// good rule to run by mistake. Asserting the *count* alone would pass against a reference
    /// that contributed rule 0 instead, and asserting the id alone would pass against one that
    /// contributed both. Both, together, are what pin it.
    #[test]
    fn a_built_in_contributes_the_one_rule_its_table_recorded() {
        let fixture = Fixture::new("builtin-component-narrowed", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/shared-second"]}"#,
        )]);

        let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect("a rule of a shared component is resolvable by specifier");

        assert_eq!(
            config.rules.len(),
            1,
            "one entry naming one rule of a two-rule component must produce one rule, not the \
             component's whole list: {:?}",
            config
                .rules
                .iter()
                .map(|rule| rule.id.to_string())
                .collect::<Vec<_>>()
        );

        let rule = &config.rules[0];
        assert_eq!(
            rule.id.to_string(),
            "fixture/second",
            "the reference is recorded at index 1 and index 0 is a rule that would run happily"
        );
        assert_eq!(
            rule.component
                .as_ref()
                .expect("a built-in component reaches the engine as a component")
                .index,
            1,
            "the engine dispatches on this, so it has to be the recorded index and not a \
             position in the config"
        );
        // And the *first* rule of the same component is reachable in its own right, so this is
        // narrowing rather than an artifact of only ever asking for one thing. Fetched with
        // `expect` rather than compared through `get`: `assert_ne!` on two `Option`s passes
        // vacuously when the key is absent, which is exactly the case this assertion exists
        // to rule out.
        let query = rule
            .queries
            .get("rust")
            .expect("the narrowed rule targets rust");
        assert_ne!(query, "(call_expression) @0");
    }

    /// The other index of the same artifact, so "it narrows" is not "it always picks index 1".
    #[test]
    fn each_rule_of_one_shared_component_is_reachable_as_itself() {
        let fixture = Fixture::new("builtin-component-both", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/shared-first", "lanekeep/shared-second"]}"#,
        )]);

        let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect("two rules of one artifact both resolve");

        let ids: Vec<String> = config
            .rules
            .iter()
            .map(|rule| rule.id.to_string())
            .collect();
        assert_eq!(ids, vec!["fixture/first", "fixture/second"]);

        let indices: Vec<u32> = config
            .rules
            .iter()
            .filter_map(|rule| rule.component.as_ref().map(|component| component.index))
            .collect();
        assert_eq!(indices, vec![0, 1], "each names its own slot");

        // One artifact, read once per reference and carried by value, so both rules hold the
        // same bytes — which is what lets `ruleset_hash` collapse them and `RuleSet::add` give
        // them one instance.
        let bytes: Vec<&[u8]> = config
            .rules
            .iter()
            .filter_map(|rule| rule.component.as_ref().map(|c| c.bytes.as_slice()))
            .collect();
        assert_eq!(bytes.len(), 2);
        assert_eq!(bytes[0], bytes[1], "two rules, one component");
    }

    /// A table recording an index the component does not have is refused, naming the disagreement.
    ///
    /// The failure mode a name-to-index table has that a lookup through the component does not:
    /// it can drift. Refused here rather than left to `RuleSet::add`, whose message would be
    /// about a slot — nobody reading "index 7 is out of range" would go looking for a built-in
    /// table that had moved.
    #[test]
    fn a_table_recording_an_index_its_component_does_not_have_is_refused() {
        let fixture = Fixture::new("builtin-component-drifted", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/shared-missing"]}"#,
        )]);

        let error = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect_err("a recorded index the component does not have cannot be dispatched");

        let rendered = error.to_string();
        assert!(
            rendered.contains("lanekeep/shared-missing"),
            "the refusal has to name the entry: {rendered}"
        );
        assert!(
            rendered.contains("index 7"),
            "and the index it could not find: {rendered}"
        );
        assert!(
            rendered.contains("hosting 2 rule(s)"),
            "and what the component actually hosts, which is the other half of a \
             disagreement: {rendered}"
        );
        assert!(
            rendered.contains("disagree"),
            "the diagnostic is about two things not matching, not about a bad number: \
             {rendered}"
        );
    }

    /// A component has to answer its own id the same way twice.
    ///
    /// `world rule` splits `rules` from `metadata` because a rule's id must be knowable before
    /// the rule is configured, and `metadata` is read after `configure`. Two exports therefore
    /// answer one question, and until this check nothing compared them.
    ///
    /// **What a disagreement costs, and why it is silent.** The slot is registered under the id
    /// `rules()` reported and the `RuleSpec` is built from `metadata()`, so the rule *runs* under
    /// one name and is *reported* under another. Nothing fails: a violation appears, carrying an
    /// id that a suppression comment or a `--rule` filter naming the configured rule will not
    /// match, and neither of those says so.
    ///
    /// The fixture exists for this and nothing else — every other component in
    /// `crates/lanekeep-wasm/tests/fixtures/` answers consistently, which is exactly why none of
    /// them can catch a host that never asked. It also reaches `rule_specifier`, whose fallback
    /// nothing else exercises: this diagnostic is raised in the description pass, after the loop
    /// that had the entry's specifier in hand.
    #[test]
    fn a_component_that_answers_two_different_ids_for_one_rule_is_refused() {
        let fixture = Fixture::new("component-two-faced", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/two-faced"]}"#,
        )]);

        let error = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect_err("a component whose two accounts of itself disagree cannot be loaded");

        let rendered = error.to_string();
        assert!(
            rendered.contains("lanekeep/two-faced"),
            "the refusal has to name the config entry, which is what a reader can act on: \
             {rendered}"
        );
        assert!(
            rendered.contains("fixture/enumerated"),
            "and the id it was registered under: {rendered}"
        );
        assert!(
            rendered.contains("fixture/described"),
            "and the id its metadata answered, or a reader cannot see what disagreed: \
             {rendered}"
        );
    }

    /// And a component that agrees with itself loads, so the check above is not rejecting
    /// everything.
    ///
    /// The pair matters more than usual here: an agreement check written as an unconditional
    /// refusal would pass the test above and fail nothing else in this file, because most of
    /// this suite's components are reached by path rather than as built-ins.
    #[test]
    fn a_component_that_agrees_with_itself_is_not_refused() {
        let fixture = Fixture::new("component-consistent", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/metadata"]}"#,
        )]);

        let config = load_with_components(&fixture.dir, "lanekeep.json", built_in_components)
            .expect("a component whose two exports agree must load");
        assert_eq!(config.rules[0].id.to_string(), "fixture/metadata");
    }

    /// A component whose `rules()` answers nothing is refused, and the refusal names the entry.
    ///
    /// The check is reached through [`no_rules_detail`] rather than a `.wasm` fixture that
    /// answers `rules()` with an empty list, because the question is whether an empty id list and
    /// a specifier produce the refusal — nothing a component has to run to answer. Deleting the
    /// branch in [`describe_components`] used to survive the whole suite; this reaches it
    /// directly, the way `a_component_naming_no_language_is_refused` reaches `validate_metadata`.
    #[test]
    fn a_component_hosting_no_rules_is_refused_with_its_specifier() {
        let error = no_rules_detail(&[], "./rules/empty.wasm")
            .expect_err("an empty rule list is nothing to run");
        assert!(
            error.contains("./rules/empty.wasm"),
            "the refusal has to name the entry, which is what a reader can act on: {error}"
        );
        assert!(
            error.contains("hosts no rules"),
            "and what is wrong with it: {error}"
        );
    }

    /// And a component hosting rules is not refused, so the check above is not an unconditional
    /// refusal. The pair is what makes the first test mean something: a `no_rules_detail` that
    /// always erred would pass it and fail here.
    #[test]
    fn a_component_hosting_rules_is_not_refused() {
        assert!(
            no_rules_detail(&["fixture/one".to_owned()], "./rules/one.wasm").is_ok(),
            "a non-empty id list is a component with something to run"
        );
    }

    #[test]
    fn the_same_specifier_is_a_module_in_a_build_where_no_component_ships() {
        // The pair, and it is the assertion that makes the one above mean something. With no
        // component table installed, `lanekeep/metadata` is an ordinary built-in module
        // specifier — and no such module ships, so it is refused as a missing rule rather than
        // silently becoming something else. A `classify` that ignored the lookup would pass the
        // test above and fail here.
        let fixture = Fixture::new("builtin-component-absent", &[]);
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"include": ["**/*.rs"], "namespaces": ["fixture"],
                "rules": ["lanekeep/metadata"]}"#,
        )]);

        let error = fixture
            .load_json()
            .expect_err("nothing ships under that name in this build");

        let rendered = error.to_string();
        assert!(
            rendered.contains("lanekeep/metadata"),
            "the refusal has to name the specifier: {rendered}"
        );
    }

    /// The other half of the pair above. `load` is not the only way to build a
    /// `ComponentRule` — `ComponentRule::uncounted` is the door this crate hands an embedder or
    /// a test that attaches a component outside `load` — and it has to answer honestly too, or
    /// a mutant hardcoding `counted_in_ruleset_hash` to `true` would pass every test in this
    /// crate: nothing above ever exercises a value that is genuinely `false`.
    #[test]
    fn an_uncounted_component_is_not_counted_in_ruleset_hash() {
        let component = ComponentRule::uncounted(
            PathBuf::from("rules/mine.wasm"),
            0,
            "null".to_owned(),
            b"\0asm".to_vec(),
        );
        assert!(
            !component.counted_in_ruleset_hash(),
            "bytes nobody hashed must not claim to be counted"
        );
    }

    // --- an empty language list ---------------------------------------------------------
    //
    // A rule runs only on a file whose language it names, so an empty list is not "every
    // language", it is *no file at all* — and silently: the rule loads, matches nothing and
    // reports nothing, which is what a clean codebase looks like. `wit/world.wit` declares
    // that the host refuses one at load; both ways a rule can arrive have to be held to it,
    // and the check is one piece of code in `build_rule` precisely so that they are.

    #[test]
    fn a_typescript_rule_naming_no_language_is_refused() {
        let fixture = Fixture::new(
            "empty-languages-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/silent',\n\
                       language: [],\n\
                       query: '(identifier) @id',\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.id); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("a rule that can never run must not load");
        let rendered = error.to_string();
        assert!(rendered.contains("local/silent"), "{rendered}");
        assert!(rendered.contains("names no language"), "{rendered}");
    }

    #[test]
    fn a_component_naming_no_language_is_refused() {
        // The same refusal on the other path, driven through the two functions the wasm path
        // uses — `raw_rule_from` turns what a guest answered into a rule declaration, and
        // `build_rule` validates it. It stops short of a real guest for one reason: no
        // committed fixture answers an empty list, and adding a `.wasm` artifact whose only
        // purpose is to be rejected before it ever runs buys nothing this does not.
        let described = Described {
            raw: raw_rule_from(
                lanekeep_wasm::bindings::types::RuleMetadata {
                    id: "fixture/silent".to_owned(),
                    languages: Vec::new(),
                    severity: "error".to_owned(),
                    card: lanekeep_wasm::bindings::types::RuleCard {
                        message: "m".to_owned(),
                        remediation: "r".to_owned(),
                        examples: lanekeep_wasm::bindings::types::RuleExamples {
                            bad: "a".to_owned(),
                            good: "b".to_owned(),
                        },
                    },
                    queries: vec![lanekeep_wasm::bindings::types::QueryFor {
                        language: "rust".to_owned(),
                        query: "(call_expression) @call".to_owned(),
                    }],
                    gates: lanekeep_wasm::bindings::types::RuleGates {
                        path_matches: Vec::new(),
                        path_not_matches: Vec::new(),
                        file_contains: Vec::new(),
                        file_not_contains: Vec::new(),
                    },
                    timeout: None,
                },
                true,
                false,
            ),
            component: ComponentRule {
                path: PathBuf::from("silent.wasm"),
                index: 0,
                options: "null".to_owned(),
                bytes: Vec::new().into(),
                source_map: None,
                // This test drives `build_rule` directly, below `describe_components` and
                // `hash_ruleset` both — irrelevant to either, so `true` for the same reason
                // `Fixture::component` gives it.
                counted_in_ruleset_hash: true,
            },
        };

        let declared = BTreeSet::from(["fixture".to_owned()]);
        let error = build_rule(
            described.raw,
            1,
            "lanekeep.json",
            &BTreeMap::new(),
            &declared,
            Some(described.component),
        )
        .expect_err("a component that can never run must not load");

        let rendered = error.to_string();
        assert!(rendered.contains("fixture/silent"), "{rendered}");
        assert!(rendered.contains("names no language"), "{rendered}");
    }

    // --- a language whose query is missing, in either direction ---------------------------
    //
    // One rule names one query per language it targets, so a rule can span grammars that do
    // not share node vocabulary. A declared language with no query of its own would run on
    // nothing, and a query for a language the rule does not target would never run — the
    // same silent failure the empty-`languages` refusal guards. Both directions are refused
    // in `build_rule`, naming the language.

    #[test]
    fn a_typescript_rule_declaring_a_language_without_a_query_is_refused() {
        let fixture = Fixture::new(
            "missing-query-for-language-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/multi',\n\
                       language: ['typescript', 'python'],\n\
                       query: { typescript: '(call_expression) @call' },\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("a language with no query of its own must not load");
        let rendered = error.to_string();
        assert!(rendered.contains("local/multi"), "{rendered}");
        assert!(rendered.contains("python"), "{rendered}");
    }

    #[test]
    fn a_typescript_rule_declaring_a_query_for_an_undeclared_language_is_refused() {
        let fixture = Fixture::new(
            "undeclared-language-query-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/multi',\n\
                       language: ['typescript'],\n\
                       query: { typescript: '(call_expression) @call', python: '(call) @call' },\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("a query for a language the rule does not target must not load");
        let rendered = error.to_string();
        assert!(rendered.contains("local/multi"), "{rendered}");
        assert!(rendered.contains("python"), "{rendered}");
    }

    // --- an empty or malformed query, refused with the message these tests pin -----------
    //
    // The empty-query refusals went unasserted for a while: the one fixture that reached
    // them grew an unusable card too, the card check fires first, and nothing else drove
    // them — so deleting both `trim().is_empty()` blocks left the whole suite green. These
    // pin the messages through the real TypeScript pipeline.

    #[test]
    fn a_typescript_rule_with_an_empty_query_is_refused() {
        let fixture = Fixture::new(
            "empty-query-string-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/empty',\n\
                       query: '',\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("an empty query can never match, so it must not load");
        let rendered = error.to_string();
        assert!(rendered.contains("local/empty"), "{rendered}");
        assert!(rendered.contains("has an empty `query`"), "{rendered}");
    }

    #[test]
    fn a_typescript_rule_with_an_empty_query_for_one_language_is_refused() {
        let fixture = Fixture::new(
            "empty-query-for-language-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/multi',\n\
                       language: ['typescript', 'python'],\n\
                       query: { typescript: '(call_expression) @call', python: '   ' },\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("an empty query for one language can never match on it");
        let rendered = error.to_string();
        assert!(rendered.contains("local/multi"), "{rendered}");
        assert!(rendered.contains("empty `query` for"), "{rendered}");
        assert!(rendered.contains("python"), "{rendered}");
    }

    #[test]
    fn a_query_of_the_wrong_shape_is_refused_naming_the_field() {
        // The refusal has to say what a `query` may be. An untagged enum reported "data did
        // not match any variant of untagged enum RawQueries" here — a private type's name,
        // with the field and the expected shapes gone.
        let fixture = Fixture::new(
            "malformed-query-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/malformed',\n\
                       query: 42,\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("a number is not a query in either shape");
        let rendered = error.to_string();
        assert!(
            rendered.contains("`query` must be a string, or an object"),
            "{rendered}"
        );
        assert!(rendered.contains("not a number"), "{rendered}");
    }

    #[test]
    fn a_query_entry_of_the_wrong_shape_is_refused_naming_its_language() {
        let fixture = Fixture::new(
            "malformed-query-entry-ts",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/malformed',\n\
                       query: { typescript: 5 },\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let error = fixture
            .load_config()
            .expect_err("a number is not a query for a language either");
        let rendered = error.to_string();
        assert!(
            rendered.contains("`query` for `typescript` must be a string"),
            "{rendered}"
        );
        assert!(rendered.contains("not a number"), "{rendered}");
    }

    #[test]
    fn a_component_declaring_a_language_without_a_query_is_refused() {
        // The same refusal on the component path, driven through the two functions the wasm
        // path uses — `raw_rule_from` and `build_rule` — exactly as the empty-languages test
        // drives its refusal.
        let described = Described {
            raw: raw_rule_from(
                lanekeep_wasm::bindings::types::RuleMetadata {
                    id: "fixture/silent".to_owned(),
                    languages: vec!["rust".to_owned(), "go".to_owned()],
                    severity: "error".to_owned(),
                    card: lanekeep_wasm::bindings::types::RuleCard {
                        message: "m".to_owned(),
                        remediation: "r".to_owned(),
                        examples: lanekeep_wasm::bindings::types::RuleExamples {
                            bad: "a".to_owned(),
                            good: "b".to_owned(),
                        },
                    },
                    queries: vec![lanekeep_wasm::bindings::types::QueryFor {
                        language: "rust".to_owned(),
                        query: "(call_expression) @call".to_owned(),
                    }],
                    gates: lanekeep_wasm::bindings::types::RuleGates {
                        path_matches: Vec::new(),
                        path_not_matches: Vec::new(),
                        file_contains: Vec::new(),
                        file_not_contains: Vec::new(),
                    },
                    timeout: None,
                },
                true,
                false,
            ),
            component: ComponentRule {
                path: PathBuf::from("silent.wasm"),
                index: 0,
                options: "null".to_owned(),
                bytes: Vec::new().into(),
                source_map: None,
                counted_in_ruleset_hash: true,
            },
        };

        let declared = BTreeSet::from(["fixture".to_owned()]);
        let error = build_rule(
            described.raw,
            1,
            "lanekeep.json",
            &BTreeMap::new(),
            &declared,
            Some(described.component),
        )
        .expect_err("a language with no query of its own must not load");

        let rendered = error.to_string();
        assert!(rendered.contains("fixture/silent"), "{rendered}");
        assert!(rendered.contains("go"), "{rendered}");
    }

    #[test]
    fn a_component_declaring_a_query_for_an_undeclared_language_is_refused() {
        let described = Described {
            raw: raw_rule_from(
                lanekeep_wasm::bindings::types::RuleMetadata {
                    id: "fixture/silent".to_owned(),
                    languages: vec!["rust".to_owned()],
                    severity: "error".to_owned(),
                    card: lanekeep_wasm::bindings::types::RuleCard {
                        message: "m".to_owned(),
                        remediation: "r".to_owned(),
                        examples: lanekeep_wasm::bindings::types::RuleExamples {
                            bad: "a".to_owned(),
                            good: "b".to_owned(),
                        },
                    },
                    queries: vec![
                        lanekeep_wasm::bindings::types::QueryFor {
                            language: "rust".to_owned(),
                            query: "(call_expression) @call".to_owned(),
                        },
                        lanekeep_wasm::bindings::types::QueryFor {
                            language: "go".to_owned(),
                            query: "(call_expression) @call".to_owned(),
                        },
                    ],
                    gates: lanekeep_wasm::bindings::types::RuleGates {
                        path_matches: Vec::new(),
                        path_not_matches: Vec::new(),
                        file_contains: Vec::new(),
                        file_not_contains: Vec::new(),
                    },
                    timeout: None,
                },
                true,
                false,
            ),
            component: ComponentRule {
                path: PathBuf::from("silent.wasm"),
                index: 0,
                options: "null".to_owned(),
                bytes: Vec::new().into(),
                source_map: None,
                counted_in_ruleset_hash: true,
            },
        };

        let declared = BTreeSet::from(["fixture".to_owned()]);
        let error = build_rule(
            described.raw,
            1,
            "lanekeep.json",
            &BTreeMap::new(),
            &declared,
            Some(described.component),
        )
        .expect_err("a query for a language the rule does not target must not load");

        let rendered = error.to_string();
        assert!(rendered.contains("fixture/silent"), "{rendered}");
        assert!(rendered.contains("go"), "{rendered}");
    }

    #[test]
    fn per_language_queries_survive_extraction_from_a_typescript_module() {
        // A rule declaring one query per language, each set to a different value on purpose —
        // asserting two of the two leaves neither mapped by nothing.
        let fixture = Fixture::new(
            "ts-module-per-language-queries",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/multi',\n\
                       language: ['typescript', 'python'],\n\
                       query: {\n\
                         typescript: '(call_expression) @call',\n\
                         python: '(call) @call',\n\
                       },\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let config = fixture.load_config().expect("loads");
        assert_eq!(
            config.rules[0].queries,
            BTreeMap::from([
                (
                    "typescript".to_owned(),
                    "(call_expression) @call".to_owned()
                ),
                ("python".to_owned(), "(call) @call".to_owned()),
            ])
        );
    }

    #[test]
    fn a_single_string_query_is_expanded_to_every_declared_language() {
        // The sugar shape: one string for every language the rule targets, so `One` becomes
        // one entry per declared language in `build_rule`.
        let fixture = Fixture::new(
            "ts-module-query-sugar",
            &[
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     export default defineRule({\n\
                       id: 'local/multi',\n\
                       language: ['typescript', 'python'],\n\
                       query: '(call_expression) @call',\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check(ctx, m) { ctx.report(m.call); },\n\
                     });\n",
                ),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );

        let config = fixture.load_config().expect("loads");
        assert_eq!(
            config.rules[0].queries,
            BTreeMap::from([
                (
                    "typescript".to_owned(),
                    "(call_expression) @call".to_owned()
                ),
                ("python".to_owned(), "(call_expression) @call".to_owned()),
            ])
        );
    }

    #[test]
    fn a_component_is_held_to_the_same_card_and_query_a_typescript_rule_is() {
        // End to end, through a real guest: `world-shape.wasm` answers `metadata` with an empty
        // card and an empty query, because it is a probe rather than a rule. A component's
        // answers go through `build_rule` exactly as an extracted TypeScript rule's do, so it
        // is refused for the reasons a TypeScript rule would be. The card check fires first,
        // so the card refusal is what this fixture reaches — asserted below, so a reordering
        // that changed which refusal answers does not pass unnoticed. The empty-*query*
        // refusal is pinned by its own tests above, and on the component path by
        // `a_component_with_an_empty_query_for_a_language_is_refused`.
        let fixture = Fixture::new("component-validated", &[]);
        fixture.write_component("rules/probe.wasm", "world-shape");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"], "rules": ["./rules/probe.wasm"]}"#,
        )]);

        let error = fixture
            .load_json()
            .expect_err("a probe is not a usable rule");
        assert!(
            matches!(error, ConfigError::Rule { position: 1, .. }),
            "{error:?}"
        );
        assert!(
            error.to_string().contains("fixture/world-shape"),
            "the component's own id should name it: {error}"
        );
        assert!(
            error.to_string().contains("unusable card"),
            "the card check fires first for this probe: {error}"
        );
    }

    #[test]
    fn a_component_with_an_empty_query_for_a_language_is_refused() {
        // The host gate deliberately admits an empty query string — probe fixtures answer
        // `metadata` with one on purpose — so the refusal belongs to the last gate before a
        // rule runs, `build_rule`, and this drives it through a real guest: the `metadata`
        // fixture's `{"empty-query":true}` flag makes its `metadata` answer a well-formed
        // card and an empty query for its one language.
        let fixture = Fixture::new("component-empty-query", &[]);
        fixture.write_component("rules/probe.wasm", "metadata");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"],
                "rules": [{"rule": "./rules/probe.wasm", "options": {"empty-query": true}}]}"#,
        )]);

        let error = fixture
            .load_json()
            .expect_err("an empty query for a language can never match on it");
        let rendered = error.to_string();
        assert!(rendered.contains("fixture/metadata"), "{rendered}");
        assert!(rendered.contains("empty `query` for"), "{rendered}");
        assert!(rendered.contains("rust"), "{rendered}");
    }

    // --- confinement ------------------------------------------------------------------
    //
    // A rule reference is a string in a config file and a component is *executed*, so where
    // one may point is a trust boundary. The cases below are the sibling's: `crates/
    // lanekeep-js/src/loader.rs` refuses traversal, an absolute path and a symlink out of the
    // root for a module import, and a `.wasm` reference has to be refused for the same reasons
    // — through `RuleRoot::confine`, which is that same check rather than a second one.

    #[test]
    fn a_component_reference_may_not_traverse_out_of_the_rules_root() {
        // Refused whatever is on disk: `secret.wasm` is real and is one directory up. An error
        // that depended on whether the target existed would tell a reader about the filesystem
        // rather than about their config.
        let fixture = Fixture::new("component-traversal", &[]);
        fixture.write_component("secret.wasm", "metadata");
        fs::create_dir_all(fixture.dir.join("project")).expect("creates the inner root");

        for specifier in ["../secret.wasm", "../../secret.wasm", "./../secret.wasm"] {
            fs::write(
                fixture.dir.join("project/lanekeep.json"),
                format!(r#"{{"namespaces": ["fixture"], "rules": ["{specifier}"]}}"#),
            )
            .expect("writes");

            let error = load_from(&fixture.dir.join("project"), "lanekeep.json")
                .expect_err("traversal must not resolve");
            assert!(
                matches!(error, ConfigError::Rule { position: 1, .. }),
                "{specifier} gave {error:?}"
            );
            assert!(
                error.to_string().contains("outside the rules root"),
                "{specifier} gave {error}"
            );
        }
    }

    #[test]
    fn a_component_reference_may_not_be_an_absolute_path() {
        // Built from `temp_dir` rather than written literally: `Path::is_absolute` is
        // platform-specific, so a literal would take a different branch on each platform. What
        // makes this reachable at all is that `Path::join` lets an absolute path replace the
        // base outright, so joining it against the rules root does not confine it.
        let fixture = Fixture::new("component-absolute", &[]);
        fixture.write_component("outside.wasm", "metadata");

        let outside = fixture.dir.join("outside.wasm");
        let inner = fixture.dir.join("project");
        fs::create_dir_all(&inner).expect("creates the inner root");
        // Two platform hazards sit between this path and the check it is here to reach, and
        // both refuse it for a reason that is not confinement.
        //
        // Forward slashes, because `validate_specifier` rejects any specifier containing a
        // backslash — a guard that predates components and exists because a specifier is
        // interpolated into generated JavaScript. A Windows path spelled `C:\Users\...` is
        // therefore refused one layer above `confine`, with a message about quoting. Spelled
        // `C:/Users/...` it is still absolute — Rust accepts either separator on Windows — and
        // it reaches the confinement check this test names. On Unix the replacement is a no-op.
        //
        // Then `serde_json` rather than `format!`, because a backslash also begins an escape
        // inside a JSON string, so an interpolated Windows path makes the config fail to
        // *parse*. Belt and braces: the replacement above already removes them, and encoding
        // properly keeps this true if the path ever carries something else JSON reserves.
        let forward = outside.display().to_string().replace('\\', "/");
        let specifier = serde_json::to_string(&forward).expect("a path is a JSON string");
        fs::write(
            inner.join("lanekeep.json"),
            format!(r#"{{"namespaces": ["fixture"], "rules": [{specifier}]}}"#),
        )
        .expect("writes");

        let error = load_from(&inner, "lanekeep.json").expect_err("an absolute path is refused");
        assert!(
            error.to_string().contains("outside the rules root"),
            "{error}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn a_component_reference_may_not_be_a_symlink_out_of_the_rules_root() {
        // The case a lexical check cannot see, and the reason `confine` canonicalizes rather
        // than only normalizing. `./link.wasm` sits inside the root and looks entirely
        // innocent.
        let fixture = Fixture::new("component-symlink", &[]);
        fixture.write_component("outside.wasm", "metadata");
        let inner = fixture.dir.join("project");
        fs::create_dir_all(&inner).expect("creates the inner root");
        std::os::unix::fs::symlink(fixture.dir.join("outside.wasm"), inner.join("link.wasm"))
            .expect("creates symlink");
        fs::write(
            inner.join("lanekeep.json"),
            r#"{"namespaces": ["fixture"], "rules": ["./link.wasm"]}"#,
        )
        .expect("writes");

        let error = load_from(&inner, "lanekeep.json").expect_err("a symlink out is refused");
        assert!(
            error.to_string().contains("outside the rules root"),
            "{error}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn an_escaping_component_is_refused_before_its_bytes_are_read() {
        // Confinement that happened after the read would already have loaded, compiled and
        // instantiated whatever the reference pointed at — the check would be a report rather
        // than a guard. Pointing at a path that is *unreadable* rather than absent separates
        // the two: read-then-check reports the permission error, check-then-read reports the
        // escape.
        let fixture = Fixture::new("component-escape-before-read", &[]);
        fixture.write_component("outside.wasm", "metadata");
        let outside = fixture.dir.join("outside.wasm");
        fs::set_permissions(
            &outside,
            std::os::unix::fs::PermissionsExt::from_mode(0o000),
        )
        .expect("makes it unreadable");

        let inner = fixture.dir.join("project");
        fs::create_dir_all(&inner).expect("creates the inner root");
        fs::write(
            inner.join("lanekeep.json"),
            r#"{"namespaces": ["fixture"], "rules": ["../outside.wasm"]}"#,
        )
        .expect("writes");

        let error = load_from(&inner, "lanekeep.json").expect_err("refused");
        let rendered = error.to_string();
        assert!(
            rendered.contains("outside the rules root"),
            "the escape must be what stopped it, not the read: {rendered}"
        );
        assert!(
            !rendered.contains("Permission denied"),
            "nothing may be read before the reference is confined: {rendered}"
        );

        // Left readable, or the fixture's own cleanup cannot remove it.
        fs::set_permissions(
            &outside,
            std::os::unix::fs::PermissionsExt::from_mode(0o644),
        )
        .expect("restores");
    }

    /// A TypeScript rule sitting after a component still reaches its own handler.
    ///
    /// **The silent failure this is written against.** `RuleSpec::index` is how the engine
    /// reaches a TypeScript handler — it is spelled `__lanekeepConfig.rules[index].check(...)`
    /// — and a component contributes nothing to that array. Numbering the array separately
    /// from the config would leave rule 2 of a mixed config at array position 1: the call
    /// succeeds, the wrong rule's handler runs, and every violation is reported under a
    /// neighbor's id. Nothing errors and nothing looks wrong.
    ///
    /// So the two numberings are one numbering, and this is what says so end to end: the
    /// TypeScript rule's `index` is its position in the config, and the entry module has that
    /// rule at that position.
    #[test]
    fn a_typescript_rule_after_a_component_keeps_its_own_index() {
        let fixture = Fixture::new(
            "component-mixed-order",
            &[("second.ts", &rule("local/second"))],
        );
        fixture.write_component("rules/metadata.wasm", "metadata");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"],
                "rules": ["./rules/metadata.wasm", "./second"]}"#,
        )]);

        let config = fixture.load_json().expect("loads");

        assert_eq!(config.rules[0].id.to_string(), "fixture/metadata");
        assert_eq!(config.rules[0].index, 0);
        assert_eq!(config.rules[1].id.to_string(), "local/second");
        assert_eq!(
            config.rules[1].index, 1,
            "the TypeScript rule's index is its position in the array the engine indexes"
        );
        assert!(config.rules[1].component.is_none());
    }

    /// One reference, one component, and every rule the component hosts.
    ///
    /// **The rule a config names is not the unit a component is.** A component hosts a list —
    /// which is what makes one 12.34 MiB JavaScript engine worth building rules on rather than
    /// one copy per rule — so describing one reference means enumerating it and then describing
    /// each rule by position. A description that stopped at rule 0 would load cleanly and leave
    /// every later rule of the component configured, cached and never run, which looks exactly
    /// like a codebase that is clean.
    #[test]
    fn one_component_describes_every_rule_it_hosts() {
        let fixture = Fixture::new(
            "component-many-rules",
            &[("second.ts", &rule("local/last"))],
        );
        fixture.write_component("rules/two-rules.wasm", "two-rules");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"],
                "rules": ["./rules/two-rules.wasm", "./second"]}"#,
        )]);

        let config = fixture.load_json().expect("loads");

        let ids: Vec<String> = config.rules.iter().map(|r| r.id.to_string()).collect();
        assert_eq!(ids, ["fixture/first", "fixture/second", "local/last"]);

        // Each rule described as itself, not as its neighbor. The fixture's query is the one
        // field that has nothing to do with its configuration, so two rules collapsing into one
        // description shows up here whatever `configure` did.
        assert_eq!(
            config.rules[0].queries.get("rust"),
            Some(&"(call_expression) @0".to_owned())
        );
        assert_eq!(
            config.rules[1].queries.get("rust"),
            Some(&"(call_expression) @1".to_owned())
        );

        let first = config.rules[0]
            .component
            .as_ref()
            .expect("a component-backed rule");
        let second = config.rules[1]
            .component
            .as_ref()
            .expect("a component-backed rule");
        assert_eq!((first.index, second.index), (0, 1));
        assert_eq!(
            first.bytes, second.bytes,
            "two rules of one component are one artifact, read once"
        );

        // The entry module has one slot for the reference and none for what it turned out to
        // hold, so both rules carry the reference's own position — and the TypeScript rule
        // after them keeps the position it was written at. Numbering `Config::rules` instead
        // would leave `local/last` reaching the component's placeholder, which is `null`.
        assert_eq!(config.rules[0].index, 0);
        assert_eq!(config.rules[1].index, 0);
        assert_eq!(
            config.rules[2].index, 1,
            "a rule after a multi-rule component still indexes the array the engine indexes"
        );
        assert!(config.rules[2].component.is_none());
    }

    /// And the options a reference carries reach every one of them, before metadata is read.
    ///
    /// A reference names a component, and there is no syntax naming one rule inside it, so a
    /// family of rules shipped in one artifact is configured as a family. The fixture echoes
    /// its `tag` back through `metadata`, which is the one export whose answer is allowed to
    /// depend on `configure` — so this also says the two calls happened in that order, for
    /// each rule rather than for the first.
    #[test]
    fn a_multi_rule_components_options_reach_each_of_its_rules() {
        let fixture = Fixture::new("component-many-options", &[]);
        fixture.write_component("rules/two-rules.wasm", "two-rules");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"],
                "rules": [{"rule": "./rules/two-rules.wasm", "options": {"tag": "alpha"}}]}"#,
        )]);

        let config = fixture.load_json().expect("loads");

        let messages: Vec<&str> = config
            .rules
            .iter()
            .map(|r| r.card.message.as_str())
            .collect();
        assert_eq!(
            messages,
            ["fixture/first tag=alpha", "fixture/second tag=alpha"]
        );
        for spec in &config.rules {
            assert_eq!(
                spec.component
                    .as_ref()
                    .expect("a component-backed rule")
                    .options,
                r#"{"tag":"alpha"}"#
            );
        }
    }

    #[test]
    fn a_component_carries_the_options_it_was_configured_with() {
        // A component cannot close over a host-supplied value, so its options travel with it
        // as data — all the way to every worker's `configure`. A rule named with no options is
        // still configured, with `null`, which is the world's own shape for it.
        let fixture = Fixture::new("component-options", &[]);
        fixture.write_component("rules/metadata.wasm", "metadata");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"],
                "rules": [{"rule": "./rules/metadata.wasm", "options": {"allow": ["a.rs"]}}]}"#,
        )]);

        let config = fixture.load_json().expect("loads");
        let component = config.rules[0]
            .component
            .as_ref()
            .expect("a component-backed rule");
        assert_eq!(component.options, r#"{"allow":["a.rs"]}"#);

        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"], "rules": ["./rules/metadata.wasm"]}"#,
        )]);
        let bare = fixture.load_json().expect("loads");
        assert_eq!(
            bare.rules[0]
                .component
                .as_ref()
                .expect("a component-backed rule")
                .options,
            "null"
        );
    }

    #[test]
    fn a_component_that_refuses_its_options_is_refused_at_load() {
        // A misconfigured rule is not a rule that misbehaved, and the difference is what the
        // user can do about it. The guest's own message has to survive to the diagnostic,
        // because it is the only part that names what was wrong with the configuration.
        let fixture = Fixture::new("component-bad-options", &[]);
        fixture.write_component("rules/metadata.wasm", "metadata");
        fixture.write_all(&[(
            "lanekeep.json",
            r#"{"namespaces": ["fixture"],
                "rules": [{"rule": "./rules/metadata.wasm", "options": [1, 2]}]}"#,
        )]);

        let error = fixture
            .load_json()
            .expect_err("the fixture refuses an array");

        assert!(
            matches!(error, ConfigError::Rule { position: 1, .. }),
            "the diagnostic should name which entry: {error:?}"
        );
        assert!(
            error.to_string().contains("expected an object"),
            "the guest's own message should survive: {error}"
        );
    }

    /// A component that cannot be read fails the load, so it never reaches a hash.
    ///
    /// **This is where §8.2's "absence is a dependency" went, and it is stronger here.**
    /// `ruleset_hash` used to fold a present/absent marker per component, so that a missing
    /// one and a present one could not share a cache key; it folds bytes that were already
    /// read now, and cannot see absence at all. It does not need to: the run whose key would
    /// have been wrong does not happen. A missing component is refused before a `Config`
    /// exists, naming which entry, so there is nothing to serve a stale answer to.
    #[test]
    fn a_component_that_is_not_there_is_refused_by_position() {
        let fixture = Fixture::new(
            "component-missing",
            &[
                ("first.ts", &rule("local/first")),
                (
                    "lanekeep.json",
                    r#"{"rules": ["./first", "./rules/gone.wasm"]}"#,
                ),
            ],
        );

        let error = fixture.load_json().expect_err("there are no bytes to run");
        assert!(
            matches!(error, ConfigError::Rule { position: 2, .. }),
            "the diagnostic should name which entry: {error:?}"
        );
        assert!(error.to_string().contains("gone.wasm"), "{error}");
    }

    // --- hashing --------------------------------------------------------------------

    #[test]
    fn the_ruleset_hash_covers_an_imported_helper() {
        // The §8 property, and the reason the loader records what it read rather than the
        // config naming its own inputs. A rule importing a helper has to invalidate when
        // that helper changes — nothing else in the system knows the helper was involved.
        let files: &[(&str, &str)] = &[
            ("helper.ts", "export const QUERY = '(identifier) @id';\n"),
            (
                "rule.ts",
                "import { defineRule } from 'lanekeep';\n\
                 import { QUERY } from './helper';\n\
                 export default defineRule({\n\
                   id: 'local/example',\n\
                   query: QUERY,\n\
                   card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                   check() {},\n\
                 });\n",
            ),
            ("lanekeep.config.ts", ""),
        ];
        let fixture = Fixture::new("helper-hash", files);
        fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);

        let before = fixture.load_config().expect("loads").ruleset_hash;

        fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
        let after = fixture.load_config().expect("loads").ruleset_hash;

        assert_ne!(
            hex(&before),
            hex(&after),
            "changing an imported helper must invalidate the ruleset hash"
        );
    }

    #[test]
    fn the_ruleset_hash_is_stable_when_nothing_changed() {
        let fixture = Fixture::new(
            "stable-hash",
            &[
                ("rule.ts", &rule("local/example")),
                ("lanekeep.config.ts", &config_with("rules: [rule]")),
            ],
        );
        let first = fixture.load_config().expect("loads").ruleset_hash;
        let second = fixture.load_config().expect("loads").ruleset_hash;
        assert_eq!(hex(&first), hex(&second));
    }

    #[test]
    fn the_ruleset_hash_covers_a_components_bytes() {
        // The component half of the same property `the_ruleset_hash_covers_an_imported_helper`
        // asserts for modules: editing the code a rule is made of must invalidate.
        let fixture = Fixture::new("component-bytes", &[("mine.wasm", "\u{0}asm-one")]);
        let sandbox = fixture.empty_sandbox();

        let before = hash_ruleset(&sandbox, &[&fixture.component("mine.wasm")]);
        fixture.write_all(&[("mine.wasm", "\u{0}asm-two")]);
        let after = hash_ruleset(&sandbox, &[&fixture.component("mine.wasm")]);

        assert_ne!(
            hex(&before),
            hex(&after),
            "rebuilding a rule component must invalidate its cached results"
        );
    }

    #[test]
    fn two_rules_from_one_component_fold_its_bytes_once() {
        // Same component, two rules. The bytes must reach ruleset_hash once, or the hash is
        // quadratic in a component's rule count and cannot tell "one component, two rules"
        // from "one component named twice".
        let fixture = Fixture::new("component-two-rules", &[("a.wasm", "\u{0}asm-two-rules")]);
        let sandbox = fixture.empty_sandbox();

        let one = hash_ruleset(
            &sandbox,
            &[
                &fixture.component_at("a.wasm", 0),
                &fixture.component_at("a.wasm", 1),
            ],
        );
        let twice = hash_ruleset(
            &sandbox,
            &[
                &fixture.component_at("a.wasm", 0),
                &fixture.component_at("a.wasm", 0),
            ],
        );
        assert_ne!(
            one, twice,
            "distinct rule indices must not hash the same as the same index twice"
        );

        // And the once-ness the name is about, which the inequality above does not reach: it
        // holds whether the bytes were folded once or twice, so on its own this test survived
        // `distinct.dedup()` being deleted.
        //
        // **Repetition is the only lever that can observe how many times a component was
        // folded, and that is forced by the encoding rather than chosen.** The two halves cannot
        // be compared separately — a hash has no halves — so seeing the component fold's
        // multiplicity means holding the rule fold still while it moves, and the rule fold
        // encodes the rule count. Listing a rule a second time is the one edit that changes
        // nothing there, because rules deduplicate too. So: a component named three times for
        // two rules is folded exactly as it is when it is named twice.
        let listed_again = hash_ruleset(
            &sandbox,
            &[
                &fixture.component_at("a.wasm", 0),
                &fixture.component_at("a.wasm", 1),
                &fixture.component_at("a.wasm", 0),
            ],
        );
        assert_eq!(
            one, listed_again,
            "a component's bytes must reach the fold once however many times it is listed"
        );
    }

    /// A rule index means nothing on its own, so the fold has to say which component it is in.
    ///
    /// The half `two_rules_from_one_component_fold_its_bytes_once` cannot reach. Both rulesets
    /// below hold the same two components and the same two indices — the deal is swapped — so
    /// the component fold is identical between them and a rule fold recording only the index
    /// would sort to the same pair. They run different code: one asks `a` for its second rule
    /// and `b` for its first, the other the reverse.
    #[test]
    fn a_rule_is_folded_against_the_component_it_runs_in() {
        let fixture = Fixture::new(
            "component-rule-pairing",
            &[("a.wasm", "\u{0}asm-a"), ("b.wasm", "\u{0}asm-b")],
        );
        let sandbox = fixture.empty_sandbox();

        let dealt = hash_ruleset(
            &sandbox,
            &[
                &fixture.component_at("a.wasm", 0),
                &fixture.component_at("b.wasm", 1),
            ],
        );
        let swapped = hash_ruleset(
            &sandbox,
            &[
                &fixture.component_at("a.wasm", 1),
                &fixture.component_at("b.wasm", 0),
            ],
        );

        assert_ne!(
            hex(&dealt),
            hex(&swapped),
            "which component a rule index belongs to is part of the ruleset"
        );
    }

    /// And the options a rule was configured with, which decide what a factory rule *is*.
    ///
    /// `hash_config` folds a JSON config's options too, through `resolved`. That is not this
    /// claim: `resolved` is empty for a TypeScript config, and the day that path can name a
    /// component the options would reach no key at all. The code a component runs includes what
    /// it was configured to be, so it is folded where the code is.
    #[test]
    fn the_ruleset_hash_covers_the_options_a_component_was_configured_with() {
        let fixture = Fixture::new("component-options-hash", &[("a.wasm", "\u{0}asm-a")]);
        let sandbox = fixture.empty_sandbox();

        let bare = fixture.component("a.wasm");
        let mut configured = fixture.component("a.wasm");
        configured.options = r#"{"limit":1}"#.to_owned();

        assert_ne!(
            hex(&hash_ruleset(&sandbox, &[&bare])),
            hex(&hash_ruleset(&sandbox, &[&configured])),
            "a component configured differently is a different ruleset"
        );
    }

    #[test]
    fn two_components_cannot_run_together_into_one() {
        // The reason a component's bytes are length-prefixed, and now the only thing that says
        // so — the length is the whole of the delimiting.
        //
        // A module's source is text and its separator is a NUL. A component is arbitrary
        // binary, so there is no byte available to separate one from the next: whichever were
        // chosen could appear inside a component. Without the length, these two rulesets are
        // genuinely different and fold to the identical byte sequence, under an identical
        // component count:
        //
        //   A:  'A' 'A' | 'B' 'B' 'C' 'C'      a = "AA",   b = "BBCC"
        //   B:  'A' 'A' 'B' 'B' | 'C' 'C'      a = "AABB", b = "CC"
        //
        // **The data used to carry a `\x01` and stopped discriminating when it was no longer
        // needed.** The bytes were built around the old present/absent marker acting as the
        // delimiter, so removing the marker made the two rows genuinely different sequences and
        // this test passed with `length_prefixed` deleted. Concatenation is the property; the
        // data has to be a real collision under it.
        //
        // **Re-derived once more when `hash_ruleset` split into a component fold and a rule
        // fold**, and it survived unchanged — which is a fact about the encoding that was
        // chosen and is not a reason to skip the check. The components are ordered by their
        // *bytes* now rather than by their paths, and `"AA" < "BBCC"` exactly as
        // `a.wasm < b.wasm` did, so both rows still fold to `AABBCC`. Ordering them by a digest
        // instead would have made each row's order a coin flip and this collision a one-in-four
        // accident. What the rule fold contributes is identical between the rows — both are two
        // rules, at index 0, with `null` options, in components 0 and 1 — so the length prefix
        // is still the only thing telling the rows apart. Verified by deleting it and watching
        // this test fail, which is the only form the check has.
        //
        // Two different rulesets sharing a cache key is the one failure `docs/architecture.md`
        // §8.1 exists to prevent, so it is asserted here rather than left to the fact that
        // nothing writes a `.wasm` by hand.
        let fixture = Fixture::new("component-run-together", &[("a.wasm", ""), ("b.wasm", "")]);
        let sandbox = fixture.empty_sandbox();

        fixture.write_all(&[("a.wasm", "AA"), ("b.wasm", "BBCC")]);
        let split_early = hash_ruleset(
            &sandbox,
            &[&fixture.component("a.wasm"), &fixture.component("b.wasm")],
        );

        fixture.write_all(&[("a.wasm", "AABB"), ("b.wasm", "CC")]);
        let split_late = hash_ruleset(
            &sandbox,
            &[&fixture.component("a.wasm"), &fixture.component("b.wasm")],
        );

        assert_ne!(
            hex(&split_early),
            hex(&split_late),
            "two components must not be able to concatenate into one byte sequence — the \
             length is the only thing delimiting them, because any separator byte can appear \
             inside a component"
        );
    }

    #[test]
    fn the_ruleset_hash_ignores_where_a_component_sits() {
        // A resolved component path is absolute. Hashing it would mean a cache thrown away by
        // moving a checkout, for a change to nothing a rule can observe — and which component
        // a rule *names* is already `config_hash`'s, through the specifier.
        let fixture = Fixture::new(
            "component-path",
            &[("a.wasm", "\u{0}asm-same"), ("nested/b.wasm", "")],
        );
        fixture.write_all(&[("nested/b.wasm", "\u{0}asm-same")]);
        let sandbox = fixture.empty_sandbox();

        assert_eq!(
            hex(&hash_ruleset(&sandbox, &[&fixture.component("a.wasm")])),
            hex(&hash_ruleset(
                &sandbox,
                &[&fixture.component("nested/b.wasm")]
            )),
            "the same component bytes are the same ruleset wherever they sit"
        );
    }

    #[test]
    fn the_ruleset_hash_ignores_the_order_and_the_repetition_of_a_component() {
        // The "change nothing, assert the key does not move" half. `ruleset_hash` is about the
        // code a run is made of; which rules a config lists, in what order and how often, is
        // `hash_config`'s — where the order is deliberately *not* normalized. Sorting and
        // deduplicating here means a config edit that only reorders costs no recompute.
        let fixture = Fixture::new(
            "component-order",
            &[("one.wasm", "\u{0}asm-one"), ("two.wasm", "\u{0}asm-two")],
        );
        let sandbox = fixture.empty_sandbox();
        let one = fixture.component("one.wasm");
        let two = fixture.component("two.wasm");

        let canonical = hex(&hash_ruleset(&sandbox, &[&one, &two]));
        assert_eq!(
            canonical,
            hex(&hash_ruleset(&sandbox, &[&two, &one])),
            "reordering two components is not a different ruleset"
        );
        assert_eq!(
            canonical,
            hex(&hash_ruleset(&sandbox, &[&one, &two, &one])),
            "naming one component twice is not a different ruleset"
        );
    }

    /// One path can carry two byte sequences, and both have to reach the key — not just the
    /// count of them, but the bytes themselves.
    ///
    /// `component_bytes` reads once per `ResolvedRule` and nothing deduplicates `rules`, so a
    /// config naming one file twice — bare in one entry and with options in another — reads it
    /// twice. A rewrite between those reads produces a pair that carry one path and two
    /// different byte sequences, and both rules go on to execute the bytes they carry.
    ///
    /// **The mutant this data discriminates is a component fold that records *how many* distinct
    /// byte sequences there are but not *what* they are.** The rules fold names a component by
    /// its position in `distinct` and nothing about its code, so it cannot catch that mutant
    /// alone: two rulesets with the same positions, indices and options but different byte
    /// values would hash equal. The comparison below holds the rules fold fixed — both pairs
    /// sort to the same two positions, same index, same options — and varies only the bytes, so
    /// a fold that dropped the bytes makes the two equal. Comparing against a "collapsed" pair
    /// (`[before, before]`) does not isolate the fold, because the rules fold already differs
    /// there (one rule against two) and backstops whatever the component fold did.
    ///
    /// The window is microseconds and the trigger is exotic. It is asserted anyway because the
    /// claim it falsifies — the bytes hashed are the bytes that run — is the one the component
    /// half of `ruleset_hash` exists to make, and a claim with one shape that breaks it is not
    /// quite the claim.
    #[test]
    fn one_path_with_two_byte_sequences_reaches_the_ruleset_hash_as_both() {
        let fixture = Fixture::new("component-torn-read", &[("r.wasm", "\u{0}asm-before")]);
        let sandbox = fixture.empty_sandbox();

        // The first reference's read.
        let before = fixture.component("r.wasm");
        // The file is rewritten, and the second reference reads what is there now. Both carry
        // the same path, because it is the same file.
        fixture.write_all(&[("r.wasm", "\u{0}asm-after")]);
        let after = fixture.component("r.wasm");
        assert_eq!(
            before.path, after.path,
            "the fixture is one file, read twice"
        );
        assert_ne!(
            before.bytes.as_slice(),
            after.bytes.as_slice(),
            "the rewrite is what makes this pair interesting"
        );

        // A third read of the same file, rewritten again to a byte sequence that sorts to the
        // same position `after` does — both precede `before` (`after` < `again` < `before`) — so
        // the rules fold (positions, index, options) is identical to the first pair's. The only
        // thing that differs between the two rulesets is the byte value of the second component.
        fixture.write_all(&[("r.wasm", "\u{0}asm-again")]);
        let again = fixture.component("r.wasm");
        assert_eq!(after.path, again.path, "still one file, read a third time");
        assert_ne!(
            after.bytes.as_slice(),
            again.bytes.as_slice(),
            "the second rewrite is a third byte sequence, not a reread of the second"
        );

        assert_ne!(
            hex(&hash_ruleset(&sandbox, &[&before, &after])),
            hex(&hash_ruleset(&sandbox, &[&before, &again])),
            "two rulesets whose rules fold agrees but whose second component's bytes differ \
             must not key equal — a component fold that hashed the count of distinct programs \
             but not the bytes made these equal"
        );
    }

    #[test]
    fn the_ruleset_hash_still_covers_modules_when_a_component_is_present() {
        // The deviation this change makes from its own plan, asserted rather than described.
        // The plan said the component fold *replaces* the module walk; two built-ins are
        // components and every other rule in this tree is a module, so that would have taken
        // almost the whole ruleset out of the cache key.
        let files: &[(&str, &str)] = &[
            ("rule.ts", &rule("local/example")),
            ("lanekeep.config.ts", ""),
        ];
        let fixture = Fixture::new("component-and-module", files);
        fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
        fixture.write_all(&[("mine.wasm", "\u{0}asm")]);

        let mine = fixture.component("mine.wasm");
        let root = RuleRoot::new(&fixture.dir).expect("canonicalizes");
        let hash_after_loading = |source: &str| {
            fixture.write_all(&[("rule.ts", source)]);
            let sandbox =
                sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
            evaluate_into(&sandbox, &root, &fixture.dir.join("lanekeep.config.ts"))
                .expect("evaluates");
            hash_ruleset(&sandbox, &[&mine])
        };

        assert_ne!(
            hex(&hash_after_loading(&rule("local/example"))),
            hex(&hash_after_loading(&rule("local/renamed"))),
            "a module edit must still invalidate when a component is in the ruleset too"
        );
    }

    #[test]
    fn the_config_hash_ignores_glob_order() {
        // Include and exclude are order-insensitive in effect, so reordering them must not
        // throw away a warm cache for a change that alters nothing.
        let make = |globs: &str, tag: &str| {
            Fixture::new(
                &format!("glob-order-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.config.ts",
                        &config_with(&format!("rules: [rule], include: {globs}")),
                    ),
                ],
            )
            .load_config()
            .expect("loads")
            .config_hash
        };

        assert_eq!(
            hex(&make("['a/**', 'b/**']", "sorted")),
            hex(&make("['b/**', 'a/**' ]", "reversed")),
            "reordering globs must not change the config hash"
        );
    }

    #[test]
    fn the_config_hash_changes_with_severity() {
        let make = |extra: &str, tag: &str| {
            Fixture::new(
                &format!("severity-hash-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.config.ts",
                        &config_with(&format!("rules: [rule]{extra}")),
                    ),
                ],
            )
            .load_config()
            .expect("loads")
            .config_hash
        };

        assert_ne!(
            hex(&make("", "none")),
            hex(&make(", severity: { 'local/example': 'warn' }", "warn")),
            "changing a severity must invalidate"
        );
    }

    #[test]
    fn the_config_hash_changes_with_a_timeout() {
        let make = |extra: &str, tag: &str| {
            Fixture::new(
                &format!("timeout-hash-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.config.ts",
                        &config_with(&format!("rules: [rule]{extra}")),
                    ),
                ],
            )
            .load_config()
            .expect("loads")
            .config_hash
        };

        assert_ne!(
            hex(&make("", "d")),
            hex(&make(", timeouts: { rule: 5000 }", "t"))
        );
    }

    #[test]
    fn the_config_hash_changes_with_a_suppression_policy() {
        // Every key of the block is a `config_hash` input, asserted separately — a fold that
        // dropped one arm would leave a policy edit that invalidates nothing, which is the
        // exact "reaches no hash" shape `AGENTS.md` records. `maxExpiryDays` gets a value
        // change as well as a presence change, because Some(30) and Some(31) must not hash
        // alike any more than None and Some must.
        let make = |extra: &str, tag: &str| {
            Fixture::new(
                &format!("suppression-hash-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.config.ts",
                        &config_with(&format!("rules: [rule]{extra}")),
                    ),
                ],
            )
            .load_config()
            .expect("loads")
            .config_hash
        };

        let none = hex(&make("", "none"));
        assert_ne!(
            none,
            hex(&make(", suppressions: { requireExpiry: true }", "require")),
            "turning on requireExpiry must invalidate"
        );
        assert_ne!(
            none,
            hex(&make(", suppressions: { maxExpiryDays: 30 }", "days")),
            "adding maxExpiryDays must invalidate"
        );
        assert_ne!(
            none,
            hex(&make(", suppressions: { forbidFileScope: true }", "file")),
            "turning on forbidFileScope must invalidate"
        );
        assert_ne!(
            hex(&make(", suppressions: { maxExpiryDays: 30 }", "days30")),
            hex(&make(", suppressions: { maxExpiryDays: 31 }", "days31")),
            "changing maxExpiryDays must invalidate"
        );
    }

    #[test]
    fn the_suppression_policy_is_read_from_a_typescript_config() {
        let fixture = Fixture::new(
            "suppression-policy-ts",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with(
                        "rules: [rule], suppressions: { requireExpiry: true, \
                         maxExpiryDays: 30, forbidFileScope: true }",
                    ),
                ),
            ],
        );
        let config = fixture.load_config().expect("loads");
        assert_eq!(
            config.suppressions,
            SuppressionPolicy {
                require_expiry: true,
                max_expiry_days: Some(30),
                forbid_file_scope: true,
            }
        );
    }

    #[test]
    fn a_zero_max_expiry_days_is_refused() {
        // `build` is the one place both formats construct a `Config`, so one test proves the
        // validation for both — unlike the hashing properties, which are asserted in pairs.
        let fixture = Fixture::new(
            "suppression-zero",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with("rules: [rule], suppressions: { maxExpiryDays: 0 }"),
                ),
            ],
        );
        let error = fixture
            .load_config()
            .expect_err("a zero horizon is refused");
        assert!(format!("{error}").contains("maxExpiryDays"), "{error}");
    }

    /// A JSON rule's options are a cache-key input, and were reaching neither hash.
    ///
    /// The same config in the same directory, one option value edited: before this was
    /// fixed both hashes came back byte-identical, so a warm run kept answering the
    /// previous configuration. `docs/architecture.md` §8.1 lists options under
    /// `config_hash`, and the JSON path is where they are known as data.
    ///
    /// The fixture is rewritten in place rather than built twice under different names.
    /// Two directories would move `ruleset_hash` on their own — it hashes each module's
    /// path alongside its source — which is a difference that looks like the assertion
    /// passing and is not.
    #[test]
    fn the_config_hash_changes_with_a_json_rule_option() {
        let config =
            |options: &str| format!(r#"{{"rules": [{{"rule": "./rule", "options": {options}}}]}}"#);
        let fixture = Fixture::new(
            "json-option-hash",
            &[
                ("rule.ts", &factory_rule("local/example")),
                ("lanekeep.json", &config(r#"{"limit": 1}"#)),
            ],
        );

        let before = fixture.load_json().expect("loads");
        fixture.write_all(&[("lanekeep.json", &config(r#"{"limit": 2}"#))]);
        let after = fixture.load_json().expect("loads");

        assert_ne!(
            hex(&before.config_hash),
            hex(&after.config_hash),
            "editing a rule option must invalidate"
        );
        assert_eq!(
            hex(&before.ruleset_hash),
            hex(&after.ruleset_hash),
            "no module changed, so the ruleset hash must not move — which is exactly why \
             the config hash has to"
        );
    }

    // --- hashing, the JSON path -------------------------------------------------------
    //
    // Matched pairs of the six above. The two formats used to be one mechanism — a JSON
    // config was compiled into the module a TypeScript one is imported by — so asserting
    // these properties once covered both. It no longer does, and these are what replaced
    // that guarantee. A property that holds on one path and not the other is drift, and
    // drift in a cache key is silent: the run completes and answers with yesterday's
    // configuration.

    #[test]
    fn the_ruleset_hash_covers_an_imported_helper_for_json() {
        let fixture = Fixture::new(
            "json-helper-hash",
            &[
                ("helper.ts", "export const QUERY = '(identifier) @id';\n"),
                (
                    "rule.ts",
                    "import { defineRule } from 'lanekeep';\n\
                     import { QUERY } from './helper';\n\
                     export default defineRule({\n\
                       id: 'local/example',\n\
                       query: QUERY,\n\
                       card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
                       check() {},\n\
                     });\n",
                ),
                ("lanekeep.json", r#"{"rules": ["./rule"]}"#),
            ],
        );

        let before = fixture.load_json().expect("loads").ruleset_hash;
        fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
        let after = fixture.load_json().expect("loads").ruleset_hash;

        assert_ne!(
            hex(&before),
            hex(&after),
            "changing an imported helper must invalidate the ruleset hash"
        );
    }

    #[test]
    fn the_ruleset_hash_is_stable_when_nothing_changed_for_json() {
        let fixture = Fixture::new(
            "json-stable-hash",
            &[
                ("rule.ts", &rule("local/example")),
                ("lanekeep.json", r#"{"rules": ["./rule"]}"#),
            ],
        );
        let first = fixture.load_json().expect("loads").ruleset_hash;
        let second = fixture.load_json().expect("loads").ruleset_hash;
        assert_eq!(hex(&first), hex(&second));
    }

    #[test]
    fn the_config_hash_ignores_glob_order_for_json() {
        let make = |globs: &str, tag: &str| {
            Fixture::new(
                &format!("json-glob-order-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.json",
                        &format!(r#"{{"rules": ["./rule"], "include": {globs}}}"#),
                    ),
                ],
            )
            .load_json()
            .expect("loads")
            .config_hash
        };

        assert_eq!(
            hex(&make(r#"["a/**", "b/**"]"#, "sorted")),
            hex(&make(r#"["b/**", "a/**"]"#, "reversed")),
            "reordering globs must not change the config hash"
        );
    }

    /// The same property one level down, for the values only this path can see.
    ///
    /// `serde_json::Map` is a `BTreeMap` in this build, so the options blob serializes in
    /// key order whatever order it was written in. That is a property of a dependency's
    /// feature set rather than of anything written here — `preserve_order` would reverse it
    /// silently, and the only symptom would be a cache that stops hitting.
    #[test]
    fn the_config_hash_ignores_option_key_order() {
        let make = |options: &str, tag: &str| {
            Fixture::new(
                &format!("json-option-order-{tag}"),
                &[
                    ("rule.ts", &factory_rule("local/example")),
                    (
                        "lanekeep.json",
                        &format!(r#"{{"rules": [{{"rule": "./rule", "options": {options}}}]}}"#),
                    ),
                ],
            )
            .load_json()
            .expect("loads")
            .config_hash
        };

        assert_eq!(
            hex(&make(r#"{"a": 1, "b": 2}"#, "sorted")),
            hex(&make(r#"{"b": 2, "a": 1}"#, "reversed")),
            "reordering option keys must not change the config hash"
        );
    }

    #[test]
    fn the_config_hash_changes_with_severity_for_json() {
        let make = |severity: &str, tag: &str| {
            Fixture::new(
                &format!("json-severity-hash-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.json",
                        &format!(r#"{{"rules": ["./rule"], "severity": {severity}}}"#),
                    ),
                ],
            )
            .load_json()
            .expect("loads")
            .config_hash
        };

        assert_ne!(
            hex(&make("{}", "none")),
            hex(&make(r#"{"local/example": "warn"}"#, "warn")),
            "changing a severity must invalidate"
        );
    }

    #[test]
    fn the_config_hash_changes_with_a_timeout_for_json() {
        let make = |timeouts: &str, tag: &str| {
            Fixture::new(
                &format!("json-timeout-hash-{tag}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    (
                        "lanekeep.json",
                        &format!(r#"{{"rules": ["./rule"], "timeouts": {timeouts}}}"#),
                    ),
                ],
            )
            .load_json()
            .expect("loads")
            .config_hash
        };

        assert_ne!(hex(&make("{}", "d")), hex(&make(r#"{"rule": 5000}"#, "t")));
    }

    #[test]
    fn the_config_hash_changes_with_a_suppression_policy_for_json() {
        // The JSON half of the matched pair, and every key gets a turn: a fold that dropped
        // one arm would leave that key's edits invalidating nothing, on the path that knows
        // the policy as data. The fixture is rewritten in place so `ruleset_hash` is provably
        // stable — the two hashes must disagree because the policy changed, not because a
        // module moved.
        let config = |suppressions: &str| {
            format!(r#"{{"rules": ["./rule"], "suppressions": {suppressions}}}"#)
        };

        for (label, edited) in [
            ("requireExpiry", r#"{"requireExpiry": true}"#),
            ("maxExpiryDays", r#"{"maxExpiryDays": 30}"#),
            ("forbidFileScope", r#"{"forbidFileScope": true}"#),
        ] {
            let fixture = Fixture::new(
                &format!("json-suppression-hash-{label}"),
                &[
                    ("rule.ts", &rule("local/example")),
                    ("lanekeep.json", &config("{}")),
                ],
            );

            let before = fixture.load_json().expect("loads");
            fixture.write_all(&[("lanekeep.json", &config(edited))]);
            let after = fixture.load_json().expect("loads");

            assert_ne!(
                hex(&before.config_hash),
                hex(&after.config_hash),
                "editing `{label}` must invalidate"
            );
            assert_eq!(
                hex(&before.ruleset_hash),
                hex(&after.ruleset_hash),
                "no module changed, so the ruleset hash must not move — which is exactly \
                 why the config hash has to"
            );
        }

        // A value change too, not just presence: Some(30) and Some(31) must not hash alike.
        let fixture = Fixture::new(
            "json-suppression-hash-days-value",
            &[
                ("rule.ts", &rule("local/example")),
                ("lanekeep.json", &config(r#"{"maxExpiryDays": 30}"#)),
            ],
        );
        let before = fixture.load_json().expect("loads");
        fixture.write_all(&[("lanekeep.json", &config(r#"{"maxExpiryDays": 31}"#))]);
        let after = fixture.load_json().expect("loads");
        assert_ne!(
            hex(&before.config_hash),
            hex(&after.config_hash),
            "changing maxExpiryDays must invalidate"
        );
        assert_eq!(hex(&before.ruleset_hash), hex(&after.ruleset_hash));
    }

    #[test]
    fn the_suppression_policy_is_read_from_a_json_config() {
        let fixture = Fixture::new(
            "suppression-policy-json",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.json",
                    r#"{"rules": ["./rule"], "suppressions": {"requireExpiry": true, "maxExpiryDays": 30, "forbidFileScope": true}}"#,
                ),
            ],
        );
        let config = fixture.load_json().expect("loads");
        assert_eq!(
            config.suppressions,
            SuppressionPolicy {
                require_expiry: true,
                max_expiry_days: Some(30),
                forbid_file_scope: true,
            }
        );
    }

    /// `"x"` and `{ "rule": "x" }` are different configurations and must not hash alike.
    ///
    /// One uses a rule as it comes; the other configures it, with `null`. A rule factory
    /// reading `options?.strict` behaves differently under the two, so a key that could not
    /// tell them apart would serve one's results for the other. The fixture's default export
    /// is deliberately usable both ways, so the *only* difference between the two runs is
    /// the form the config wrote.
    #[test]
    fn the_config_hash_tells_a_bare_rule_from_a_configured_one() {
        let module = "import { defineRule } from 'lanekeep';\n\
             const built = defineRule({\n\
               id: 'local/example',\n\
               query: '(identifier) @id',\n\
               card: { message: 'no', remediation: 'do this', examples: { bad: 'a', good: 'b' } },\n\
               check(ctx, m) { ctx.report(m.id); },\n\
             });\n\
             export default Object.assign((options) => built, built);\n";

        let make = |rules: &str, tag: &str| {
            Fixture::new(
                &format!("json-rule-form-{tag}"),
                &[
                    ("rule.ts", module),
                    ("lanekeep.json", &format!(r#"{{"rules": [{rules}]}}"#)),
                ],
            )
            .load_json()
            .expect("loads")
            .config_hash
        };

        assert_ne!(
            hex(&make(r#""./rule""#, "bare")),
            hex(&make(r#"{"rule": "./rule"}"#, "configured")),
            "a rule used as it comes and a rule configured with `null` are not the same run"
        );
    }

    /// `config_hash` says *which* rule was configured, not merely that something was.
    ///
    /// Today `ruleset_hash` would notice this on its own, because two references load two
    /// different modules. It is pinned here anyway, because Task 15 turns that hash into a
    /// path-sorted fold over component bytes, and a property held only by the hash that is
    /// about to be rewritten is a property about to be lost quietly. The two configs below
    /// differ in nothing `config_hash` sees except the specifier.
    #[test]
    fn the_config_hash_tells_apart_two_rules_with_the_same_options() {
        let make = |name: &str| {
            Fixture::new(
                &format!("json-which-rule-{name}"),
                &[
                    (
                        &format!("{name}.ts"),
                        &factory_rule(&format!("local/{name}")),
                    ),
                    (
                        "lanekeep.json",
                        &format!(r#"{{"rules": [{{"rule": "./{name}", "options": {{"x": 1}}}}]}}"#),
                    ),
                ],
            )
            .load_json()
            .expect("loads")
            .config_hash
        };

        assert_ne!(
            hex(&make("a")),
            hex(&make("b")),
            "the same options on a different rule is a different configuration"
        );
    }

    /// The two formats saying the same thing produce the same configuration.
    ///
    /// This is the assertion the shared entry module used to make unnecessary. It cannot
    /// compare the hashes — a TypeScript config is itself a module in the rule graph, so
    /// `ruleset_hash` legitimately differs — but everything a run actually does is decided
    /// by the fields below, and those must agree exactly.
    #[test]
    fn the_two_formats_load_the_same_configuration() {
        let typescript = Fixture::new(
            "parity-ts",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.config.ts",
                    &config_with(
                        "rules: [rule], include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], \
                         severity: { 'local/example': 'warn' }, \
                         timeouts: { rule: 2000, global: 30000 }",
                    ),
                ),
            ],
        )
        .load_config()
        .expect("the TypeScript config loads");

        let json = Fixture::new(
            "parity-json",
            &[
                ("rule.ts", &rule("local/example")),
                (
                    "lanekeep.json",
                    r#"{"rules": ["./rule"], "include": ["src/**/*.ts"],
                        "exclude": ["**/*.test.ts"], "severity": {"local/example": "warn"},
                        "timeouts": {"rule": 2000, "global": 30000}}"#,
                ),
            ],
        )
        .load_json()
        .expect("the JSON config loads");

        assert_eq!(typescript.include, json.include);
        assert_eq!(typescript.exclude, json.exclude);
        assert_eq!(typescript.limits, json.limits);
        assert_eq!(typescript.rules, json.rules);
    }

    /// The un-coupling, as a property of the source rather than of a call graph.
    ///
    /// `src/json.rs` names neither the sandbox crate nor any type this crate's root imports
    /// from it. The crate as a whole still depends on it, and deliberately — see the note
    /// above `entry_source`.
    ///
    /// **Grepping for `lanekeep_js` alone is not enough, and the gap is the spelling a
    /// refactor would reach for first.** The `use lanekeep_js::{…}` below is at the crate
    /// root, and a `use` at the root is in scope for every descendant module, so this
    /// compiles inside `json.rs`, reaches the sandbox, and contains no `lanekeep_js` at all:
    ///
    /// ```ignore
    /// use crate::{ConfigError, Sandbox};
    /// fn probe(s: &Sandbox) -> bool { s.eval::<bool>("true").unwrap_or(false) }
    /// ```
    ///
    /// The forbidden names are therefore read out of that import line rather than listed
    /// here, so importing a fifth type from that crate extends this check instead of quietly
    /// outgrowing it.
    ///
    /// What it does not cover, stated rather than left to be discovered: reaching the sandbox
    /// without naming a type, through some crate-level function that takes one. No such
    /// function exists for `json.rs` to call today. This is a source check, not a proof.
    #[test]
    fn the_json_path_names_nothing_from_the_sandbox_crate() {
        let root = include_str!("lib.rs");
        let import = root
            .lines()
            .find(|line| line.starts_with("use lanekeep_js::{"))
            .expect("the crate root imports the sandbox crate in one braced list");

        let mut forbidden: Vec<&str> = import
            .trim_start_matches("use lanekeep_js::{")
            .trim_end_matches("};")
            .split(',')
            .map(str::trim)
            .filter(|name| !name.is_empty())
            .collect();
        assert!(
            forbidden.len() > 1
                && forbidden
                    .iter()
                    .all(|n| n.chars().all(char::is_alphanumeric)),
            "the import list should have parsed into type names: {forbidden:?}"
        );
        forbidden.push("lanekeep_js");

        let source = include_str!("json.rs");
        for name in forbidden {
            assert!(
                !source.contains(name),
                "src/json.rs must resolve a JSON config without the sandbox, and it names \
                 `{name}`"
            );
        }
    }

    #[test]
    fn hex_renders_a_full_hash() {
        assert_eq!(hex(&[0u8; 32]).len(), 64);
        assert_eq!(hex(&[0xab; 32]), "ab".repeat(32));
    }
}