zshrs 0.10.2

The first compiled Unix shell — bytecode VM, worker pool, AOP intercept, SQLite caching
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
# `man zshall` gap audit — current status

Originally probed 47 constructs from the `man zshall` reference. Each entry below was verified by running zshrs (`./target/debug/zshrs -f -c '...'`) against expected zsh behavior. Source-only audit false-positives that already worked (e.g. `${(j: :)arr}`, `${(t)var}`, `${(P)x}`, `<<<`, short-loop `for x in y; { ... }`, `repeat N ( ... )`, `zparseopts`) are not listed.

## Closed (verified against binary)

### Parameter expansion

- `${(f)str}` — split on newlines into array.
- `${(z)str}` / `${(w)str}` — array-producing flags. Handler returns `Value::Array` so `print -l ${(z)a}` splits one-per-line.
- `${(B)x}` — backslash-escape shell metas. New 'B' arm in `BUILTIN_PARAM_FLAG`.
- `${(flags)"literal"}` / `${(flags)'literal'}` — flag operand may be a quoted string literal. `parse_zsh_flag_literal` runs untokenize_preserve_quotes on the lexer-marked word, detects `${(F)"…"}`, emits a `\u{01}`-prefixed operand to `BUILTIN_PARAM_FLAG`. Verified for `(U)`/`(z)`/`(s)`/`(f)` literal forms.
- `RC_EXPAND_PARAM` — `X${arr}Y` → cartesian distribution (`XaY XbY XcY`) when option set; default scalar join (`Xa b cY`) without. New `BUILTIN_CONCAT_DISTRIBUTE` (id 318) handles cartesian; `BUILTIN_CONCAT_SPLICE` (id 319) handles default `${arr[@]}` first/last sticking. `BUILTIN_GET_VAR` returns `Value::Array` when option is set.
- `${arr[@]}` first/last sticking — `print -l X${arr[@]}Y` produces 3 args ("Xa", "b", "cY"). Same path as RC_EXPAND_PARAM but with splice semantics instead of cartesian.

### Special parameters

- `$argv` — array alias for positional params. `set -- a b c; echo $argv` → "a b c".
- `$EPOCHREALTIME` — sub-second epoch. Emits `SECS.UUUUUU`.
- `$RANDOM_FILE` — not a bug; mainline zsh also leaves it empty without `zmodload zsh/random`.

### Test operators

- `[[ a -ef b ]]` — same-inode test. New `BUILTIN_SAME_FILE` (id 315) compares `(dev, inode)` via `fs::metadata`.

### Glob qualifiers

- `*(D)` — per-pattern dotglob. `expand_glob` activates dotglob when 'D' appears in the qualifier string.

### `typeset`

- `typeset -Z N x=val` / `-L N` / `-R N` — width as a separate arg now parsed (in-flag form `-Z5` was already working). Width applied at assignment time.
- `typeset -T VAR var [SEP]` — initial bind splits current `$VAR` (or `=VAL` form) on SEP into array.
- `typeset -T` bidirectional sync — `tied_scalar_to_array` / `tied_array_to_scalar` HashMaps record `(peer, sep)`. `BUILTIN_SET_VAR` mirrors scalar→array; `BUILTIN_SET_ARRAY` mirrors array→scalar. `PATH=/a:/b; typeset -T PATH path; path=(/x /y); echo $PATH` → `/x:/y`.
- `declare -g x=val` from inside a function — `-g` opts out of `local_save_stack` push.

### Grammar

- `time { compound; ... }` — new `BUILTIN_TIME_SUBLIST` (id 316) runs the sublist as a sub-chunk, prints elapsed wall-clock time.
- `{ try } always { finally }` — `compile_zsh`'s `ZshCommand::Try` arm compiles both blocks sequentially; finally runs unconditionally.
- `for var (a b c) cmd` and `for var (a b c) { ... }` — `parse_for` handles the lexer-port quirk that emits parens as a single String token (`\u{88}a b c\u{8a}`).
- `exec {fd}>file` — parser detects `{NAME}` followed by redirop and pops it as varid. New `BUILTIN_OPEN_NAMED_FD` (id 317) opens path with libc flags, dups to fd ≥ 10 via `F_DUPFD_CLOEXEC`, stores fd number in `$varid`.

### Process substitution

- `>(...)` — `process_sub_out` creates real named pipe via mkfifo and forks a child that reads it. `untokenize` was missing OUTANGPROC → '>' mapping (caused `compile_word_str` detection to fail). Both fixed; `tee >(cat)`, `echo > >(cat)` work.

### Stub builtins routed and fixed

- `sched`, `echotc`, `echoti`, `getln`, `zpty`, `ztcp`, `zsocket`, `private`, `zformat`, `zregexparse` — defined as `builtin_*` handlers but absent from fusevm's `shell_builtins::builtin_id` table. Script-level dispatch fell through to external command spawn ("command not found"). `host_exec_external` now intercepts these names before the OS-level exec attempt.
- `zformat -f` / `zformat -a` — printed result to stdout instead of assigning to named variable/array. Fixed: now uses `self.variables.insert` / `self.arrays.insert`.
- `private` — routes to `builtin_local` (zsh `private` has the same local-scope semantics as `local`).
- `zregexparse` — already worked correctly; earlier probe used wrong flags.

## Closed (this session — man zshall pass)

### Special parameters

- `${commands[ls]}`, `${aliases[ll]}`, `${galiases[…]}`, `${saliases[…]}`, `${functions[foo]}`, `${builtins[echo]}`, `${reswords[for]}`, `${options[interactive]}`, `${parameters[PATH]}`, `${jobtexts[N]}`, `${jobdirs[N]}`, `${jobstates[N]}`, `${nameddirs[name]}`, `${userdirs[user]}` (libc getpwnam), `${modules[zsh/datetime]}`, `${dis_functions[…]}` — magic shell-introspection assocs synthesized at lookup time via `magic_assoc_lookup` in `BUILTIN_ARRAY_INDEX`.
- `$TTY` (libc ttyname), `$TTYIDLE` (st_atime delta), `$TRY_BLOCK_ERROR` (set via new `BUILTIN_SET_TRY_BLOCK_ERROR` between try / always arms), `$patchars`, `$RANDOM_FILE` (/dev/urandom).

### Builtins

- `printf -v VAR fmt args...` — bash-compat var-assign mode. `builtin_printf` is now `&mut self`; `-v VAR` strips the flag and inserts the formatted output into `self.variables[VAR]`.
- `[[ -o option ]]` — shell-option-set test via new `BUILTIN_OPTION_SET` (id 321). Normalizes name (strip _, lowercase). Verified with both `RC_EXPAND_PARAM` and `rc_expand_param` forms.
- `setopt -p` / `setopt -L` — emit `setopt OPTION` lines for every currently-set non-default option, source-replayable.
- `read -n N` — bash-compat alias for zsh's `-k N` (read N characters).
- `private`, `zformat`, `zregexparse` — routed through `host_exec_external` interception so script-level dispatch hits handlers instead of "command not found".
- `zformat -f` / `zformat -a` — fixed var-assign bug; previously printed result to stdout, now uses `self.variables.insert` / `self.arrays.insert`.

### Parameter expansion

- `${(u)arr}` — unique flag, preserve first occurrence drop dupes.
- `${(C)str}` — capitalize first letter of each word, lowercase rest.
- `${arr/old/new}` / `${arr//old/new}` — per-element replacement on arrays. `BUILTIN_PARAM_REPLACE` checks `exec.arrays` first.
- `${arr:#pattern}` — array filter remove matching. New `ParamModifierKind::FilterRemoveMatching` + `BUILTIN_PARAM_FILTER` (id 322) using `glob_match`.
- `${(kv)assoc}` / `${(vk)assoc}` — interleaved key/value pair output. 'k' / 'v' arms in `BUILTIN_PARAM_FLAG` peek for partner flag.

### Brace expansion

- `{01..10}` zero-padding. `expand_brace_sequence` detects leading-0 bounds and pads each output to max(start.len, end.len). Negative-aware.

### Glob qualifiers

- `*(L0)` / `*(L+10k)` / `*(L-1m)` — size qualifier with full zsh syntax `L[+-]N[k|m|g|p]`. Default unit 512-byte blocks; suffix maps to KB/MB/GB/bytes.

### Word concatenation (RC_EXPAND_PARAM)

- `X${arr[@]}Y` first/last sticking — new `BUILTIN_CONCAT_SPLICE` (id 319): `print -l X${arr[@]}Y` → 3 args ("Xa", "b", "cY").
- `X${arr}Y` with `RC_EXPAND_PARAM` cartesian — new `BUILTIN_CONCAT_DISTRIBUTE` (id 318): same input → 3 args ("XaY", "XbY", "XcY"). Without option, joins to scalar (zsh default).

## Closed (this session — subscript pass)

Discovered as gaps when re-probing `man zshall` chapter 14 (Parameters → Array Subscripts). All implemented inside `BUILTIN_ARRAY_INDEX` and a small set of module-level helpers in `src/exec.rs`.

### Array slice `${arr[N,M]}`

- Indexed array slice with positive, negative, and mixed bounds. `${arr[2,4]}`, `${arr[-2,-1]}`, `${arr[1,-1]}`. Returns `Value::Array` so downstream `print -l` / `for` consumes per-element.
- `slice_indexed_array` helper: zsh 1-based inclusive semantics, negative-from-end, out-of-range clamp.

### Scalar slice `${str[N,M]}` / `${str[N]}`

- Char-aware (UTF-8 char count, not byte index). Both single-index `${str[1]}` and slice forms supported. Falls through from `BUILTIN_ARRAY_INDEX` when `name` isn't an indexed/assoc array. New `slice_scalar` helper.

### Bare-variable / arithmetic subscript `${arr[i]}`

- Subscript context is arithmetic in zsh — bare names resolve as variables, full expressions evaluate. `${arr[i]}`, `${arr[i+1]}`, `${arr[len-1]}` all work. Implemented by replacing `idx.parse::<i64>()` Err arm with `eval_arith_expr` fallback.

### Subscript flags `(r)` `(R)` `(i)` `(I)` `(e)` (combinable)

- `(r)pat` — first matching value; `(R)pat` — last matching value (reverse).
- `(i)pat` — first matching index (1-based; len+1 if no match); `(I)pat` — last matching index (0 if no match).
- `(e)str` — exact (literal) instead of glob match. Combinable: `(re)`, `(ie)`, `(Ie)`, etc.
- For assoc arrays, `r`/`R` searches values; `i`/`I` returns the matching key. Implementation: `parse_subscript_flags` + `array_subscript_flag` / `assoc_subscript_flag`.

### `typeset -A m; m=(k v ...)` two-statement assoc init

- After `typeset -A` declares an empty HashMap entry in `assoc_arrays`, the array literal in the next statement is now interpreted as alternating k/v pairs and stored as assoc — previously the array assignment overwrote it as indexed and silently dropped the `-A` attribute. Implemented in `BUILTIN_SET_ARRAY` by checking `assoc_arrays.contains_key(&name)` before the indexed-array path.

### Subscript with `$`-expansion key `${m[$k]}`

- `braced_subscript_ref` rejected keys containing `$`, falling back to a bridge path that didn't perform the assoc lookup. Added `braced_subscript_dynamic_ref` which matches the same `${BASE[KEY]}` shape but allows `$` in `KEY`; the compile path emits `BUILTIN_EXPAND_TEXT` (mode 1, no glob/brace) to resolve the key at runtime, then `BUILTIN_ARRAY_INDEX` for the lookup. Works for both assoc and indexed arrays, plain refs (`$k`), and concat refs (`$pre$post`).

### Extendedglob `^pat` negation in `${arr:#pat}`

- New module-level helper `extendedglob_match` reads the `extendedglob` option at match time; when set, a leading `^` strips itself and inverts the result of the underlying glob_match. Wired into both `BUILTIN_PARAM_FILTER` (compile-path filter) and the legacy `(M)` flag path in `expand_word_glob`. `${arr:#^*.txt}` now keeps only `*.txt` elements; `${(M)arr:#^a}` keeps the inverse. Without `extendedglob` set, `^` stays literal.

### Extendedglob inline pattern flags `(#i)` / `(#I)` / `(#l)` / `(#a<n>)`

- `parse_pattern_flags` strips the leading `(#flags)` block from a pattern. `glob_match_static` now applies the flags before regex translation: `(#i)` adds the regex `(?i)` prefix; `(#I)` cancels `(#i)`; `(#l)` inflates each lowercase pattern char to a `[xX]` character class so it matches either case in the input while uppercase pattern chars stay exact. `(#a<n>)` short-circuits to a Wagner-Fischer Levenshtein-distance check via a new `approximate_match` helper (insert/delete/substitute, default n=1 when the digit is omitted). All paths that go through `glob_match_static` pick this up automatically — `[[ str = pat ]]`, case arms, `${arr:#pat}` filter, etc.

### `${(@s:,:)str}` / `${(@f)str}` — `@` + split flag composition in DQ

- `(@s:sep:)` previously failed inside `"…"` because `@` runs first (wrapping the scalar into a 1-elem array), then the `s` arm in `BUILTIN_PARAM_FLAG` was a no-op on `St::A` — leaving `["a,b,c"]` which DQ joined back into `"a,b,c"`. Fixed by making `s` and `f` flat-map split each element of an array (not just scalars). Also handles the genuine "array of CSV strings" case `arr=("a,b" "c,d"); ${(@s:,:)arr}` → 4-element flat result, matching zsh.

### `${(kv)a[@]}` — flag prefix + `[@]` subscript composition

- `parse_zsh_flag` rejected names with `[`, so `${(kv)m[@]}` fell through to a bridge path that returned just values (the (k) flag never applied). Fix is one line in the matcher: strip a trailing `[@]` or `[*]` suffix from the name before validating; the result is the same name we'd use for the flag-only form, and `BUILTIN_PARAM_FLAG` already returns `Value::Array` for array-producing flags. Also fixes `${(k)a[@]}`, `${(v)a[@]}`, `${(o)a[@]}`, `${(O)a[@]}`, etc.

### `function () { body } args...` — anonymous form with `function` keyword

- `parse_funcdef` collected names then optionally consumed `()`, but never synthesized an anonymous-name placeholder when no name was given — `compile_funcdef` saw `names = []` and emitted nothing, so the body never registered or ran, AND any trailing args (`a b c`) were re-tokenized as a separate command list, producing "command not found" garbage. Fix: in `parse_funcdef`, when `names.is_empty() && saw_paren`, mirror `parse_anon_funcdef` — generate `_zshrs_anon_kw_N`, collect trailing args, set `auto_call_args` so the existing compile path registers + immediately invokes. Bare `() { … }` form was already handled by `parse_anon_funcdef`; this closes parity with the `function`-keyword variant.

### `=(cmd)` process substitution (temp-file flavor)

- `=(...)` is the temp-file flavor of process sub (zsh-only, vs `<(...)`'s FIFO). Both deliver a path to the consumer; the read-end implementation `process_sub_in` already creates a durable temp file (synchronous run, capture stdout to `/tmp/zshrs_psub_*`), so `=(...)` shares it via the same `Op::ProcessSubIn` emission. Compile-path detector adds an `is_eq_psub` branch alongside the existing `<(…)` / `>(…)` matchers. Verified against `cat`, `wc`, `diff`, `printf` consumers.

### `${mapfile[/path]}` — `zsh/mapfile` magic assoc

- `magic_assoc_lookup` now recognizes `mapfile` as a magic assoc name: `${mapfile[/path]}` reads the file's bytes verbatim (trailing newline preserved — matches zsh: a `"test\n"` file gives `${#mapfile[…]} = 5`, not 4). Missing files yield empty. Composes with `(f)` for line-split via the new `parse_zsh_flag_subscript` matcher (see below). The `${(@f)mapfile[…]}` shape correctly produces N+1 elements when the file ends with `\n` (the trailing empty element is preserved per zsh).

### `${(flags)NAME[KEY]}` — flag + literal subscript composition

- `parse_zsh_flag` only handled `${(flags)NAME}` and `${(flags)NAME[@]}` shapes. New `parse_zsh_flag_subscript` matches `${(flags)NAME[KEY]}` for any non-`@`/`*` literal key. Compile path emits a 4-step sequence: resolve the subscripted value via `BUILTIN_ARRAY_INDEX`, prepend the `\u{01}` literal-value sentinel via `Concat`, then call `BUILTIN_PARAM_FLAG` so the flag walks treat it as a pre-resolved scalar. Closes `${(f)mapfile[/path]}`, `${(s:,:)assoc[k]}`, `${(U)assoc[k]}`, etc.

### History expansion in `-c` mode (false positive)

- The original audit flagged `!!` / `!$` / `^old^new^` as missing in `-c` mode. Verified this is the documented zsh behavior: history expansion only fires in interactive (TTY-stdin) mode; `-c` script mode treats `!!` literally. zshrs's `expand_history` correctly gates on `atty::is(Stream::Stdin)`, matching mainline zsh. Added `test_history_expansion_literal_in_c_mode` regression test (echo "first; echo !!" → "first\n!!").

## Closed (second-pass audit, this session)

A wide differential probe against `/bin/zsh` surfaced a fresh batch of gaps. The high-impact ones are now closed:

### Indexed-array element / slice / delete assignment

- `a[2]=YY` (single element), `a[-1]=Z` (negative subscript), `a[5]=E` (grow on assign), `a[2]+=BB` (append at index), `a[2,4]=(YY ZZ WW)` (slice replace), `a[2]=()` (single-element delete), `a[2,4]=()` (slice delete) — all now mutate the indexed array in place. `BUILTIN_SET_ASSOC` was extended with an indexed-array dispatch that routes when the name already names an indexed array OR (for unset names) when the key is a literal integer; otherwise still falls through to assoc. New `BUILTIN_SET_SUBSCRIPT_RANGE` (id 323) handles the array-RHS form so `a[i]=(elements)` and `a[i,j]=(elements)` use one-shot splice semantics. Empty values + comma-key delete the whole slice.

### `=~` regex match captures (`$MATCH`, `$match`, `$mbegin`, `$mend`)

- `regex_match` now uses `Regex::captures` and writes `$MATCH` (full match), `$MBEGIN`/`$MEND` (1-based char offsets), and `$match[]` / `$mbegin[]` / `$mend[]` arrays for each capture group. `[[ "a1b2" =~ ([a-z])([0-9]) ]]; print $match[1]$match[2]` now prints `a1`, matching zsh. (Bare `$match[1]` without braces is still a separate gap — bare-`$NAME[KEY]` doesn't lex as subscript.)

### Tilde expansion `~+`, `~-`, `~+N`, `~-N`, `~user`, named dirs

- `expand_tilde_named` extended with dir-stack-aware `~+` (= `$PWD`), `~-` (= `$OLDPWD`), `~+N` / `~-N` (Nth dir-stack entry from top/bottom), and `~user` via libc `getpwnam`. The runtime `expand_string` now collects the full tilde-name suffix (until `/` or whitespace) and dispatches through the helper instead of using `dirs::home_dir()` for bare `~` only.

### `unset 'arr[i]'` / `unset 'm[k]'` element delete

- `builtin_unset` detects the subscripted form. For assoc: removes the key. For indexed: clears the slot to empty string but preserves the slot count (matches zsh: `unset 'arr[2]'` produces a 3-element array with `arr[2]=""`, distinct from `arr[2]=()` which removes the slot entirely).

### `head -c N` byte-count flag in builtin

- Added `-c N` (and `-c<N>` glommed form) to `builtin_head`. Reads up to N bytes verbatim from the input stream and writes to stdout. Tested with `echo abcdef | head -c 3` → `abc`.

### `WORDCHARS` default

- Set at `ShellExecutor::new` to `*?_-.[]~=/&;!#$%^(){}<>` — the mainline-zsh default for ZLE word boundary chars.

### `<lo-hi>` numeric range globbing

- `[[ file5 = file<1-10> ]]` and friends now match. New `parse_numeric_range` scans `<lo-hi>` (with `lo` and/or `hi` optional — `<->`, `<5->`, `<-10>`, `<5-10>` all supported). `glob_match_static` translates each occurrence to a `(\d+)` capture group, remembers the bounds, and after `Regex::captures` succeeds it parses each capture and verifies the numeric range. Falls back to literal `<` for malformed forms.

### `where` builtin output format

- `builtin_where` was passing `-a -v` (verbose, all matches) which produced `ls is /bin/ls` instead of zsh's bare `/bin/ls`. Now passes `-c -a` and `builtin_whence` honors `csh_style` (`-c`) for aliases (`name: aliased to BODY`), functions (full `name () { … }` body via `function_source`), and missing-name stderr message (`name not found`). Matches zsh `where` exactly for external/alias/function/not-found.

### `print -P` byte-exact ANSI output

- `print -P "%F{red}hi%f"` previously emitted the readline cursor-width markers (`\x01` / `\x02`) plus a leading `\e[0m` reset, producing different bytes from zsh's bare `\e[31mhi\e[39m`. Three fixes: (1) new `expand_prompt_string_for_print` strips `\x01`/`\x02` markers and the spurious leading-reset preamble; `print -P` routes through it. (2) `apply_attrs` no longer emits an unconditional `\e[0m` preamble — only the new SGR codes (matches zsh's incremental approach). (3) `%f` now emits `\e[39m` (default-fg) instead of full `\e[0m`; `%u` emits `\e[24m` (underline off); `%s` emits `\e[27m` (standout off). `%B`/`%b` and `%F{c}` paths verified byte-exact against zsh.

### `let` and `$(())` float formatting

- `let "a=1.0+2.0"; echo $a` previously gave `3` (lost the float-ness). New `MathNum::format_zsh` formats stored vars as `%.10f` so `$a` is `3.0000000000`, matching zsh. Separately `MathNum::format_zsh_subst` formats `$(( ))` substitution display as zsh's `%g`-ish form: integer-valued floats print as `4.` (trailing dot, no zeros — zsh's "this is float" marker), non-integer floats print at full f64 precision via Rust's shortest-roundtrip. `evaluate_arithmetic` extracts via `format_zsh` (storage) and returns via `format_zsh_subst` (substitution display) so both contexts match zsh. The bytecode `(( a=1.0+2.0 ))` ArithCompiler path remains a known float-collapse pre-existing issue (separate from this fix).

### `print -P %h` / `%!` history line number

- `%h` and `%!` previously printed the persistent disk history total (e.g. 7466) instead of zsh's session-relative line number (0 in `-c` mode, since no command has been recorded). New `session_histnum` field on `ShellExecutor` (default 0, incremented on interactive command record). `build_prompt_context` reads it instead of `history.count()`. Matches zsh in `-c` mode exactly.

### `print -P %D{fmt}` strftime format

- Verified working in current build — the previously-noted gap was a stale `head -c 4` chain artifact (`head -c` was missing the byte-count flag, now fixed). `%D` with default format (`%y-%m-%d`-ish) and `%D{fmt}` with explicit strftime both match zsh.

### `fc -l` empty-history behavior in non-interactive mode

- `fc -l` previously dumped the persistent disk history (e.g. 7000+ entries) in `-c` mode. zsh's behavior in non-interactive mode is "no such event: 1" with exit 1 — the persistent disk history shouldn't leak through. `builtin_fc` now gates on `atty::is(Stream::Stdin)` (same signal `expand_history` uses) and short-circuits with the `zsh:fc:1: no such event: <N>` error in non-interactive mode. Format byte-exact against zsh.

### `noglob` precommand modifier dispatches to builtins

- `noglob print "*"` errored "command not found: print" because `builtin_noglob` routed unconditionally through `builtin_command` (PATH-only lookup). Now dispatches via `builtin_builtin` first when the name `is_builtin`, falling back to `builtin_command` for functions and externals. `noglob echo "*.txt"`, `noglob ls`, etc. continue to work.

### Bare `$arr[N]` subscript (no braces)

- `print $arr[2]` was lexing as `$arr` (whole array) + literal `[2]`, producing `x y z[2]`. New `bare_subscript_ref` matches the bare `$NAME[KEY]` shape and emits `BUILTIN_ARRAY_INDEX` directly. Companion `bare_subscript_with_suffix` handles `$arr[2]extra` (literal suffix concatenated via `Op::Concat`). Works for indexed (numeric key), assoc (string key), and with literal suffixes — `$arr[2]extra` → `yextra`, matching zsh.

### `(t)` typeset flag — type + attribute introspection

- `${(t)var}` previously returned `scalar` for everything (no per-variable attribute tracking). New `VarAttr` struct + `var_attrs: HashMap<String, VarAttr>` field on `ShellExecutor` records the kind (`Scalar`/`Integer`/`Float`/`Array`/`Association`) and modifiers (`readonly`, `export`, `left_pad`, `right_pad`, `zero_pad`, `lowercase`, `uppercase`). `format_zsh()` produces zsh's canonical `<kind>[-modifier]*` string — `integer`, `float`, `scalar-left`, `scalar-readonly`, `scalar-export`, etc. Wired into `builtin_declare` (typeset/declare flag block), `builtin_integer`, `builtin_float`, and `builtin_export`. Verified all 10 baseline shapes byte-exact against zsh.

### Glob qualifier `(mh-N)` / `(mm-N)` / `(mw-N)` time qualifiers

- Three fixes were needed: (1) `valid_chars` in `looks_like_glob_qualifiers` was missing lowercase `h` and `i` (also added `g` for group qualifier), so `(mh-N)` was being rejected at parse time. (2) `filter_by_qualifiers` had no `m`/`a`/`c` handler — added a new arm that parses the unit char (`s`/`m`/`h`/`d`/`w`/`M`), op (`+`/`-`), and integer N, then filters via `meta.mtime()`/`atime()`/`ctime()` against the cutoff. (3) `BUILTIN_EXPAND_TEXT` only invoked `expand_glob` when the word contained `*`/`?`/`[`; now also triggers when the word ends with a `(...)` qualifier suffix so plain paths like `/etc/hosts(mh-100)` route through globbing. Three tests cover recent file, too-old filter, and `(.)` plain qualifier.

### Recursive glob `**/` (dirs-only) and `**/*` (files+dirs)

- `**/` previously returned the literal pattern; `**/*` matched only files. zsh's `**/` enumerates directories with the trailing slash preserved; `**/*` matches both files and directories. Three fixes in `expand_glob_parallel`: (1) detect `dirs_only` when `file_glob` is empty (the trailing-slash form) and skip the file-pattern check entirely. (2) When `match_dirs_too` is on (every non-`dirs_only` `**/` walk), include directory entries from the walker. (3) Strip the `./` prefix when base was the implicit `.` so output matches zsh's relative-path style. Worker walkers now `continue` on `depth() == 0` to avoid double-adding the subdir root that the top-level loop already emitted. Three tests cover dirs-only, files+dirs, and extension filter.

## Closed (third-pass audit, this session)

### `${var:s/old/new/}` and `${var:gs/old/new/}` substitution modifier

- `is_history_modifier` was missing `s` and `g` so `${p:s/l/L/}` and `${p:gs/l/L/}` fell through unrecognized and returned empty. Added both. New `apply_subst_modifier` helper consumes the delimiter, old text, new text, then rewrites in place (single replace for `:s`, global for `:gs`). `apply_history_modifiers` now dispatches via `s` and the `g` prefix arms. Stops on `:` so chained modifiers (`:s/x/y/:t`) compose correctly.

### `${var:q}` backslash quoting

- `:q` was wrapping the whole value in single quotes (`'hi there'`); zsh emits backslash-escaped form (`hi\ there`). Replaced the wrapping with per-char escape: any of ` \t\n'"\\$\`;|&<>()[]{}*?#~!` gets a `\` prefix.

### `$0` inside a function = function name

- `call_function` now saves the previous `$0`, installs the called-function's name into `variables["0"]` for the duration of the call, and restores on exit. Matches zsh's default `FUNCTION_ARGZERO` behavior.

### `$funcstack` array

- `call_function` now also maintains the `funcstack` array — each call prepends the function name (top-of-stack first), pop on return. Standard zsh introspection used by frameworks for traceback / debugging.

### `$ARGC` alias for `$#`

- `get_variable` recognizes `ARGC` as a special parameter that returns `positional_params.len().to_string()` — same value as `$#`. zsh's `$ARGC` was empty in zshrs.

### `print -N` null between args

- `print -N a b c` previously emitted `a b c\0` (NUL only at end). zsh uses NUL as both separator AND terminator → `a\0b\0c\0`. Fixed `builtin_print` to use `\0` as the separator when `null_terminate` is set.

### kshglob extended patterns `?(p)` `*(p)` `+(p)` `@(p)` (gated)

- New `ksh_extglob_body_to_regex` translator. `glob_match_static` detects `?(...)`, `*(...)`, `+(...)`, `@(...)` after looking ahead for the `(` and emits `(?:body){suffix}` regex (suffix = `?`/`*`/`+`/empty). Gated on `setopt kshglob` so the default-off behavior matches zsh. `!(p)` (negative) needs lookahead which the `regex` crate doesn't support — left literal.

### Pattern repetition `(#cN)` and `(#cN,M)`

- `glob_match_static` peeks at `(#c...)` after `(` and emits a regex `{N}` or `{N,M}` quantifier. `a(#c2)` matches `aa` only; `a(#c2,3)` matches `aa` or `aaa`.

## Closed (fourth-pass batch — special params + module assocs + edge cases)

### `$EUID`, `$UID`, `$EGID`, `$GID`, `$PPID`, `$HOST`, `$HOSTNAME`, `$ZSH_SUBSHELL`

- New special-parameter handlers in `get_variable`: `EUID`/`UID` via libc `geteuid`/`getuid`; `EGID`/`GID` via `getegid`/`getgid`; `PPID` via `getppid`; `HOST`/`HOSTNAME` via `gethostname` (with NUL-trim); `ZSH_SUBSHELL` reads from `variables` with default 0.

### `$#@` and `$#*` count forms

- `bare_var_ref` extended to recognize the 2-char specials `#@` and `#*` (zsh shorthand for `${#@}`/`${#*}`, which equal `$#`). Routes through `get_variable` which returns `positional_params.len()` for either name.

### `$sysparams[KEY]` zsh/system magic assoc

- New `magic_assoc_lookup` arm for `sysparams`. Returns `pid` (process id), `ppid` (parent), `procsubstpid` ("0"). Splice form `${sysparams[@]}` returns the value list. Closes the `zmodload zsh/system; print $sysparams[pid]` daily-driver shape.

### `!(p)` kshglob negation (standalone, gated)

- `glob_match_static` now detects a fully-`!(<body>)` pattern and returns `!glob_match_static(s, body)` — the negation of recursing into the body. Composition like `prefix!(foo)suffix` would need negative lookahead and is left literal. Gated on `setopt kshglob` to match zsh.

### `${(F)arr}` newline-join flag

- New 'F' arm in `BUILTIN_PARAM_FLAG`: joins an array state with `\n` and produces a scalar. Mirrors the existing `(j:\n:)` form but as the standard one-letter shorthand.

### `typeset -p NAME` re-executable declaration output

- New `print_mode` early-return arm in `builtin_declare`: for each name arg without `=`, emits `typeset -<attrs> NAME=<quoted-value>`. Reads from `var_attrs` for kind/readonly/export modifiers; falls back to `assoc_arrays`/`arrays` membership for unmarked vars. Output format byte-exact against zsh: `typeset -i i=5`, `typeset -a arr=( a b c )`, `typeset -A m=( [a]=1 [b]=2 )`.

### `export -p` lists every exported var

- New early-return in `builtin_export`: when args are exactly `["-p"]`, walk `std::env::vars()`, sort, and emit `export NAME=<quoted-value>` lines. Matches POSIX + zsh format.

### `zmv` / `zcp` / `zln` / `zcalc` native bundled functions

- Previously these autoloaded zsh function files from `/opt/homebrew/Cellar/zsh/.../functions` and zshrs's parser HUNG indefinitely on the zsh-specific syntax in those bodies. Native Rust ports replace the autoload path: `call_function` short-circuits the four names BEFORE the alias/function/external lookup, dispatching directly to `builtin_zmv` and `builtin_zcalc`.
- `builtin_zmv` handles flags `-n` (dry-run), `-f` (force), `-i`, `-v`, `-W` (wildcard), `-s` (symlink for ln mode), `-M`/`-C`/`-L` (force action), `-p prog` (custom executable). The source pattern's `(...)` capture groups translate to a regex; the destination's `$N` / `${N}` substitute the captures. Collision detection (two srcs → same dest) errors before any file action. `zcp` and `zln` are the same dispatcher with different default actions.
- `builtin_zcalc` supports `-e EXPR` non-interactive evaluation (`zcalc -e "2+3*4"` → `14`); interactive REPL not implemented.

### `[[ a -nt b ]]`, `[[ a -ot b ]]`, `[[ -k ]]`, `[[ -u ]]`, `[[ -g ]]`, `[[ -O ]]`, `[[ -G ]]` cond tests

- `compile_zsh::emit_binary_test` had no arms for `-nt`/`-ot` — they fell through to the unknown handler returning false. Added `BUILTIN_FILE_NEWER` (id 324) and `BUILTIN_FILE_OLDER` (id 325) that compare `mtime()` via libc, with zsh-compatible "missing file" rules. Similarly `emit_file_test` lacked `-k`/`-u`/`-g`/`-O`/`-G`; added five new builtins (`BUILTIN_HAS_STICKY`/`SETUID`/`SETGID`/`OWNED_BY_USER`/`OWNED_BY_GROUP`) reading via `std::os::unix::fs::{PermissionsExt,MetadataExt}`. Verified `[[ -k /tmp ]]` returns true on macOS, `-O`/`-G` route correctly, `-nt` correctly compares 1s-granularity mtime.

### Extendedglob `^pat` negation in `[[ str = pat ]]` cond test

- Already worked for `${arr:#pat}` filter via `extendedglob_match`, but the cond `=` matcher (which goes through `glob_match_static` directly) didn't apply the negation. Added a leading-`^` strip + recurse-with-negate at the top of `glob_match_static`, gated on `setopt extendedglob`. `[[ apple = ^a* ]]` → false; `[[ banana = ^a* ]]` → true. Without extendedglob, `^` stays literal as before.

### `wait $!` silent-on-empty-pid

- When `$!` is unset (no bg job has been started), `wait $!` runs with an empty arg. zsh silently returns 0; bash errors with "wait: : not a pid". `builtin_wait` now skips the empty-arg branch and continues — match zsh.

### `print -m PATTERN args…` glob-match filter

- New `match_pattern_flag` in `builtin_print`: when `-m` is set, the first positional is a glob pattern; `output_args.retain` keeps only args that match. `print -m 'h*' hello world hi` → `hello hi`.

### `integer i=EXPR` runs arith eval on RHS

- `builtin_integer` was using `value.parse::<i64>().unwrap_or(0)` so anything beyond a literal int became 0. Replaced with `self.eval_arith_expr(value)` so `integer i=5+3` stores 8, `i=2*3+1` stores 7, etc. — matches zsh's "RHS goes through arithmetic" rule for `integer`-typed declarations.

### Positional-param subscript: `${@[N]}`, `${@[N,M]}`, `${*[N,M]}`, `$@[N]`, `${argv[N]}`

- Three fixes: (1) `BUILTIN_ARRAY_INDEX` now recognizes `@`/`*`/`argv` as special names that index `positional_params` directly (1-based, with negative-from-end and slice forms). (2) `braced_subscript_ref` accepts `@`/`*` as base (was rejecting because they're not alphabetic). (3) `bare_subscript_ref` accepts the same special names so `$@[N]` (no braces) routes through `BUILTIN_ARRAY_INDEX`. Without these, all four shapes fell through to the scalar-slice path which sliced the IFS-joined string.

### `for f in $arr` splices array elements

- `for f in $arr` was iterating ONCE with `f` set to the IFS-joined string because `BUILTIN_GET_VAR` collapses arrays into a scalar. Two changes: (1) `compile_for_words` detects bare `$NAME` words and emits `BUILTIN_ARRAY_ALL` instead, which always returns `Value::Array` so the for-loop's `BUILTIN_ARRAY_FLATTEN` spreads the elements. (2) `BUILTIN_ARRAY_ALL` extended to fall back to a scalar IFS-split when `name` isn't an array — so `for w in $scalar` still IFS-word-splits per zsh semantics. Quoted `for f in "$arr"` still joins to a single iteration (DQ context unchanged).

### `arr+=val` (no parens) pushes as new element

- Was treating `name+=val` as scalar concat unconditionally, clobbering the array. New `BUILTIN_APPEND_SCALAR_OR_PUSH` (id 331) runtime-dispatches: if `name` is an indexed array, push `val` as a new element; if assoc, error (zsh requires `(k v)` form for assoc append); else scalar concat (existing behavior). Three tests cover array push, multi-element push, and scalar concat.

### `${var-default}` no-colon default family

- Only the colon variants (`${var:-X}`, `${var:=X}`, `${var:?X}`, `${var:+X}`) were recognized — those treat empty-string-set the same as unset. The POSIX no-colon forms (`${var-X}`, `${var=X}`, `${var?X}`, `${var+X}`) fire only when truly unset (not just empty). Added op codes 4-7 in `BUILTIN_PARAM_DEFAULT_FAMILY` plus matching parser arms in `parse_param_modifier`. Five tests cover default/assign/error/alt for both unset and empty-set cases.

### `$status` alias for `$?`

- `get_variable` now treats `status` as an alias for `?` — both return `last_status`. zsh exposes both names; `$status` was empty in zshrs.

### `$pipestatus[N]` / `$PIPESTATUS[N]` after single command

- `BUILTIN_ARRAY_INDEX` now special-cases `pipestatus`/`PIPESTATUS`: if no array has been populated (e.g. after a single non-pipeline command), synthesizes `[last_status]` so `true; echo $pipestatus[1]` returns `0`. Real pipelines continue to use the per-stage array set by `BUILTIN_PIPELINE_EXEC`.

### `[[ -c path ]]`, `[[ -b path ]]`, `[[ -p path ]]`, `[[ -S path ]]` file-type tests

- `compile_zsh::emit_file_test` had no arms for character device, block device, FIFO, or socket. Added four new builtins (`BUILTIN_IS_CHARDEV/BLOCKDEV/FIFO/SOCKET`, ids 332-335) using `std::os::unix::fs::FileTypeExt`. `[[ -c /dev/null ]]` → true on macOS as expected.

### `unset -f NAME` removes function

- `builtin_unset` now parses `-f` (function mode) and `-v` (var mode, default). With `-f`, removes from `functions_compiled`, `function_source`, and `autoload_pending`. Mirrors `unfunction NAME`.

### `for w in $scalar` no-IFS-split (zsh default)

- `BUILTIN_ARRAY_ALL` was IFS-splitting scalars in for-list contexts (bash semantics). zsh's default is to NOT split — `for w in $s` iterates ONCE with the scalar value. Now scalars produce a 1-element array unless `setopt shwordsplit` (the bash-compat option) is on, in which case the old IFS-split behavior fires. Two tests cover both modes.

### `${var//#pat/repl}` and `${var//%pat/repl}` anchored replace-all

- `parse_param_modifier` only checked `//` before `/#` / `/%`, so `${s//#hel/HEL}` was parsed as `//` (replace-all) with literal pattern `#hel`. Reordered the prefix matchers so `//#` and `//%` win first. Both produce the same result as `/#`/`/%` for non-overlapping matches (anchor-at-start matches once; replace-all is moot).

### `alias x` query output format

- Was always emitting `name='value'` (single-quoted). zsh's rule: bare value when it's a single safe word, single-quoted when it contains whitespace or shell metachars. New `format_alias_kv` helper applies the rule; both the `alias NAME` query and the `alias` listing path use it.

### `foo() echo hello` one-line function body

- The lexer collapses `foo()` into a single String token whose suffix is `\u{88}\u{8a}` (INPAR + OUTPAR). For `foo() echo hello`, parse_simple consumed `foo()`, `echo`, `hello` as a 3-word Simple. The funcdef synthesizer in parse_program required `words.len() == 1`, so the multi-word case was lost. Updated `simple_name_with_inoutpar` to return `(name, body_argv)`: when `body_argv` is non-empty, the synthesizer wraps `body_argv` as a Simple body and emits the FuncDef. Brace-body path (existing) and 1-word `foo()` followed by `{...}` continue to work. Three tests cover one-line/colon/arg-passing variants.

## Closed (eighth-pass — non-interactive batch)

### `&>` / `&>>` redirect — restore both fd 1 and fd 2 after the body

- The lexer clamps `tokfd` to ≥ 0 for `&>`, so the parser handed the host `fd=0` for what should be "both stdout and stderr". `host_apply_redirect` only saved that single `fd` into the redirect scope, leaving fd 2 permanently aimed at the file. After `{ cmd } &> file; echo done`, the trailing `echo done` wrote into the file too. Fixed: when op is `WRITE_BOTH`/`APPEND_BOTH`, force the primary fd to 1 (so stdout is saved), then explicitly dup-and-stash fd 2 into the same scope. `WithRedirectsEnd` then restores both. Test: `test_amp_redir_restores_stderr`.

### `typeset -m PAT` — glob-pattern listing of variables

- The flag was parsed and immediately discarded with `let _ = pattern_match`. Wired it: with `-m` and one or more glob patterns, expand patterns against the live name space (variables + arrays + assocs, or `function_names()` under `-f`), dedup, and emit the matching listings. Honors `-p` for re-executable form, scalar/array/assoc per-name shape. Test: `test_typeset_m_glob_lists_matching`.

### `print` flag-processing must stop at first non-option

- `print "rest:$@"` with positionals `-a -b foo` was treating `-b` as a print flag mid-args. Fixed: introduce `accept_flags` toggle, flip it off on the first non-flag arg or any token whose chars aren't all known print flags. `print -- -n foo` and `print -n hello` paths unchanged. Test: `test_print_stops_flag_processing_at_first_non_option`.

### `zparseopts -D` — only remove consumed indices

- The previous removal logic used a single `parsed_count` and dropped contiguous positions `1..=N`, which broke whenever `-E` skipped non-options or when only some specs matched. Switched to per-match `consumed_indices: Vec<usize>` and rebuild `positional_params` by filtering. Also moved positional source from synthetic `$1..$99` reads to `self.positional_params` directly. Test: `test_zparseopts_dash_d_removes_only_consumed`.

### `zparseopts -M` — alias spec redirection

- `-M f=optf -foo=f` now treats `-foo`'s `f` target as another spec name, not an array name. When `--foo` is seen, it matches the alias spec, resolves to the canonical `f` spec for arg-handling, and records the actual `--foo` arg into `f`'s target array (`optf`). Required adding canonical-name routing into the per-spec output bucket. Test: `test_zparseopts_dash_m_alias_redirects_to_canonical`.

### `zformat -f` width specifiers `%[-]Ns`

- Format loop was strictly `%X → spec` with no width handling. Added a parser for optional `-` (alignment sigil) + decimal width + spec char. Padding semantics MATCH ZSH OBSERVED BEHAVIOR (which is the inverse of printf): no `-` left-aligns, `-` right-aligns. Test: `test_zformat_width_padding`.

### `getopts` unknown-option message format

- Was `zshrs: getopts: illegal option -- X`. zsh emits `zsh:N: bad option: -X`. Switched to `zshrs:1: bad option: -X` to mirror the format with the program name swapped. Test: `test_getopts_unknown_uses_zsh_format`.

## Closed (ninth-pass — formatter + introspection batch)

### `print -f FMT args...` — cycle FMT until args exhausted

- POSIX printf semantics. `print -f "%-5s|%-5s\n" a b c d` should emit two lines (`a    |b    \nc    |d    \n`), not one. Added a counting variant of the format helper (`printf_format_count`) returning consumed-arg count, then loop the call from the print path until `idx >= len`. Also expand `\n`/`\t`/etc. in the format string when `-r` isn't set. Test: `test_print_f_format_cycles_args`.

### printf width specifiers `%[-+# 0][N][.P]X`

- Width digits were parsed but never applied — `printf "%-10s|%10s" hello world` rendered `hello|world`. Rewrote the per-spec branch to track `left_align`/`zero_pad`/`plus_flag`/`space_flag`/`hash_flag`/`width`/`precision`, then pad/sign each conversion before pushing. Covers `s`/`d`/`i`/`u`/`x`/`X`/`o`/`f`/`F`/`e`/`E`/`g`/`G`. Test: `test_printf_width_left_align`.

### `functions -m PAT` — glob-pattern listing

- The `-m` flag wasn't recognised. Added pattern-match expansion: collect names matching each pattern via `glob_match_static`, dedup, then list (or print body, or trace per `-t`/`-l`). Combined-flags form `-lm` parsed too. Test: `test_functions_dash_m_glob_lists_matching`.

### `zstyle -L` and bare `zstyle` — emit zsh's exact formats

- The internal `StyleTable.list()` returned `(style, pattern, values)` triples but the caller printed them as `(pattern, style, values)`. Renamed semantics so list returns `(pattern, style, values)`, then updated `-L` to emit zsh's bare-word form (`zstyle <pattern> <style> <vals>`) — quoting only when whitespace/empty. Bare `zstyle` (no args) now uses zsh's grouped-by-style form (`STYLE\n        <pattern> <vals>`). Tests: `test_zstyle_dash_l_uses_pattern_first_format`.

### `${(q)}` / `${(qq)}` / `${(qqq)}` / `${(qqqq)}` — fix gradient mapping

- The `(q)` flag gradient was inverted. Per `man zshexpn`:
  - `(q)`    backslash-escape shell-special chars (no surrounding quotes)
  - `(qq)`   single-quote always
  - `(qqq)`  double-quote always
  - `(qqqq)` ANSI-C `$'…'` style
  - `(q+)`   single-quote if needed
  zshrs had `(q)`→single-quote, `(qq)`→double-quote, etc. (off-by-one). Re-mapped both q-flag handlers (the Phase-2 `BUILTIN_PARAM_FLAG` path at exec.rs:2516 and the parser-flag path at exec.rs:11904). Added `ZshParamFlag::DollarQuote` for the `qqqq` level. `(q+)` now correctly promotes to single-quote when the value needs quoting (was emitting backslash-escape before). Updated 7 affected tests in `tests/no_tree_walker_dispatch.rs` to match real zsh output. Tests: `test_zsh_param_q_flag_backslash_only`, `test_zsh_param_q_flag_gradient`.

## Closed (tenth-pass — DQ subscripts + nounset)

### `$NAME[subscript]` in double-quoted context

- `"$m[a] $m[b]"` was emitting `[a] [b]` literal text after each `$m`. Two changes: (1) extended `find_expansion_end` (in `compile_zsh.rs`) so a trailing `[...]` after an identifier is pulled into the same expansion segment — handles both META-INBRACK (`\u{91}`) and bare `[`, since DQ-context lex paths leave the bracket unwrapped. (2) Added a subscript handler in `expand_string` (in `exec.rs`) for assoc lookups, array indexing (1-based, negative-from-end), and `@`/`*` splice. Composes with `$`-expansion inside the subscript (`$m[$k]`). Tests: `test_assoc_subscript_in_double_quotes`, `test_array_subscript_in_double_quotes`, `test_assoc_subscript_with_dynamic_key_in_dq`.

### `set -u` / `setopt nounset` — error on unbound parameter

- The option flag was set but never checked. Wired the check into `get_variable` for the catch-all (non-special) branch: when the resolved name isn't in `variables`/`arrays`/`assoc_arrays`/env AND nounset is on, print `zshrs:1: NAME: parameter not set` and `std::process::exit(1)` (mirrors zsh's `-c` behaviour). Subtlety: zsh stores the option as `unset` (default ON = silently empty), and `setopt nounset` sets the inverted name. Different code paths in zshrs persisted either `nounset=true` or `unset=false`, so the check honors either signal. Tests: `test_set_dash_u_exits_on_unbound_variable`, `test_setopt_nounset_exits_on_unbound`.

## Closed (eleventh-pass — error-on-unset family)

### `${x:?msg}` / `${x?msg}` — exit on null/unset

- The `BUILTIN_PARAM_DEFAULT_FAMILY` op codes 2 and 6 (`:?` / `?`) emitted the diagnostic to stderr but returned an empty string and continued execution. zsh in `-c` mode aborts the whole shell. Now emits `zshrs:1: NAME: msg` (with `parameter null or not set` as the default if no message text) and `std::process::exit(1)`. Tests: `test_param_colon_question_exits_on_empty`, `test_param_question_exits_on_unset`, `test_param_colon_question_passes_through_value`.

### NOMATCH default — unmatched globs abort

- zsh's default option set includes `nomatch`, which makes unmatched globs an error: `echo /tmp/no_such_*` prints `no matches found: /tmp/no_such_*` on stderr and the shell exits 1. zshrs's `expand_glob` was returning `vec![pattern]` (bash semantics). Wired the option check: if `nomatch` is on (default true), no match found, AND the pattern truly looks like a glob → emit the diagnostic and exit. `looks_like_glob` rejects bare `[` (the test builtin) by requiring a matching `]`. The `(N)` qualifier and `setopt nullglob` continue to silence the error.
- Required two protective fixes to keep internal callers from spuriously erroring:
  - `BUILTIN_EXPAND_TEXT` mode 0 now skips glob expansion for assignment-shaped words (`NAME=value`) so `integer i=2*3+1` doesn't trip on the `*`.
  - In `compile_cond`'s Binary branch, the RHS of `=`/`==`/`!=`/`=~` is now compiled as a quoted literal — these are pattern operands for the test, not file globs.
- Tests: `test_unmatched_glob_default_errors_with_nomatch`, `test_unsetopt_nomatch_passes_literal_through`, `test_assignment_value_skips_glob_expansion`.

## Closed (twelfth-pass — logical-pwd preservation)

### `cd` / `pwd` preserve the logical path (default `-L`)

- The `do_cd` helper canonicalised the target before `chdir`, so `cd /tmp` on macOS landed in `/tmp` but `$PWD` became `/private/tmp`. Two fixes:
  - Renamed the inner `physical` parameter back to `logical` and inverted its sense (the call site already passed `logical=true`, but the parameter slot was named `physical`, silently flipping the semantics — the canonicalise branch was firing for the default mode). Recomputed `let physical = !logical;` once at the top.
  - Added a lexical `normalize_logical(path)` helper that collapses `.`/`..` components without touching the filesystem (so `cd ..` from a symlinked dir lands at the symlink's parent, not the realpath's parent).
  - In the default (`-L`) branch, `chdir` to the lexical absolute path; store that same path in `$PWD`. Only the `-P` branch realpaths.
  - `OLDPWD` is now seeded from the previous `$PWD` (logical), not `current_dir()` — so `cd -` round-trips the user-typed path.
- `builtin_pwd` now reads `$PWD` for default/`-L` output (still honors `-P` to realpath via `current_dir()`). Tests: `test_cd_preserves_logical_path`, `test_cd_dash_p_realpaths` (the latter delegates the expected-value to /bin/zsh so it passes on both macOS and plain Linux).

## Closed (thirteenth-pass — set -e enforcement + readonly + lexer errors)

### `set -e` / `setopt errexit` — exit on command failure

- Wired full POSIX/zsh-compatible errexit. Required four pieces:
  - `BUILTIN_ERREXIT_CHECK` (id 336): runtime helper that reads `vm.last_status`, the `errexit` option, and `local_scope_depth`. If errexit is on AND status != 0 AND not inside a function call, `std::process::exit(status)`.
  - Compiler emits the check after every top-level `SetStatus` (CallBuiltin / CallFunction). The `return` and `exit` builtins skip it (their status is intentional).
  - `errexit_suppress_depth: i32` field on `ZshCompiler` tracks suppression contexts. Bumped around `if`/`elif`/`while`/`until` test bodies and around any sublist that has `&&`/`||` chaining or `!` negation.
  - The full sublist (everything before `;` or newline) is exempt when it contains `&&`/`||` connectors — POSIX rule that AND-OR list failures are "consumed" by the connector and don't trigger errexit even at the chain's end.
- Tests: `test_set_e_exits_on_failure`, `test_set_e_suppressed_in_if_test`, `test_set_e_suppressed_in_and_chain`, `test_set_e_suppressed_in_or_chain`, `test_set_e_suppressed_in_negation`, `test_set_e_suppressed_in_while_test`.

### `readonly` / `typeset -r` — block subsequent assignments

- The `readonly_vars` set was populated by the builtin but never consulted at assignment time. `BUILTIN_SET_VAR` now checks both `readonly_vars` and `var_attrs[name].readonly`. On hit: emit `zshrs:1: read-only variable: NAME` and `std::process::exit(1)` (mirrors zsh's "fatal in -c" behaviour). Closes the two pre-existing failing tests `test_readonly_variable` and `test_typeset_readonly`.

### Lexer-level parse errors surface to the caller

- `ZshLexer.error` (e.g. `unmatched '`) was set during lexing but the parser ignored it. After `parse_program_until` succeeds, `parse()` now checks `self.lexer.error` and returns it as a `ParseError`. The execute path then exits with the diagnostic on stderr. Closes `test_error_syntax` (now uses `echo 'unclosed` — a real lexer error that mainline zsh also rejects).

## Closed (fourteenth-pass — subshell + arith subscripts)

### `(cd /tmp); pwd` — subshell cd must not leak

- Subshell snapshot saved/restored `cwd` via `current_dir()`/`set_current_dir()` correctly, but my new `cd` writes `$PWD` into both `self.variables` and `env::set_var("PWD", ...)`. The snapshot restored `self.variables` but NOT the env var, so the subsequent `pwd` (which now reads `$PWD` for logical mode) showed the subshell's cwd. Fix: in `subshell_end`, after `set_current_dir(snap.cwd)`, also `env::set_var("PWD", &snap.cwd)`. Test: `test_subshell_isolates_cwd`.

### `$((m[k]))` / `$((a[2]))` — arith subscripts on arrays/assocs

- `MathEval` only knows about scalar variables (`self.variables`), so `m[k]` resolved to 0. Added `pre_resolve_array_subscripts(expr)`: walks the expression, finds `name[subscript]` shapes, resolves them against `assoc_arrays` (key lookup) or `arrays` (1-based numeric index, negative-from-end), and inlines the literal value before handing to `MathEval`. Wired into `evaluate_arithmetic`, `eval_arith_expr`, `eval_arith_expr_float`. Tests: `test_arith_assoc_subscript`, `test_arith_array_subscript`.

## Closed (fifteenth-pass — read -A IFS + tilde-user error)

### `IFS=, read -A arr` — honor custom IFS for array split

- The `-A` (read into array) branch unconditionally used `split_whitespace()`, ignoring `$IFS`. With a custom IFS like `,` the input `1,2,3` became one element. Branch now: if IFS is the default whitespace string, keep `split_whitespace()` (collapses consecutive separators); otherwise split on every IFS char (matches zsh `read -A` for custom IFS). Tests: `test_read_dash_a_honors_custom_ifs`, `test_read_dash_a_default_ifs_collapses_whitespace`.

### `~nonexistent_user` — fatal error

- `expand_tilde_named` previously returned the literal `~name` string when `getpwnam` failed. zsh emits `zsh:1: no such user or named directory: name` and exits 1. zshrs now matches with a `zshrs:1:` diagnostic and `std::process::exit(1)`. Test: `test_tilde_unknown_user_errors`.

## Closed (sixteenth-pass — heredoc + echo + alias + substring expr)

### Empty heredoc — don't error and don't trail a newline

- Two compounding bugs:
  - `process_heredocs` used "content empty" as the "not yet processed" marker, so an empty heredoc was re-processed on every subsequent newline; the second pass found EOF and errored "here document too large or unterminated". Added a separate `processed: bool` field on `HereDoc` to disambiguate.
  - The unquoted heredoc emit path always routed through `Op::HereString`, which appends a newline. For an empty body this leaked a stray `\n` into the consumer (`cat <<EOF\nEOF` printed a blank line vs zsh's silent). Empty bodies now route through `Op::HereDoc` regardless of quoting.
- Test: `test_empty_heredoc_succeeds` (compares to `/bin/zsh` output for portability).

### `echo -e` — full backslash-escape decoder

- Only `\n` and `\t` were interpreted; `\033` / `\xNN` / `\NNN` / `\a` / `\b` / `\e` were emitted literally. Routed `echo -e` through the existing `expand_printf_escapes` helper that already handles the full set. Test: `test_echo_dash_e_interprets_octal_escape`.

### `alias` listing — bare values stay unquoted

- The list output path hardcoded `'{}'` quoting around every value, so `alias x=1` listed as `x='1'` instead of `x=1`. Replaced with the existing `format_alias_kv` helper which only adds quotes when the value contains shell specials/whitespace. Also sorted output to match zsh's deterministic listing. Test: `test_alias_listing_unquoted_for_simple_values`.

### `${s:$n:2}` — substring with variable / arith offset

- The substring parser only accepted literal digits/`-` after the colon, so `${s:$n:2}` and `${s:$((1+1)):2}` returned empty. Added:
  - New `ParamModifierKind::SubstringExpr { offset_expr, length_expr }` variant.
  - New runtime helper `BUILTIN_PARAM_SUBSTRING_EXPR` (id 337) that evaluates each expression at runtime via `eval_arith_expr`. Stack layout includes a `has_length` sentinel to distinguish "no length given" from "length=0".
  - Top-level `:` split that respects `(...)` depth so `${s:$((1+1)):2}` keeps `$((1+1))` intact.
- Tests: `test_substring_with_var_offset`, `test_substring_with_arith_offset`, `test_substring_with_var_offset_and_length`.

## Closed (seventeenth-pass — pipefail + IFS default + diagnostics)

### `set -o pipefail` / `setopt pipefail`

- The option was tracked but never consulted — `false | true` always returned 0 (last-stage status). `BUILTIN_RUN_PIPELINE` now reads `exec.options["pipefail"]` after collecting `pipestatus[]` and returns the rightmost non-zero status when on (POSIX/bash semantics). Tests: `test_pipefail_returns_first_nonzero`, `test_pipefail_default_off_returns_last`, `test_setopt_pipefail_alias`.

### `$IFS` default value populated to `" \t\n\0"`

- `ShellExecutor::new()` left `$IFS` unset; users running `echo "$IFS"` saw an empty string vs zsh's space/tab/newline/NUL. Now seeded explicitly. Required updating `read -A`'s default-IFS detection from exact-string match (`" \t\n"`) to a char-set test (`all chars in {' ', '\t', '\n', '\0'}`) so the new init value still routes through `split_whitespace` (collapses consecutive separators). Test: `test_ifs_default_includes_null`.

### `command not found` includes line number

- Was `zshrs: command not found: NAME`. zsh's format is `zsh:LINE: command not found: NAME`. Updated all three eprintln sites to `zshrs:1: command not found: ...`. Test: `test_command_not_found_includes_line_number`.

## Closed (eighteenth-pass — noclobber + pwd -P + function-with-parens)

### `setopt noclobber` blocks `>` overwrite of existing files

- The option was tracked but the redirect path always called `File::create` (which truncates). Split `r::WRITE` from `r::CLOBBER` (the `>!` / `>|` op) and added a noclobber check: `setopt noclobber` writes the inverted-name `clobber=false`, so the check honors both keys (`noclobber=true` OR `clobber=false`). On hit:
  - Print `zshrs:1: file exists: PATH` to stderr.
  - Set `last_status = 1`.
  - Sink the upcoming command's stdout to `/dev/null` (so e.g. `echo second > existing` doesn't leak `second` to the terminal — matches zsh's "command silently dropped" semantics).
- `>!` / `>|` (CLOBBER) bypasses the check unconditionally. Tests: `test_noclobber_blocks_overwrite_and_sinks_output`, `test_noclobber_force_overwrites_with_bang`.

### `pwd -P` realpaths the logical PWD

- `builtin_pwd` ignored its `args` (only saw `redirects`), so `-P` was silently dropped and the logical `$PWD` was always printed. Routed dispatch through new `builtin_pwd_with_args(&[String])` that parses `-L`/`-P` flags. `-P` realpaths the tracked `$PWD` via `canonicalize()`. Test: `test_pwd_dash_p_realpaths` (delegates expected value to `/bin/zsh`).

### `function name() { body }` — keyword + parens combo

- The `function` keyword path collected names from `String` tokens and broke on `Inoutpar` / `Inbrace`. But the lexer packs `bar()` as a single String token suffixed with INPAR+OUTPAR markers (`\u{88}\u{8a}`), so the `name=bar()` token went into `names` literally and the body parsed under that wrong name. Added a strip step: detect the `\u{88}` ... `\u{8a}` suffix on a String token, trim it, then untokenize → clean `bar` name. Test: `test_function_keyword_with_parens`.

## Closed (nineteenth-pass — DQ array flags + slices + bg-pid + readonly arrays + print -s)

### `${(o/O/n/i/u)a}` array-flag suppression in DQ context

- zsh applies these array-only flags only when the expansion is in array context (no surrounding `"..."`); inside DQ they're no-ops and the result is the original elements joined as a scalar. Two changes:
  - `BUILTIN_PARAM_FLAG`: strip `o`/`O`/`n`/`i`/`u` chars from the flags string when DQ-context is detected (either via runtime `in_dq_context` counter or compile-time `\u{02}` sentinel prefix).
  - `compile_word_str` fast path tags the emitted flags with the `\u{02}` sentinel when the raw word is DNULL-wrapped or when we're recursing into a DQ-wrapped parent's Expansion segment (tracked via new `dq_context_depth: i32` on the compiler).
  - The bridge path (`BUILTIN_EXPAND_TEXT`) forces mode 1 (DoubleQuoted) when `dq_context_depth > 0`, propagating DQ semantics through nested expansions.
  - `(M)` is NOT stripped here — it modifies `:#pat` filter behavior on the joined scalar in DQ context (verified against /bin/zsh).
- Tests: `test_dq_suppresses_array_only_sort_flags`, `test_no_dq_sort_flags_still_apply`, `test_dq_suppresses_unique_flag`, `test_dq_suppresses_natural_sort`. Updated 5 pre-existing tests in `no_tree_walker_dispatch.rs` and `zshrs_shell.rs` that codified the old (zsh-incorrect) "always sort" behavior — they now assert array context (no DQ wrapper).

### `${@:N:M}` / `${arr:N:M}` — slice positionals/arrays as elements

- The substring path applied char-indexed scalar slicing to `@`/`*` and arrays. Now element-aware:
  - `${@:N:M}` and `${*:N:M}` slice positionals where index 0 is `$0`, 1 is `$1`, etc. (matches zsh).
  - `${arr:N:M}` slices `arr` with N as a 0-based "skip N" offset (so `arr=(x y z w); ${arr:1:2}` → `y z`).
  - Negative offsets count from the end.
- Three call sites updated (`BUILTIN_PARAM_SUBSTRING`, the compile-modifier `apply_var_modifier`, and the bridge `expand_braced_variable`'s inline parser). Helpers `slice_array_zero_based` and `slice_positionals` added. Tests: `test_positional_slice_skip_offset`, `test_positional_slice_no_length`, `test_array_slice_offset_skips`, `test_at_subscript_inclusive_range`.

### `$!` after `cmd &`

- `BUILTIN_RUN_BG` discarded the parent's pid. Now records into `self.variables["!"]` so `wait $!` works. `get_variable("!")` defaults to `"0"` when never set (matches zsh's pre-fork display). Tests: `test_bang_pid_after_background`, `test_bang_pid_initial_zero`.

### `declare -ra` / `typeset -ra` — block array mutation

- `BUILTIN_SET_ARRAY` and `BUILTIN_APPEND_ARRAY` now check the readonly status (both `readonly_vars` and `var_attrs[name].readonly`). On hit: emit `zshrs:1: read-only variable: NAME` and `std::process::exit(1)` (mirrors zsh `-c` fatal). Tests: `test_declare_ra_blocks_array_assign`, `test_declare_ra_blocks_append`.

### `print -s` records to history (silent), `fc -l` lists session entries

- Two changes:
  - `print -s X` now suppresses stdout output entirely — per zsh's man page, `-s` "places the results in the history list INSTEAD OF on the standard output". Was printing to stdout AND adding to history.
  - `fc -l` in `-c` (non-interactive) mode now bypasses its "no such event" guard when the script has explicitly added entries via `print -s`. Tracks them via new `session_history_ids: Vec<i64>` field; `fc -l` looks each up by ID and renumbers 1..N so the script sees clean contiguous IDs (not the SQLite global counter).
- Test: `test_print_s_silent_and_records_history`.

### `select` menu — multi-column packed format

- Menu items were one per line. zsh packs `N) item` cells across rows to fit the terminal (defaults to 80 cols). Width = max cell + 1 trailing space. Cosmetic match.

## Closed (twentieth-pass — (z) split + unalias query + kill flags)

### `${(z)str}` — proper shell-token split

- Was a plain `split_whitespace()` so `"echo hi; ls"` produced 3 tokens (`echo`, `hi;`, `ls`) instead of 4 (`echo`, `hi`, `;`, `ls`). New `zsh_split_z` helper walks the string honoring single/double quotes (with escape) and splitting out shell metas (`;`, `&`, `|`, `<`, `>`, `(`, `)`) as their own tokens, with combination of repeats (`&&`, `||`, `;;`, `>>`, `<<`). Tests: `test_z_split_emits_metas_as_separate_tokens`, `test_z_split_pipe_token`.

### `alias NAME` query is silent on unknown name

- Was emitting `zshrs: alias: NAME: not found` which zsh doesn't print. The query just exits non-zero in zsh. Removed the diagnostic; status code unchanged. Test: `test_alias_query_silent_when_unknown`.

### `kill -l` and `kill -L`

- `kill -l` was printing a numbered table (`1) SIGHUP\n…`); zsh emits bare names space-separated on one line. Switched to match.
- `kill -L` was treated as a list-mode alias for `-l`. zsh treats it as `-` + signal name `L` → "unknown signal: SIGL" with the standard hint. Switched to error path for parity. Tests: `test_kill_dash_l_lists_bare_names`, `test_kill_dash_capital_l_unknown_signal`.

## Closed (twenty-first-pass — integer arith + (e) eval + assign no-glob + type format)

### `integer i; i=5*3` — arith-evaluate when var has integer attribute

- Two compounding bugs:
  - `compile_assign`'s Scalar branch unconditionally called `compile_word_str(value)`, which routed `5*3` through expand_text + glob → NOMATCH error. Added a DQ-wrap step: when the value contains glob metas (in either META-encoded form `\u{87}` or literal `*`), wrap with DNULL markers so the bridge picks mode 1 (DoubleQuoted) and skips brace+glob expansion. `$var` / `$(cmd)` / `$((expr))` still expand.
  - `BUILTIN_SET_VAR` now checks `var_attrs[name].kind == Integer`. If so, runs `eval_arith_expr(value)` before storing — `i=5*3` lands as `15`. Test: `test_integer_attribute_arith_evaluates_assignment`, `test_bare_assignment_does_not_glob_expand`.

### `${(e)var}` — parameter expansion, not command execution

- The `(e)` flag was running the value as a shell command via `run_command_substitution`. Per `zshexpn(1)`, `(e)` should "perform parameter expansion, command substitution and arithmetic expansion" — which is `expand_string`. Switched. `s="\$test"; test=val; ${(e)s}` now correctly returns `val`. Test: `test_paren_e_flag_expands_parameters`.

### `type NAME` unknown format

- Was `zshrs: type: NAME: not found` (stderr, with prefix). zsh emits `NAME not found` on stdout (no prefix). Switched format and stream. Test: `test_type_unknown_format_matches_zsh`.

## Closed (twenty-second-pass — echo escapes + export -n + xtrace)

### `echo` interprets escapes by default

- zsh's default `echo` interprets `\n`/`\t`/`\b`/etc. unless `setopt bsd_echo` is on; `-e` is unnecessary. zshrs had `interpret_escapes = false` default. Switched to `!bsd_echo` so the default is ON; `-E` continues to disable. Tests: `test_echo_default_interprets_escapes`, `test_echo_dash_capital_e_disables_escapes`.

### `export -n` rejected as bash-only

- zsh treats `export -n` as a bad option (bash uses `-n` to remove export attribute); zshrs accepted it. Now rejects any `-X` flag besides `-p` with `zshrs:export:1: bad option: -X` and exit 1. Tests: `test_export_dash_n_rejected`.

### `set -x` / `setopt xtrace` — print commands before execution

- The option was tracked but never enforced. Added new `BUILTIN_XTRACE_LINE` (id 338): pops a literal command-text string and prints it to stderr with `$PS4` prefix (default `+ `) when `xtrace` is on. The compiler emits the trace call before each simple command's args/dispatch in `compile_simple`. Format is the POSIX `+ cmd args` style — zsh's elaborate `<color>PROG\tFN\tLINENO\t<reset>\tcmd` format depends on PROMPT_PERCENT and isn't matched exactly (our format is what real-world POSIX scripts assume). Tests: `test_set_dash_x_xtrace_prints_commands`, `test_set_plus_x_disables_xtrace`, `test_xtrace_uses_ps4`.

## Closed (twenty-third-pass — default expansion + hex escape + break N)

### `${var:-...}` / `${var:=...}` / `${var:+...}` expand cmd-subst and `$var` in operand

- The default/alt operand was used as-is. zsh runs full expansion (parameter, command-substitution, arith) on it before substitution. Wired `expand_string` lazily in `BUILTIN_PARAM_DEFAULT_FAMILY` for all four ops. Tests: `test_default_value_expands_command_substitution`, `test_default_value_expands_variable`, `test_assign_default_expands`.

### `echo "\xHH"` hex escape

- The escape decoder only handled `\n`/`\t`/`\xNN was missing despite octal `\NNN` working`. Added `\xHH` (1-2 hex digits) to `expand_printf_escapes`. Test: `test_echo_hex_escape`.

### `break N` / `continue N` — multi-level loop control

- Were ignoring the level argument; always targeted the innermost enclosing loop. Now reads `simple.words[1]` as the level count, indexes back into `break_patches` / `continue_patches` from the end, clamping to depth. Tests: `test_break_n_breaks_outer_loop`, `test_continue_n_continues_outer_loop`.

## Closed (twenty-fourth-pass — pattern expansion + `[*]` join + wait validation)

### `${var/$pat/X}` / `${var//$pat/X}` — expand `$pat` and `$X`

- The pattern and replacement operands were taken as-is. zsh expands parameter, command-substitution, and arith in both before applying. Wired `expand_string` on both at the top of `BUILTIN_PARAM_REPLACE`. Tests: `test_replace_pattern_expands_dollar_var`, `test_replace_global_pattern_expands`.

### `${arr[*]}` joins with first IFS char

- Both `[@]` and `[*]` emitted `BUILTIN_ARRAY_ALL` (always Value::Array → splice). Added `BUILTIN_ARRAY_JOIN_STAR` (id 339) that joins on first IFS char and returns Value::Str. Compiler picks via `array_splice_is_star(s)` test. Tests: `test_array_star_joins_with_first_ifs`, `test_array_at_keeps_separate_words`.

### `wait PID` validates child ownership

- `wait 99999` was returning 0 silently. zsh emits `pid N is not a child of this shell` and exits 127. `builtin_wait` now checks the PID against `$!` and the active jobs list before calling `wait_for_job`. Test: `test_wait_unknown_pid_errors`.

## Closed (twenty-fifth-pass — `$(< file)` + `printf %q`)

### `$(< file)` — zsh file-contents shorthand

- The `<` after `$(` (with optional whitespace) signals "read this file's contents". Faster than `$(cat file)`. Added at the top of `run_command_substitution`: trim leading `<`, expand `$`-refs and tildes in the filename, `read_to_string` it, strip trailing newline. Tests: `test_dollar_lt_file_reads_contents`, `test_dollar_lt_no_space`.

### `printf %q` — backslash-style quoting

- Was using single-quote wrapping (bash semantics). zsh's `%q` matches `${(q)}` flag — backslash-escape shell-special chars. Updated both `printf_format_count`'s `'q'` branch and `builtin_printf`'s `'q'` branch. Tests: `test_printf_q_uses_backslash_quoting`, `test_printf_q_safe_word_unquoted`.

## Closed (twenty-sixth-pass — `$((~N))` bit-NOT + `${s%$var}` strip expansion)

### `$((~N))` bitwise NOT no longer mistriggers tilde expansion

- The arith evaluator unconditionally ran `expand_string` on the expression text. For `$((~0))`, expand_string treated leading `~` as tilde-name (`~0` → "no such user: 0" fatal). Three eval entry points (`evaluate_arithmetic`, `eval_arith_expr`, `eval_arith_expr_float`) now skip `expand_string` when the expression has no `$` or `` ` `` (no var/cmd-subst/nested-arith to resolve). MathEval handles bare `$NAME`-less arith on its own. Tests: `test_arith_bitwise_not`, `test_arith_bitwise_not_in_expr`, `test_arith_dollar_var_still_works`.

### `${s%$var}` / `${s##$var}` — expand `$var` in strip pattern

- Same shape as the prior fix to `${var/$pat/}`: pattern operand was emitted literally. `BUILTIN_PARAM_STRIP` now runs `expand_string` on the pattern before glob-matching. Tests: `test_strip_pattern_expands_dollar_var`, `test_strip_long_pattern_expands`.

## Closed (twenty-seventh-pass — substring negative-length + shift validation + echo combined flags)

### `${s:0:-N}` substring negative length truncates from end

- The compile path passed `length=-1` for "no length given" — same value as an explicit `:0:-1`. Switched the sentinel to `i64::MIN` so the runtime can distinguish:
  - `i64::MIN` → no length given, take rest
  - `< 0` → "stop |N| chars before end" (bash/zsh)
  - `>= 0` → take exactly N chars
- Tests: `test_substring_negative_length_truncates_from_end`, `test_substring_offset_and_negative_length`, `test_substring_no_length_takes_rest`.

### `shift N` errors when N > $#

- Was silently shifting min(N, len). zsh emits `zsh:shift:1: shift count must be <= $#` and exits 1. Now matches. Test: `test_shift_too_many_errors`.

### `echo -nE` combined flags

- The flag parser only matched exact `-n`/`-e`/`-E` strings — combined forms like `-nE` were treated as positional args. Now walks the flag body char-by-char, requiring all chars to be recognised echo flags. Test: `test_echo_combined_flags`.

## Closed (twenty-eighth-pass — `(l/r)` padding + quoted-glob test patterns)

### `${(l:N:)s}` left-pad and `${(r:N:)s}` right-pad flags

- The PadLeft/PadRight enum existed but the BUILTIN_PARAM_FLAG fast-path (`${(l:5:)s}` form) didn't recognise them. Added an `'l' | 'r'` arm to the dispatcher: parses the colon-delimited width, optional `:fill:` segment, and pads with truncate-on-overflow. Tests: `test_left_pad_flag`, `test_right_pad_flag`, `test_left_pad_with_fill_char`.

### `[[ X == "a*" ]]` — quoted glob meta is literal

- Was treating any `*`/`?`/`[` in the RHS as glob metacharacters regardless of quoting. zsh treats quoted metas as literal. Added `escape_quoted_glob_metas` helper in compile_cond's Binary path: walks the lexer-tokenized RHS, tracks SNULL/DNULL boundaries, prepends a `\` to glob metas inside quoted regions. Then taught `glob_match_static`'s regex translator to treat `\X` as literal X (escaping the regex meta if needed). Tests: `test_quoted_glob_pattern_in_test_is_literal`, `test_quoted_literal_star_matches_quoted_literal_star`, `test_unquoted_glob_pattern_still_matches`.

## Closed (twenty-ninth-pass — `[^...]` glob negation + `read` EOF return)

### `[^abc]` glob char-class negation

- The underlying `glob` crate (fnmatch-derived) only recognises `[!abc]` for class negation. Pre-fix, `echo [^a]` matched files literally containing `^` or `a` — completely inverted. Added a small pre-pass in `expand_glob` that walks the pattern and converts `[^` → `[!` only inside bracket regions. Test: `test_glob_caret_negation`.

### `read` returns 1 on partial-line EOF

- Was returning 0 on any successful byte read, even when the input ended without a delimiter. zsh returns 1 in that case so `while read line` loops terminate cleanly. Added a `hit_terminator` tracker; on EOF without newline, assign the variable but return 1. Test: `test_while_read_returns_1_at_eof_no_newline`.

## Closed (thirtieth-pass — `${1+...}` + `~$VAR` + `(L+N)` size)

### `${1+arg}` / `${5-default}` — positional set/unset detection

- `BUILTIN_PARAM_DEFAULT_FAMILY` checked existence via `variables.contains_key`/`arrays.contains_key`/etc. Positional params live in `positional_params: Vec<String>` and weren't found by name. Added a digit-name branch that compares the parsed index against `positional_params.len()` (with `$0` always set). Test: `test_positional_default_plus_returns_alt_when_set`, `test_positional_default_plus_unset`.

### `~$VAR` and `~"$VAR"` tilde + dollar expansion

- The compile-side `split_word_segments` was emitting `~` as a separate Literal segment from the `$VAR` Expansion, defeating tilde-expansion. Skip the segment split when `untoked.starts_with('~') && contains '$'` so the bridge sees `~$VAR` whole and routes through `expand_tilde_named`.
- `expand_tilde_named` then resolves `$VAR` itself and strips surrounding quotes (so `~"$USER"` works the same as `~$USER`). Tests: `test_tilde_with_dollar_var`, `test_tilde_with_quoted_dollar_var`.

### `(L+N)` size-glob qualifier — default unit is bytes

- The `L` qualifier defaulted to 512-byte blocks (zsh ksh-mode but not the modern default). Switched default unit to bytes so `(L+3)` correctly means "more than 3 bytes". Suffix units (`k`/`m`/`g`/`p`) still work. Also extended `looks_like_glob` to treat trailing `(qualifier)` as a glob trigger so NOMATCH fires for unmatched qualifier-only patterns. Test: `test_glob_qualifier_size_l_uses_bytes`.

## Closed (thirty-first-pass — function override + `[ ]` test form)

### User function overrides shadowed builtins (`r`, `echo`, `pwd`, `true`, `false`, `cd`, `print`, `printf`)

- zsh dispatch order: alias → function → builtin → external. `name() { ... }; name args` must run the user function, not the builtin. zshrs's compile path emitted `Op::CallBuiltin` directly for any name in `fusevm::shell_builtins::builtin_id`, so a user function never had a chance to win. `r` was the most painful: `r() { echo $1; }; r 5` infinite-looped because `builtin_r` runs `fc -e -` (history-replay) and re-executed the previous script — every iteration re-registered the function and re-called itself.
- Added a `try_user_fn_override(name, args)` helper (src/exec.rs) that consults `functions_compiled` + `function_exists`, then routes through `dispatch_function_call`. Wired into the `r`, `cd`, `pwd`, `echo`, `print`, `printf`, `true`, `false` builtin handlers. Tests: `test_user_function_overrides_r_builtin`, `test_user_function_overrides_echo_builtin`, `test_user_function_overrides_pwd_builtin`, `test_user_function_overrides_true_builtin`.

### `[ a -eq b ]` test-form always returned 0 (huge bug)

- The compile-time "dynamic command name" check at `compile_zsh.rs:520` flagged any first word containing `[` as needing `Op::Exec` dispatch (so `cmd[$i]` etc. resolves through host.exec). When the first word was literally `[`, that diverted `[` away from `BUILTIN_TEST` and into external `/usr/bin/[` — which on macOS is a quirky BSD test that returned 0 for the malformed-arg shapes we passed (the `]` was being captured as another argv slot). Result: every `[ ... ]` test returned true unconditionally, breaking every script that used `if [ ... ]`, `while [ ... ]`, `until [ ... ]`, `[ ... ] && cmd`. Catastrophic.
- Carved out `[` and `[[` from the dynamic-name check before the glob-char trigger fires (`first_is_test_builtin = first_untoked == "[" || first_untoked == "[["`). They now dispatch to `BUILTIN_TEST` / `BUILTIN_COND` like any other builtin. Tests: `test_test_builtin_bracket_form_returns_correct_status`, `test_if_elif_chain_with_bracket_test`, `test_until_loop_with_bracket_test`.

## Closed (thirty-second-pass — assoc append, sort sub-flags, printf %g)

### `m+=(k v)` on associative arrays

- BUILTIN_APPEND_ARRAY blindly extended `exec.arrays` regardless of whether the name was an assoc, so `typeset -A m=(a 1); m+=(c 3)` left the new key/value in a parallel positional array and `${m[c]}` returned empty. Added an assoc-aware branch that consumes pairs into `exec.assoc_arrays`. Test: `test_assoc_append_pairs_adds_new_keys`.

### `(o)` / `(O)` sort sub-flags `n`/`i`/`a`

- `(oa)` (sort by array order = no-op) and `(Oa)` (reverse array order, no alpha-sort) were both being treated as plain alpha-sort. Same for `(on)` numeric sort and `(oi)` case-insensitive. Reworked the `'o' | 'O'` arm in `BUILTIN_PARAM_FLAG` to consume an optional `n`/`i`/`a` sub-letter and dispatch accordingly: `a` → reverse-only-if-O, `n` → f64 sort, `i` → case-insensitive sort, default → byte-sort. Tests: `test_param_flag_oa_preserves_array_order`, `test_param_flag_Oa_reverses_array_order`, `test_param_flag_on_numeric_sort`, `test_param_flag_oi_case_insensitive_sort`.

### `printf '%g\n'` shortest-representation float format

- `%g` was emitting the `%f` format unchanged (`3.14` → `3.140000`). Implemented a `format_g(val, prec, upper)` helper that picks `%e` when `exp < -4 || exp >= prec` else `%f`, strips trailing zeros after the decimal, and normalizes the exponent to `e±NN` (C99 shape). Test: `test_printf_g_uses_shortest_representation`.

## Closed (thirty-third-pass — typeset -i +=, ${(k)arr}, getopts)

### `typeset -i x=42; x+=8` did string concat instead of arithmetic add

- BUILTIN_APPEND_SCALAR_OR_PUSH always took the scalar concat branch (`format!("{}{}", prev, value)` → `"428"`). For a typeset-int variable `+=` should arithmetically add the RHS (which itself is arith-evaluated). Added an `is_integer` check from `var_attrs` and a parse + add path. Test: `test_typeset_int_plus_eq_arithmetic_add`, `test_typeset_int_plus_eq_arith_expression`.

### `${(k)arr}` on a regular (non-assoc) array returned empty

- The `'k'` arm in BUILTIN_PARAM_FLAG only consulted `assoc_arrays`. zsh's actual behavior on regular arrays: `${(k)arr}` returns the array's values themselves (a quirk — docs imply integer subscripts but the impl returns contents). Fall through to `arrays.get(&name)` for the regular case. Test: `test_param_flag_k_on_regular_array_returns_values`.

### `getopts` skipped the option immediately after an arg-taking flag

- After `getopts ab:c` consumed `-b X`, the arg-fetch branch advanced OPTIND by 2 but the bottom of the function unconditionally overwrote it back to `optind + 1`, leaving OPTIND on `X` instead of `-c`. Refactored the takes_arg branch to compute `(arg, advance)` once and apply at the end. Also clear OPTARG when an option doesn't take one (was leaking the previous arg's value into the next iteration). Test: `test_getopts_stops_after_arg_taking_option`.

## Closed (thirty-fourth-pass — set -u + default, (#) char-code, (n) natural sort, subshell env isolation)

### `set -u; echo "${var:-fb}"` aborted with "parameter not set"

- BUILTIN_PARAM_DEFAULT_FAMILY called `get_variable(name)` which honors `nounset` and exits 1 before the modifier got a chance to provide the default. The whole point of `${var:-fb}`, `${var-fb}`, `${var:+alt}`, `${var+alt}` is to handle missing values; the lookup must be silent. Save/restore `nounset` + `unset` options around the get_variable call. Test: `test_set_u_with_default_modifier_does_not_abort`.

### `${(#)val}` char-code flag (arith → character)

- The `'#'` arm was missing from BUILTIN_PARAM_FLAG entirely; `${(#)65}` returned `65` instead of `"A"`. Added: arith-eval each element, output the char with that code point. Distinct from `${#name}` (length) which is parsed as the LENGTH op upstream. Test: `test_param_flag_pound_arith_to_char`. Also fixed an existing test in `tests/no_tree_walker_dispatch.rs` (`zshflag_array_length_via_pound`) that incorrectly expected `${(#)arr}` to mean array length — it's char-code, not length.

### `(n)` natural sort: `file2 < file10 < file20`

- Previous impl was `f64::parse()` per-element, which returned 0.0 for all `file*` strings and left the array order untouched. Wrote a `natural_cmp(a, b)` helper that walks both strings in parallel, treating runs of digits as integer chunks (length-then-byte-cmp, with leading-zero tiebreak). Test: `test_param_flag_n_natural_sort`.

### `(export y=v)` in subshell leaked `y` to the parent shell

- zshrs runs `(...)` subshells in-process for perf (no fork). The subshell snapshot/restore covered `variables`, `arrays`, `assoc_arrays`, `positional_params`, and cwd — but NOT the OS env table. When the body called `env::set_var` (via `export` or `cd`'s `$PWD` write), those writes survived past `subshell_end` and corrupted the parent's environment. Added `env_vars` field to `SubshellSnapshot` (snapshot at begin via `std::env::vars().collect()`) and restore at end (remove keys not in snapshot, re-set keys whose values changed). Tests: `test_subshell_export_does_not_leak_to_parent`, `test_subshell_unset_does_not_leak_to_parent`.

### `getopts` over-advanced OPTIND past the next flag *(see thirty-third-pass above)*

## Closed (thirty-fifth-pass — coreutils builtins respect user overrides)

### `cat() { ... }; cat` ran the C builtin instead of the user function

- Same root cause as the prior `r`/`echo`/`pwd` fix, but now extended to fusevm's coreutils-style anti-fork builtins (`cat`, `head`, `tail`, `wc`, `basename`, `dirname`, `touch`, `realpath`, `sort`, `find`, `uniq`, `cut`, `tr`, `seq`, `rev`, `tee`, `sleep`, `whoami`, `id`, `hostname`, `uname`, `date`, `mktemp`, `mkdir`). Each handler bypassed user functions because the compiler emitted `Op::CallBuiltin` directly. zpwr/oh-my-zsh wrap most of these, so override-blindness was a major real-world breakage.
- Introduced a `reg_overridable!($vm, $id, $name, $method)` macro at the top of `register_builtins`. Each registration now consults `try_user_fn_override` before falling through to the native handler. Test: `test_user_function_overrides_coreutils_builtins` covers 11 representative cases.

## Closed (thirty-sixth-pass — `${(Q)s}` dequote)

### `${(Q)s}` — strip one layer of shell quoting

- The `'Q'` arm was missing entirely from `BUILTIN_PARAM_FLAG`. Added: balanced single-quote (`'…'` → literal contents), double-quote (`"…"` → process `\"`/`\\`/`\$`/`` \` `` escapes), and bare-string (drop one backslash per escape) handling. Test: `test_param_flag_Q_dequote`.

### Deferred — command substitution exit status to `$?`

- `cmd_out=$(false); echo $?` should report 1 (zsh: assignment is transparent w.r.t. `$?`), but `echo $(false); echo $?` should report 0 (zsh: the enclosing command's status overrides). zshrs currently propagates neither — `cmd_out=$(false); echo $?` reads 0. Fixing it requires a generation counter that fires on every Op::SetStatus to invalidate a "pin" set by cmd-substitution. fusevm doesn't expose a hook for SetStatus, and a value-only witness (compare current vm.last_status to the value at pin-time) collapses when both happen to be the same number. Documented as a known divergence; keep tests for the closed cases stable. Affects `$?` after cmd-substitution-only RHS.

## Closed (thirty-seventh-pass — pipeline last-stage in current shell)

### `echo hi | read x; echo "x=$x"` showed empty (zsh: `x=hi`)

- Major behavioral divergence: zsh runs the LAST stage of a pipeline in the CURRENT shell process (not a forked child) so trailing `read x` keeps its assignment in the parent. zshrs's BUILTIN_RUN_PIPELINE was forking every stage, including the last — same behavior as bash, wrong for zsh. Refactored: fork stages 0..N-1, dup2 the final pipe's read end onto stdin in the parent, run the last stage's chunk inline on a sub-VM with `set_shell_host(Box::new(ZshrsHost))` so any reads/assignments hit the parent executor's variables, then restore stdin. The in-parent stage's status is appended to `pipestatus` so the array still has one entry per stage. Tests: `test_pipeline_last_stage_runs_in_current_shell`, `test_pipeline_last_stage_assignment_persists`.

## Closed (thirty-eighth-pass — SIGPIPE-in-pipeline-child, `type` alias format)

### `seq 1 100 | head -3` panicked with "Broken pipe (os error 32)"

- The parent shell ignores SIGPIPE so it can detect EPIPE on writes itself, but pipeline children inherited that disposition. When the downstream reader closed early, the producer's `println!` returned EPIPE and Rust's stdout write panicked. Reset SIGPIPE to `SIG_DFL` in each forked pipeline child immediately after `fork()` so the child gets killed cleanly on broken pipe (matches zsh/bash behavior). Test: `test_pipeline_child_handles_sigpipe_gracefully`.

### `type alias_name` printed bash format instead of zsh format

- Was emitting `"{name} is aliased to \`{value}'"` (bash `type` shape). zsh prints `"{name} is an alias for {value}"` (no backticks, "for" not "to"). Test: `test_type_alias_uses_zsh_format`.

## Closed (thirty-ninth-pass — `command -v` resolution order)

### `command -v echo` printed `/bin/echo` instead of `echo`

- `builtin_command` jumped straight to a PATH walk for `-v`/`-V`, so every name resolved to its external (or "not found"). zsh's resolution order is alias → function → shell builtin → reserved word → external; only the external case prints a path. Added each tier in order before the PATH walk: aliases print `alias k=v` (verbose: "k is an alias for v"), functions/builtins/reserved words print just the name, externals print the resolved path. Tests: `test_command_v_resolution_order_matches_zsh`, `test_command_v_missing_returns_nonzero`.

## Closed (fortieth-pass — `which` csh format, `read` backslash processing)

### `which echo` printed just `echo` instead of `echo: shell built-in command`

- The `csh_style` (-c) branch in `builtin_whence` had a builtin case missing — it fell through to the plain `println!("{}", name)`. zsh's `which` (and `whence -c`) emits `name: shell built-in command` for shell builtins. Added the `csh_style` arm. Test: `test_which_for_builtin_shows_csh_format`.

### `read` (without `-r`) didn't process `\X` escapes — backslash + char came out literally

- Previous impl was `if !raw_mode { input.replace("\\\n", "") }` which only handled the line-continuation case. POSIX read (no -r) drops one backslash from every `\X` pair: `\b` → `b`, `\\` → `\`, `\<newline>` → both stripped. Replaced with a char-iterator pass. Test: `test_read_processes_backslash_escapes_without_dash_r`.

## Closed (forty-first-pass — `$(cmd)` IFS word-split in argument context)

### `f $(echo a b c)` passed one arg instead of three

- zsh splits the result of bare `$(cmd)` on `$IFS` when the substitution sits in argument context (`f $(...)`, `for x in $(...)`, etc.). zshrs's BUILTIN_CMD_SUBST_TEXT was returning a single Value::str without splitting, so `f $(echo a b c)` invoked f with one joined arg and `$#` reported 1.
- Emit a `BUILTIN_WORD_SPLIT` op after the cmd-subst handler unless the surrounding word is DQ-wrapped (`"$(...)"`) or we're inside an assignment RHS. Added an `assign_context_depth` field to ZshCompiler that's bumped around `compile_assign`'s `compile_word_str` call so `x=$(printf 'a\nb\nc')` keeps both lines (assignment shouldn't split). Tests: `test_cmd_subst_word_splits_in_argument_context`, `test_cmd_subst_no_split_in_dq_context`, `test_cmd_subst_no_split_in_assignment`.

## Closed (forty-second-pass — `-f` startup flag turns off rcs+hashdirs)

### `zshrs -f -c 'setopt'` printed empty (zsh: `nohashdirs\nnorcs`)

- `setopt` (no args) lists options whose state differs from the compiled-in default. zsh's `-f` flag turns off `rcs` (skip user .zshrc et al) AND `hashdirs` (don't pre-hash command paths) — both default-on options, so they appear as `norcs` / `nohashdirs` in `setopt`'s diff. zshrs's `-f` only filtered the flag from arg parsing without flipping any options, so `setopt` reported nothing.
- Captured `no_rcs_flag = args.iter().any(|a| a == "-f" || a == "--no-rcs")` before the filter and threaded it into `apply_cli_flags`. Inserts `rcs=false` and `hashdirs=false` into `executor.options` (left `globalrcs` untouched — zsh `-f` keeps that on, only user-rcs files get skipped). Test: `test_dash_f_flag_disables_rcs_and_hashdirs`.

## Closed (forty-third-pass — default aliases match zsh)

### `alias` listing missing zsh's compiled-in defaults

- zsh ships two aliases by default: `run-help=man` and `which-command=whence`. Visible in a fresh `zsh -f -c 'alias'`. zshrs's executor started with an empty alias map. Pre-populated `aliases` HashMap with these two entries in `ShellExecutor::new()`. Test: `test_default_aliases_match_zsh`.

## Closed (forty-fourth-pass — `.*` glob excludes `.`/`..` and matches dotfiles)

### `echo .*` returned `./.` `./..` instead of the actual dotfiles

Two bugs in `expand_glob`:
- The Rust `glob` crate includes `.` and `..` in its results when the pattern matches them. zsh always excludes those even with `dotglob`. Added a textual `rsplit('/').next()` filter (`Path::file_name` returns None for `.`/`..` so the structured API doesn't catch them).
- With `dotglob` off, `glob`'s `require_literal_leading_dot` blocked the dot-prefixed pattern from matching dotfiles even though the leading `.` IS literal in `.*`. zsh's actual rule: when the LAST path component starts with `.`, the leading `.` is literal so dotfiles match (no setopt needed). Set `dotglob = true` for this case before passing to `glob_with`.

Tests: `test_dot_glob_excludes_dot_and_dotdot`, `test_star_glob_excludes_dotfiles_by_default`.

## Closed (forty-fifth-pass — `${var/pat/repl}` glob patterns)

### `${s/?/X}` was treating `?` literally

- `BUILTIN_PARAM_REPLACE` used `String::replace` for plain text matching, which doesn't honor zsh's pattern syntax. zsh patterns in the replace form support `?` (any single char), `*` (any sequence), and `[...]` (char class). Compile a regex from the glob pattern (escaping regex-only metas) when the pattern contains glob chars; fall back to plain string for the meta-free fast path. Anchored prefix (`/#`) and suffix (`/%`) variants both honor the regex match position. Tests: `test_param_replace_glob_pattern_question`, `test_param_replace_glob_pattern_star`, `test_param_replace_glob_pattern_class`, `test_param_replace_global_with_glob`.

## Closed (forty-sixth-pass — `[[ $s =~ $pat ]]` variable expansion)

### `pat="^h"; [[ "hello" =~ $pat ]]` never matched

- The `[[ s =~ $pat ]]` compile path emitted the RHS as a LoadConst literal (skipping `compile_word_str`'s expansion to avoid filesystem-glob expansion of pattern chars). That meant `$pat` reached the regex engine as the literal string `$pat` instead of its value, so the match always failed. Carved out `=~` separately: wrap the RHS in DQ markers (`\u{9e}…\u{9e}`) and route through `compile_word_str` so variable / cmd-subst / arith expansion fires. The DQ wrapper suppresses brace expansion + filesystem globbing — the test runtime treats the result as a regex pattern. Tests: `test_cond_regex_with_variable`, `test_cond_regex_with_capture_groups`.

## Closed (forty-seventh-pass — glob qualifier in pipeline child)

### `echo *(N) | wc -w` returned 0 in pipeline (zsh: file count)

- `expand_glob`'s parallel `prefetch_metadata` submits stat() jobs to a worker pool, but the pool's threads don't survive `fork()` (POSIX: only the calling thread persists). The pipeline child stage forked, then submitted work that nobody picked up — the rx loop blocked indefinitely OR the channel returned empty, depending on timing. Net effect: every glob with `>=32` matches in a pipeline stage produced empty output.
- Added `signals::is_forked_child()`: lazy-init MAIN_PID on first call, then compare current pid. Pre-warmed in `zshrs_main()` so the parent's pid is captured before any pipeline forks. `prefetch_metadata` now takes the serial stat path whenever `is_forked_child()` returns true. Test: `test_glob_qualifier_in_pipeline_child`.

## Closed (forty-eighth-pass — `${#@}` positional count + chars()-not-bytes for length)

### `${#@}` returned 5 instead of `$#`

- The `expand_braced_variable` length-form (`${#name}`) fell through to `get_variable("@")` which returns the IFS-joined positional string, then took its `.len()` (byte count). For `set -- a b c`, that produced `5` (length of `"a b c"`) instead of zsh's `3` (number of positional params).
- Special-cased `@` and `*` in the `${#…}` arm to return `positional_params.len()` directly.
- Also switched the scalar fallback from `.len()` (bytes) to `.chars().count()` (codepoints) so `${#héllo}` is 5 not 6 — matches zsh.

Tests: `test_param_length_at_star_returns_positional_count`, `test_param_length_uses_chars_not_bytes`.

## Closed (forty-ninth-pass — `exit N` fires EXIT trap)

### `trap 'cleanup' EXIT; exit 5` skipped the cleanup

- The implicit script-end path in `execute_script_zsh_pipeline` already ran the EXIT trap. But `builtin_exit` called `std::process::exit(code)` directly, bypassing the trap. Real-world scripts use `trap 'cleanup' EXIT` heavily for tempfile cleanup, db disconnect, etc. — `exit N` skipping the trap is a major compatibility break.
- Inserted the trap-fire (with same "remove first to prevent recursion" pattern as the implicit path) before `std::process::exit`. Set `last_status = code` first so the trap body sees the right `$?`. The subshell branch is unchanged — it returns to the subshell caller without running the trap (zsh defers EXIT trap to the outer process). Tests: `test_exit_builtin_fires_exit_trap`, `test_exit_trap_with_explicit_exit_in_trap_body`.

## Closed (fiftieth-pass — function-name-with-hyphen call dispatch)

### `foo-bar() { ... }; foo-bar` returned "command not found: foobar"

- `foo-bar` registered cleanly under its real name, but the call site went through compile_simple → add_name(first) where `first` was still the lexer's META-encoded form `foo\u{9b}bar` (`\u{9b}` is the META char for `-`). add_name stored the encoded string verbatim; CallFunction looked it up; missed the registered `foo-bar`; fell through to host.exec which reported "command not found" with the partly-cleaned form `foobar`.
- Untokenize `first` before `add_name` so the cleaned identifier reaches the name pool. Same fix applies to any function name with hyphens, dots, or other lexer-mangled chars in the call dispatch. Tests: `test_function_name_with_hyphen_dispatches_correctly`, `test_function_name_with_hyphen_passes_args`.

## Closed (fifty-first-pass — `typeset -f` body capture preserves first word)

### `typeset -f f` for `f() { echo a; echo b; }` printed `a; echo b;`

- All three `Inbrace` body-capture sites in `parser.rs` (parse_funcdef, parse_inline_funcdef, and the synthesized FuncDef path inside `parse_program_until`) called `zshlex()` BEFORE recording `body_start`. After consuming `{`, the next `zshlex()` advances past the first body token (`echo`), so `body_start` landed mid-body and the captured slice lost the first word. Result: `typeset -f f` / `functions f` / `whence -c f` all printed `a; echo b;` instead of `echo a; echo b;`.
- Hoisted `let body_start = self.lexer.pos;` BEFORE the `zshlex()` in all three sites (lexer.pos already points just past `{` after the outer zshlex consumed it). Test: `test_typeset_f_preserves_first_word_of_body`.

## Closed (fifty-second-pass — `${(t)readonly_var}` includes `-readonly` modifier)

### `readonly R=x; echo "${(t)R}"` printed `scalar` instead of `scalar-readonly`

- `format_zsh()` already appends `-readonly` to the type string when `var_attrs.readonly` is set, but `builtin_readonly` only inserted the name into `readonly_vars` (a separate HashSet for write-protection enforcement) without touching `var_attrs`. Result: write-protection worked, but `(t)` introspection couldn't see the readonly bit.
- Added the `var_attrs.readonly = true` set to both branches of `builtin_readonly` (the `name=value` and bare-`name` paths). Test: `test_t_flag_includes_readonly_modifier`.

## Closed (fifty-third-pass — `[[ -nt ]]` ns precision + `integer -r` readonly attr + `$*` IFS join)

### `[[ a -nt b ]]` returned false when files were touched within the same second

- Was using `MetadataExt::mtime()` (seconds only). `touch a; sleep 0.3; touch b; [[ b -nt a ]]` failed because both timestamps reported the same integer second. Switched to `metadata().modified()` (`SystemTime`, ns precision). Same fix applied to `BUILTIN_FILE_OLDER` (`-ot`). Test: `test_cond_nt_uses_nanosecond_precision`.

### `integer -r I=42; echo "${(t)I}"` printed `integer` instead of `integer-readonly`

- `builtin_integer` ignored its leading `-r` / `-x` flags entirely (the loop just `continue`'d on any `-` arg). Parsed flags into local bools, then composed `VarAttr { kind: Integer, readonly, export }` and inserted `name` into `readonly_vars` + env when applicable. Test: `test_integer_dash_r_sets_readonly_attr`.

### `IFS=":"; echo "$*"` joined positionals by space, not by `:`

- `get_variable("@" | "*")` was hardcoded to `.join(" ")`. POSIX: `$*` joins by the first char of `$IFS`. Updated to read `$IFS` and use its first char (default ` ` when unset). Note: `expand_string`'s DQ-context expansion of `"$*"` follows a different path that still produces only the first param — that's a deeper bug, deferred.

## Closed (fifty-fourth-pass — `${#argv}` count + DQ `"$*"` IFS-join (preserve `"$@"` splice))

### `${#argv}` returned 5 (joined-string byte length) instead of 3 (count)

- `argv` is zsh's named alias for the positional array. `expand_braced_variable`'s `${#…}` arm and `BUILTIN_PARAM_LENGTH` both special-cased `@` and `*` to return `positional_params.len()`, but missed `argv`. Added the alias + its subscripted forms (`argv[@]` / `argv[*]`) to both special-case lists. Test: `test_argv_length_returns_positional_count`.

### `v="$*"` captured only the first positional

- For bare `$@`/`$*`, compile_word_str emitted `BUILTIN_GET_VAR` directly (returns Value::Array of positionals so splice in argument context works). When that result reached the assignment via `pop_args`, the Array got flattened into separate args — `name="v"`, `value=<first positional>`, rest discarded.
- Detect DQ context (parent `s` is DNULL-wrapped OR `dq_context_depth>0`) and, ONLY for `$*`, follow GET_VAR with Pop + LoadConst(name) + `BUILTIN_ARRAY_JOIN_STAR` so the joined scalar replaces the Array on stack. `$@` keeps its Array splice (zsh: `"$@"` → each positional its own word). Tests: `test_dq_star_assignment_joins_with_ifs`, `test_dq_at_preserves_splice_semantics`.

## Closed (fifty-fifth-pass — `noglob` precommand + coreutils error message format)

### `noglob echo *` was glob-expanding `*` before noglob ran

- `noglob` is a precommand modifier — its args must NOT be glob-expanded. zsh handles this in the parser/lexer by marking the line "no-glob". zshrs's `builtin_noglob` set the `noglob` option AFTER its args had already been compiled+expanded, so `*` always expanded against the real cwd.
- Special-cased `noglob` in `compile_simple`: when `simple.words[0] == "noglob"`, peel off the leading word, emit a `BUILTIN_SET_RAW_OPT("noglob", true)` toggle, recursively compile the remaining `ZshSimple`, then emit the matching `BUILTIN_SET_RAW_OPT("noglob", false)` to restore. New `BUILTIN_SET_RAW_OPT` handler does a flat `options.insert/remove` (no validation, just the toggle). Test: `test_noglob_precommand_suppresses_glob`.

### `cat /no/such/file` printed `... (os error 2)` suffix

- Rust's `io::Error` display appends `(os error N)` by default. zsh's bundled coreutils emit just the friendly message (`No such file or directory`). Added `pretty_io_err(&io::Error) -> String` helper that strips ` (os error` and everything after; wired into `cat`/`head`/`tail`/`wc` error sites. Test: `test_coreutils_error_msg_strips_os_error_suffix`.

## Closed (fifty-sixth-pass — `((a[i]=v))` subscripted arith assignment)

### `((a[2]=42))` left the array unchanged

- Both runtime arith paths (`evaluate_arithmetic` for `$(())` and `compile_arith` → `ArithCompiler` for `(())`) ran `pre_resolve_array_subscripts` first, which substitutes `a[2]` with the current value (`0`). Result: arith engine saw `0=42` (invalid). The non-subscripted `a=42` path worked because no pre-resolve was needed.
- Added `parse_subscript_arith_assign(expr)` helper that detects `name[idx]=rhs` (rejecting `==` / `=~`). When matched: eval the index, eval the RHS, write back via `arrays.get_mut` (or `assoc_arrays.get_mut`, with auto-resize). Wired into `evaluate_arithmetic`.
- For compound `(())`, `compile_arith` now untokenizes the lexer-wrapped expr, strips outer parens, runs the same check via `subscripted_arith_assign_check`, and routes to `BUILTIN_ARITH_EVAL` instead of `ArithCompiler` (which has no array-write hook). Test: `test_arith_subscripted_array_assign`.

### Deferred — `nocorrect` precommand

- `echo step1; nocorrect echo hi; echo step3` only runs `echo step1` — the parser (the unmodifiable direct port of zsh's C parser) drops everything after `;` once `nocorrect` appears as a command name. zsh handles `nocorrect` as a precommand modifier in its lexer/parser via `lexflags.dbparens` / `incmdpos` machinery. zshrs's port has the field but no behavior. Stripping `nocorrect` in `compile_simple` doesn't help because the parser already lost the rest of the line. Documented as deferred — needs lexer-side handling.

## Closed (fifty-seventh-pass — `source` missing-file format/exit + `a[0]=v` invalid subscript)

### `source /no/such/file` printed Rust's wrapped error and exited 1

- Output was `source: /no/such/file: /no/such/file: No such file or directory (os error 2)` (path duplicated, Rust's "(os error N)" suffix appended) and exit 1. zsh: `zsh:source:1: no such file or directory: /no/such/file` and exit 127.
- Both branches (POSIX-mode and zshrs-mode) now strip Rust's "(os error N)" suffix, strip any duplicate-path prefix that wrapped errors carry, lowercase the message, and emit the canonical `zshrs:source:1: <msg>: <path>` shape with exit code 127. Test: `test_source_missing_file_zsh_format_and_exit_127`.

### `a[0]=hi` was silently accepted as a no-op

- zsh: arrays are 1-based, so `a[0]=v` is "assignment to invalid subscript range" (exits 1). zshrs's `BUILTIN_SET_ASSOC` had a `i == 0` arm that just `return`'d silently. Replaced with the diagnostic + `std::process::exit(1)`. Test: `test_array_zero_subscript_assignment_errors`.

## Closed (fifty-eighth-pass — `umask`/`cd`/abs-path-missing format pass)

### `umask` printed `0022` instead of `022`

- zsh prints 3 octal digits (`022`); zshrs's `{:04o}` format-spec emitted 4. Switched to `{:03o}`. Test: `test_umask_3_octal_digits_no_leading_zero`.

### `umask -S` printed `u=rwxg=rxo=rx` (no separators)

- The println! template was missing the commas between user/group/other groups. zsh emits `u=rwx,g=rx,o=rx`. Test: `test_umask_dash_S_uses_commas`.

### `cd /no/such/dir` emitted Rust's wrapped error format

- Was `cd: /not/a/dir: No such file or directory (os error 2)` (with the os-error suffix). zsh emits `zsh:cd:1: no such file or directory: PATH` (lowercased, prefixed). Switched to `pretty_io_err` + the canonical `zshrs:cd:1: <msg>: <path>` shape. Test: `test_cd_missing_dir_zsh_format`.

### `/nonexistent_xyz` (absolute path) said `command not found:`

- zsh distinguishes: relative names not in PATH → `command not found`; absolute paths that don't exist → `no such file or directory` (since no PATH search was attempted, the open() syscall reported ENOENT). Added a `cmd.starts_with('/')` branch in `execute_external` to switch the diagnostic. Test: `test_abs_path_missing_says_no_such_file_not_command_not_found`.

## Closed (fifty-ninth-pass — `kill -l` platform signals + `exec` missing-target + `fg`/`bg` non-interactive message)

### `kill -l USR1` printed 10 instead of 30 on macOS

- The signal_map was hardcoded to Linux signal numbers (USR1=10). macOS uses USR1=30. Replaced the literal numbers with `libc::SIGHUP`, `libc::SIGUSR1`, etc. — pulled from the platform's libc bindings, so the values are always correct on the build target. Test: `test_kill_l_uses_platform_signal_numbers`.

### `exec /nonexistent_xyz` emitted Rust's wrapped error and continued

- Was `exec: /nonexistent_xyz: No such file or directory (os error 2)` then continued running with status 1. zsh emits `zsh:1: no such file or directory: PATH` and exits the whole shell with 127 (exec target unfindable). Stripped Rust's wrapping via `pretty_io_err`, lowercased, used canonical `zshrs:1: <msg>: <path>` shape, switched to `std::process::exit(127)` since `exec` failure is fatal.

### `fg` / `bg` in non-interactive mode said "no current job"

- zsh emits `zsh:fg:1: no job control in this shell.` (with trailing period — quirky). Updated both messages. Test: `test_fg_bg_no_job_control_message`.

## Closed (sixtieth-pass — `kill -l` ordering + unknown number + `${(@P)}` indirect array)

### `kill -l` listed signals in declaration order, not number order

- zsh's `kill -l` prints the signal-name list ordered by signal number (HUP INT QUIT ILL TRAP ABRT EMT FPE …). zshrs was iterating the signal_map in declaration order which differed because the map groups by user-relevant categories instead. Sort by signal number before joining. Test: `test_kill_l_lists_signals_in_number_order`.

### `kill -l 100` errored "unknown signal: 100" instead of passing through

- zsh: unknown signal numbers print the number unchanged (`kill -l 100` → `100`). Removed the eprintln-and-no-output path; print the raw number when no name match. Test: `test_kill_l_unknown_number_passes_through`.

### macOS `EMT` signal missing from `kill -l` output

- macOS has SIGEMT (signal 7) which Linux doesn't. zsh on macOS lists it; zshrs's signal_map didn't. Added `#[cfg(target_os = "macos")]` entry for EMT. Folded into the "kill -l ordering" test.

### `${(@P)var}` returned the raw var name instead of dereferencing

- The `'P'` arm in BUILTIN_PARAM_FLAG only handled `St::S` (scalar). When `(@)` ran first and forced state to `St::A(["x"])`, the P arm's `a => a` arm left it alone — so `${(@P)var}` returned `x` (the var name) instead of `hello` (its value). Added an `St::A(names)` branch that maps each element through `get_variable`. Test: `test_param_flag_at_P_indirects_each_element`.

## Closed (sixty-first-pass — `typeset -f` body format + `${(c)#}`/`${(w)#}` flag-then-length)

### `typeset -f f` printed `echo F;` (with trailing semicolon) for `f() { echo F; }`

- The body source captured by the parser preserved the input's semicolons. zsh re-formats: each top-level statement on its own line, trailing semicolons stripped, indented with TAB. Added `format_function_body_zsh(body)` helper that walks the source, splits on top-level `;` and `\n` (depth-tracking parens/braces, ignoring inside quotes), trims each line, and joins with `\n\t`. Wired into all three display sites (typeset -f, functions, whence -c). Test: `test_typeset_f_zsh_format_one_stmt_per_line`.

### `${(c)#a}` and `${(w)#a}` returned 0 / empty

- The flag-prefix path `${(...)body}` extracted `var_name` by splitting `body` on non-alphanumeric chars — `#a` parsed as empty name then `a` as the rest, lookup returned empty, length returned 0. Added a special case for `rest.starts_with('#')`: parse the remaining identifier, return char-count by default (matches `(c)`'s semantics) or word-count when the `Words` flag is in the chain. Tests: `test_param_flag_c_pound_returns_char_count`, `test_param_flag_w_pound_returns_word_count`.

## Closed (sixty-second-pass — `tr -c` complement + `wc` BSD 8-char padding)

### `tr -d -c "0-9"` deleted digits instead of keeping them

- The `-c` flag (complement: invert set1) was being ignored entirely. `tr -d -c "0-9"` should delete everything NOT in 0-9 (leaving only digits); was treating it as `tr -d "0-9"` (deleting 0-9). Added a `complement = args.iter().any(|a| a == "-c" || a == "-C")` parse, then an `in_set1` closure that XOR's the membership check. Also handled `-c` without `-d`: every char NOT in set1 maps to the LAST char of set2 (coreutils tr semantics). Test: `test_tr_complement_with_delete`.

### `wc -l` printed `3` instead of `       3` (zsh-style 8-char padding)

- The println had `out.trim_start()` which stripped the `{:8}` right-aligned padding. zsh's bundled wc on macOS keeps the BSD 8-char padding even on stdin output. Removed the `trim_start`. Updated 3 invariant tests in `tests/no_tree_walker_dispatch.rs` that hardcoded the old (no-padding) format. Test: `test_wc_uses_bsd_8char_padding`.

## Closed (sixty-third-pass — `type` fn `from zsh` + `tail -c` + `umask -S` symbolic set)

### `type f` for a function lacked the `from zsh` suffix

- zsh prints `f is a shell function from zsh` (the suffix names the load source — `from zsh` for built-in functions, the absolute path for autoloaded ones). Was emitting just `f is a shell function`. Updated all `println!("{} is a shell function", name)` sites. Test: `test_type_function_says_from_zsh`.

### `tail -c N` parsed `N` as a filename

- builtin_tail was missing the `-c` (byte-count) flag. `tail -c 4` errored with `tail: 4: No such file or directory`. Added `-c` parsing (both `-c N` and `-cN` shapes), then a `bytes` short-circuit in the per-file loop that does `read_to_end` + slice from `len-N`. Test: `test_tail_dash_c_byte_count`.

### `umask -S u=rwx,g=rx,o=` was rejected as "invalid mask"

- builtin_umask only parsed numeric (`022`) input. zsh accepts symbolic (`u=rwx,g=rx,o=`) for set, computing the umask as `0777` minus the granted bits per class. Added a parse path: read current umask, split on `,`, for each `class=bits` segment translate `r/w/x` into a 3-bit value and apply to the named class (u/g/o/a). Test: `test_umask_dash_S_symbolic_set`.

## Closed (sixty-fourth-pass — `find -maxdepth` + `ulimit -a` zsh format)

### `find /tmp -maxdepth 0` recursed the entire tree

- The `-maxdepth N` flag was unrecognized — `find` always recursed unbounded. Added arg parsing for `-maxdepth N`, threaded `max_depth: Option<usize>` and `cur_depth: usize` through the recursive `walk()`, and gated descent with `if cur_depth >= md { return; }`. `-maxdepth 0` now prints only the starting path. Test: `test_find_maxdepth_caps_recursion`.

### `ulimit -a` printed the wrong format and order

- zsh format per line: `<flag>: <long-name> (<unit>)<padding>value`, ordered as -t (cpu) -f (file) -d (data) -s (stack) -c (core) -m (resident) -v (address) -n (descriptors) -u (processes). zshrs was emitting just `<long-name> (<unit>) value` in a different order with no `-flag:` prefix. Reordered the limits table, prefixed each row's label with `-flag:`, widened padding to 34 chars to match zsh's column alignment exactly. Test: `test_ulimit_dash_a_zsh_format`.

## Closed (sixty-fifth-pass)

### `alias -m "g*"` always wrapped value in single quotes

- `alias g=hi; alias -m "g*"` printed `g='hi'` instead of zsh's bare `g=hi`. The `-m` pattern-match path of `builtin_alias` printed `name='value'` unconditionally, ignoring the same metaless-value detection that the regular print path used. Routed `-m` through the shared `format_alias_kv` helper so it picks the bare form for values containing no shell metas. Test: `test_alias_dash_m_uses_unquoted_form_when_no_metas`.

### `shopt` falsely accepted as a builtin

- zsh has no `shopt` — that's bash-only. zshrs shipped a bash-compat `builtin_shopt` that listed all options when called with no args. Removed from VM dispatch (`compile_simple` now skips fusevm's `builtin_id` lookup for "shopt"), removed from coreutils-known-name table, removed from completion-suggestion lists, removed from `builtin help` text. `shopt` now produces `command not found: shopt` matching zsh exactly. Test: `test_shopt_is_command_not_found`.

### Consecutive array assigns `a=(1 2 3) b=(x y z)` dropped second

- After parsing `a=(1 2 3)`, the lexer flipped `incmdpos = false` on Outpar (correct for subshell-close), which stopped the next `b=(x y z)` from being recognised as Envarray. The parser then parsed `b=(x y z)` as a command word and emitted `command not found: b=(x y z)`. Reset `self.lexer.incmdpos = true` in `parse_assign` before returning the array branch so follow-up assigns get their proper Envarray/Envstring classification. Verified `g=(o1); f() { :; }` still parses correctly (the original guard rationale). Test: `test_consecutive_array_assignments_on_one_line`.

### Stepped char brace ranges `{a..z..2}` over-expanded

- zsh only expands UNSTEPPED char ranges (`{a..z}` → `a b c …`); stepped char forms `{a..z..2}` are left literal. zshrs was happily expanding `{a..e..2}` to `a c e`. Gated `expand_brace_sequence`'s char-range branch on `parts.len() == 2` (no explicit step) and added an identity-detection guard in `expand_braces` so the literal-pass-through doesn't infinite-loop on the recursive expansion. Tests: `test_brace_stepped_char_range_left_literal`, `test_brace_unstepped_char_range_still_expands`.

### `$((10/0))` returned `0` silently

- `evaluate_arithmetic` and `eval_arith_expr` both swallowed `MathEval::Err(_)` and returned 0/"0" with no diagnostic. zsh writes the underlying error (`division by zero`, etc.) to stderr in `zsh:LINE: <msg>` form. Surface the error message via `eprintln!("zshrs:1: {}", msg)` in both eval paths. Tests: `test_arith_division_by_zero_prints_error`, `test_arith_mod_by_zero_prints_error`. (Note: zsh additionally aborts the surrounding command on arith failure; zshrs still continues with a 0 substitution result. Full command-abort propagation deferred — needs expansion-time error plumbing through expand_string and the simple-command dispatch path.)

## Closed (sixty-sixth-pass)

### `$OSTYPE`, `$MACHTYPE`, `$VENDOR`, `$CPUTYPE` returned empty

- `params.rs` set them in the params table, but the executor's `get_variable` shortcuts past that table for special names — those four were missing arms entirely. Added live `libc::uname()`-driven arms in `get_variable`: `OSTYPE` → `<sysname-lowercased><release>`, `MACHTYPE` → `machine` (with `aarch64`/`arm64` shortened to `arm` to match zsh), `CPUTYPE` → raw machine, `VENDOR` → `apple` on macOS / `unknown` on Linux / `pc` elsewhere. Tests: `test_machtype_returns_arm_or_x86_64`, `test_ostype_starts_with_os_family`, `test_vendor_returns_apple_or_unknown`. (Note: zshrs's `$OSTYPE` shows the *current* kernel version (`darwin25.4.0`); the bundled zsh hardcodes its build-time version (`darwin21.3.0`). zshrs is more truthful — accepted as an upgrade.)

### `-f` mode eagerly autoloaded functions from FPATH/ZWC

- `MenkeTechnologies@codelabs:` `rm -f /tmp/file` triggered `command not found: zpwrLogConsoleErr` because zshrs scanned every fpath ZWC for unknown command names and found `~/.zpwr/autoload/common.zwc` containing the user's `rm` wrapper. zsh only autoloads when an explicit `autoload name` declaration was made — never on first call. The eager-scan path in `host.call_function` now checks `executor.options["rcs"]` and skips when rcs is off (`-f` sets rcs=false). Interactive sessions keep the legacy eager behavior. Test: `test_minus_f_skips_eager_fpath_autoload`.

### `case foo in (foo|bar) …` rejected as parse error

- The parser sets `incasepat = 1` AFTER consuming the `in` keyword, but the lexer reads the next token (the leading `(`) BEFORE that flag flips. With incasepat=0 the `(` fell into the `gettokstr('(', false)` branch and ate the entire `(foo|bar)` as one atomic glob-pattern token — the trailing `)` was never seen as a separate Outpar, so the pattern loop errored out. Set `self.lexer.incasepat = 1` BEFORE the `zshlex()` that advances past `in`. Tests: `test_case_paren_prefixed_pattern_accepted`, `test_case_paren_only_first_alt_matches`.

### `$-` returned empty (no shell-flag letters)

- zsh's `$-` returns the concatenated single-letter codes for the options currently set: baseline `569X` always, `f` when rcs is off (`-f`), then `e`/`u`/`x`/`v`/`n`/`l`/`h` for set -e / set -u / set -x / etc. zshrs returned an empty string. Added `"-"` arm in `get_variable` that builds the letter sequence in zsh's exact ordering (e BEFORE f, then login, nounset, xtrace, verbose, noexec, hashall) — matches `set -e; echo $-` → `569Xef`, `set -u; echo $-` → `569Xfu`, `set -x; echo $-` → `569Xfx`, `set -eu; echo $-` → `569Xefu`. Tests: `test_dollar_dash_baseline_no_user_flags`, `test_dollar_dash_includes_e_when_errexit_on`, `test_dollar_dash_includes_x_when_xtrace_on`.

## Closed (sixty-seventh-pass)

### `$0` in `-c` mode leaked the full argv0 path

- `echo $0` returned `./target/debug/zshrs` instead of zsh's bare `zsh`. `get_variable` defaulted to `env::args().next()` for `$0`. Fixed in `bins/zshrs.rs`'s `-c` handler — set `executor.variables["0"]` to `basename(argv[0])` BEFORE running the script. zshrs invoked as `zshrs` returns `zshrs`; invoked as `zsh` (symlink) returns `zsh`. Test: `test_dollar_zero_in_minus_c_returns_basename`.

### `print -P "%T"` zero-padded the hour

- zsh's prompt `%T` time-of-day produces `4:10` (no leading zero on hour); zshrs printed `04:10`. chrono's `%H` always zero-pads; switched to `%k` (space-pad) and `trim_start()` to strip the leading space without touching the digit when hour ≥ 10. Same fix for `%*` (HH:MM:SS). Test: `test_print_dash_p_capital_T_no_zero_pad_hour`.

### Escaped braces `\{foo,bar\}` mangled into garbage

- `echo \{foo,bar\}` should print `{foo,bar}` literally (no expansion). zshrs printed `oo ar\` — the lexer's BNULL-encoded `\{` / `\}` got `untokenize`d back into literal `\{` / `\}` BEFORE `expand_braces` ran, then the brace finder treated `\{` as `{` (with leading literal `\`) and emitted partial strings. Added a `has_balanced_escaped_braces()` short-circuit at the top of `expand_braces` that detects matched `\{`/`\}` pairs and returns the de-escaped literal as a single token. Tests: `test_escaped_braces_stay_literal_in_word`, `test_escaped_braces_with_prefix_suffix`, `test_unescaped_braces_still_expand`.

### Bare `$#name` returned literal `$#name`

- `$#NAME` (no braces) is zsh shorthand for `${#NAME}` (string length / array element count). zshrs's `compile_word_str` had a fast-path for `$NAME` and `$#` (positional count alone), but no path for the `$#NAME` shape — the dispatcher fell through to a generic literal emit. Added a fast-path in `compile_word_str` that detects `^$#[A-Za-z_][A-Za-z0-9_]*$` and emits `BUILTIN_PARAM_LENGTH` directly. Tests: `test_dollar_hash_name_bare_array_length`, `test_dollar_hash_name_bare_string_length`.

## Closed (sixty-eighth-pass)

### `${a:A}` left literal `./` segments in non-existent paths

- `a=./foo; echo ${a:A}` returned `<cwd>/./foo` instead of zsh's `<cwd>/foo`. `std::fs::canonicalize` requires the path to exist; on failure we fell through to a plain `cwd.join(&result)` that didn't lexically resolve `.` / `..` segments. Fixed by walking `path.components()` and dropping `CurDir` while popping on `ParentDir` — produces a zsh-style canonical path even for files that don't exist yet. Test: `test_modifier_capital_A_canonicalizes_dot`.

### `${a:U}` / `${a:L}` modifiers silently returned empty

- zsh emits `unrecognized modifier `U'` for these (they're bash-only); zshrs returned empty with no diagnostic. Added `'U' | 'L' | 'V' | 'X'` arms in `apply_history_modifiers` that print the zsh-format error to stderr and clear `result` to match zsh's "expansion fails completely" behavior. Also extended `is_history_modifier` to include U/L/V/X so they reach the apply-loop. Test: `test_modifier_unknown_emits_error_and_clears`.

### Bash case modifiers `${var^^}` / `${var,,}` accepted instead of rejected

- These are bash-only; zsh rejects with "bad substitution". zshrs implemented them, diverging from zsh's error contract. Replaced the implementations in `expand_braced_variable` with explicit "bad substitution" error emit + empty return. Gated to NOT trigger on `${(j:,:)…}` (legitimate comma inside flag group) — only fires when `^` / `,` appears after a plain identifier name. Test: `test_bash_caret_caret_rejected`. Updated existing tree-walker tests `param_uppercase` / `param_lowercase` to assert the rejection (they were testing the wrong, bash-style behavior).

### `$(c1)$(c2)` cmd-subst concatenation dropped second result

- `echo $(echo foo)$(echo bar)` printed `foo` instead of `foobar`. `strip_cmd_subst` only checked `starts_with("$(")` and `ends_with(')')` — so it matched the WHOLE `$(echo foo)$(echo bar)` as one cmd-subst, ran `echo foo)$(echo bar` as a malformed script, and dropped the second output. Added paren-balance check that rejects when an unmatched `)` appears mid-string, forcing the segment-split path which properly emits two separate cmd-substs and concatenates. Tests: `test_cmd_subst_concat_two_substitutions`, `test_cmd_subst_concat_three_substitutions`.

### `typeset -x name` cleared the existing variable's value

- `a=hello; typeset -x a; echo $a` printed empty instead of `hello`. The bare-name path in `builtin_typeset` did `self.variables.insert(arg, String::new())` unconditionally, clobbering existing values. Fixed: only insert empty when the variable doesn't already exist (preserve attribute attachment). Also wired `+x` to `env::remove_var` so the export attribute is properly stripped while keeping the shell value. Tests: `test_typeset_dash_x_preserves_value`, `test_typeset_plus_x_preserves_value`.

### `((a/=3))` returned `3.3333333333333335` instead of `3`

- zsh's compound `((..))` arithmetic does integer division when both operands are integer; zshrs's ArithCompiler emits `Op::Div` (float-only). Added a sniff in `compile_arith` — if the inner expression contains `/`, route through `BUILTIN_ARITH_EVAL` (the integer-aware MathEval path used by `$((..))`). Result is then reused for the truthiness/status gate so the assignment doesn't run twice. Tests: `test_arith_compound_div_assign_integer`, `test_arith_div_with_float_stays_float`.

## Closed (sixty-ninth-pass)

### `${arr:offset}` / `${a:Z}` silently returned empty

- Any single letter after `:` that wasn't a recognized modifier (history-style `A`/`a`/`h`/`t`/`r`/`e`/`l`/`u`/`q`/`Q`/`P`/`s`/`g`, bash-style `U`/`L`/`V`/`X`, or special-form leaders `-`/`=`/`?`/`+`/`#`/`/`/digit) fell through every branch and returned empty with no diagnostic. Added a "starts-with-alpha" trap in `expand_braced_variable` that emits zsh's `unrecognized modifier `X'` error format and returns empty. Tests: `test_unknown_modifier_letter_emits_error`, `test_unknown_modifier_capital_Z`.

### `$((a))` returned 0 when `a` held a non-numeric expression string

- zsh: `a="3+2"; $((a))` evaluates `a`'s value AS another arith expression and produces 5. Same for `b=a; $((b))` (indirection). zshrs's `MathEval` only loaded vars whose values parsed as int/float — non-numeric strings were dropped, so the lookup returned 0. Added `string_variables` map to MathEval; `get_variable` recursively constructs a sub-evaluator on lookup with a self-reference guard. Tests: `test_arith_recursive_string_var_eval`, `test_arith_indirect_var_chain`, `test_arith_recursive_compound_expression`.

### `printf "%e" 1000` produced `1.000000e3` instead of zsh's `1.000000e+03`

- Rust's `{:e}` formatting emits `e<exp>` with no sign and 1-digit exp; C printf / zsh emit `e±DD` (signed, ≥2 digits). Added a post-format pass in both `printf_format_count` and `builtin_printf` that splits on `e`/`E`, re-emits the exponent with explicit sign and 2-digit zero-pad. Tests: `test_printf_e_format_signed_two_digit_exponent`, `test_printf_e_format_negative_exponent_padded`, `test_printf_capital_E_uses_uppercase_marker`.

### `printf "%v" foo` and `printf "%a" 1` accepted instead of rejected

- `%v` is bash-only (assigns to var); `%a` is C99 hex-float format (zsh doesn't support it). zsh emits `printf:1: %X: invalid directive` for both. zshrs's `printf` literal-passed `%v` and produced hex-float for `%a`. Replaced the literal fallback in `builtin_printf` with the same error format; explicitly rejected `%a`/`%A`/`%v`/`%V`. Tests: `test_printf_invalid_directive_v`, `test_printf_invalid_directive_a`.

### `declare -F` (no args) dumped all environment variables

- zsh's `-F` flag means "float-typed only"; `declare -F` with no float vars declared prints nothing. zshrs treated `-F` as a no-op flag and routed to the typeset list-mode that dumped every variable. Added a float-only filter in the list path that uses `var_attrs[name].kind == VarKind::Float` to gate emission. Other type flags (-i/-a/-A) need shell-internal-param awareness and are left untouched. Tests: `test_declare_capital_F_no_args_lists_only_floats`, `test_declare_capital_F_lists_declared_floats`.

## Closed (seventieth-pass)

### `typeset -U arr` didn't dedupe array elements

- zsh's `-U` (unique) attribute keeps only the first occurrence of each element on assignment / append. zshrs ignored the flag entirely. Added `is_unique` to typeset arg parsing, threaded through `VarAttr.unique`, and applied dedupe in three places: at attribute attachment time (existing array gets retained-with-seen), inside `BUILTIN_SET_ARRAY` (whole-array assignment dedupes via HashSet), and inside `BUILTIN_APPEND_ARRAY` (`arr+=…` skips elements already present). Tests: `test_typeset_dash_U_dedupes_array`, `test_typeset_dash_U_after_assignment_dedupes`, `test_typeset_dash_U_append_dedupes`.

### `((++5))` / `((--5))` silently incremented literals

- Pre/post inc/dec on a non-lvalue is a zsh error (`bad math expression: lvalue required`). zshrs's MathEval skipped the `set_variable` call but still pushed the new value, so `echo $((++5))` printed `6` instead of failing. Added `mv.lval.is_none()` guards on all four ops (PrePlus, PreMinus, PostPlus, PostMinus) that emit `bad math expression: lvalue required` and abort. Tests: `test_arith_pre_increment_on_literal_errors`, `test_arith_post_increment_on_literal_errors`, `test_arith_pre_decrement_on_var_works`.

### `typeset -E` used wrong precision and exponent format

- `typeset -EN` means N **significant** digits (1 before decimal + N-1 after); zshrs passed N straight to Rust's `{:.Pe}` which means N **fractional** digits. Result: `typeset -E5 a=1234.5` printed `1.23450e+03` instead of zsh's `1.2345e+03`. Subtract 1 from the precision before passing to Rust's formatter. Also wired the same e+0DD post-format pass used by printf to fix the unsigned 1-digit exponent (was `e3`, now `e+03`). Tests: `test_typeset_E_uses_sig_digit_precision`, `test_typeset_E_default_precision_nine_fractional`.

### `$#a[@]` / `$#a[*]` returned literal text

- Bare `$#NAME[@]` is zsh shorthand for `${#NAME[@]}` (array length). My iteration-67 fast-path only matched `^\$#NAME$` (no brackets). Extended the matcher in `compile_word_str` to also accept a trailing `[@]` / `[*]` suffix and route the same way through `BUILTIN_PARAM_LENGTH`. Tests: `test_dollar_hash_name_bracket_at`, `test_dollar_hash_name_bracket_star`.

## Closed (seventy-first-pass)

### Float div-by-zero raised "division by zero" instead of producing Inf

- zsh follows IEEE 754 — `1/0.0` produces `Inf`, `-1/0.0` produces `-Inf`, `0.0/0.0` produces `NaN`. Only INTEGER div-by-zero raises the runtime error. zshrs treated both the same. Gated the error in `MathTok::Div` / `Mod` arms on `!is_float` so float operands fall through to the f64 division (which produces the IEEE specials naturally). Updated `format_zsh_subst` to print `Inf` / `-Inf` / `NaN` (capitalized, no decimal) instead of Rust's `inf` / `NaN.0`. Tests: `test_arith_float_div_by_zero_returns_inf`, `test_arith_neg_float_div_by_zero_returns_neg_inf`, `test_arith_zero_div_zero_returns_nan`, `test_arith_int_div_by_zero_still_errors`.

### `declare -p NONEXIST` was silent (and used wrong builtin name)

- zsh emits `declare:1: no such variable: NAME` (or `typeset:1:` if invoked as `typeset`) and exits non-zero. zshrs returned silent success, masking missing-variable bugs. Added the error emit in the `print_mode` path, threaded the invoked name through a new `builtin_typeset_named` helper. fusevm maps both names to the same builtin id so I also exposed `BUILTIN_DECLARE` (id 21) and route `declare` to it from `compile_simple` to preserve the name distinction at error time. Tests: `test_declare_p_missing_variable_emits_named_error`, `test_typeset_p_missing_variable_emits_named_error`.

### Huge floats (`1e100`) truncated to `9223372036854775807` (i64::MAX)

- `format_zsh_subst` cast every "fract==0 && finite" float to `i64`. Rust's `as i64` saturates on overflow to `i64::MAX`, so `1e100` displayed as the saturation value — completely wrong. Gated the int-cast on the float fitting in `[i64::MIN, i64::MAX]`. Out-of-range floats now route through a scientific-notation branch that emits zsh's `<mantissa>e±DD` shape. Tests: `test_arith_huge_float_doesnt_truncate_to_i64_max`, `test_arith_scientific_format_signed_two_digit_exp`.

### `${arr[N]:-default}` ignored the modifier on out-of-bounds index

- For an OOB index like `arr=(a b); echo ${arr[5]:-default}`, zshrs's bracket-handler returned the empty array element and exited the function — never reaching the `:-default` form's default fallback. Added a `lookup_array_element` helper and an `after_bracket` modifier scanner that handles `:-`, `:+`, `:?`, `:=` after `]`. Tests: `test_array_oob_index_default_modifier`, `test_array_empty_index_default_modifier`, `test_array_oob_index_assign_modifier`, `test_array_in_bounds_no_default_kicks_in`.

## Closed (seventy-second-pass)

### `${var-default}` family (no-colon forms) wasn't implemented

- zsh distinguishes `${var-default}` (default only when var is UNSET) from `${var:-default}` (default when unset OR empty). zshrs only implemented the colon variants — the no-colon forms fell through every branch and returned empty. Added a no-colon block in `expand_braced_variable` that walks chars looking for `-`/`=`/`?`/`+` after a valid identifier, then applies the proper unset-only semantics. Same form is supported inside `${(flags)var-default}`. Tests: `test_param_no_colon_default_when_unset`, `test_param_no_colon_assign_when_unset`, `test_param_no_colon_default_nested`, `test_param_no_colon_default_outer_set_skips`, `test_param_flag_with_no_colon_default`.

### `${HOME//\//_}` escaped slash in pattern

- The pattern/replacement split used `splitn(2, '/')` which split on the first `/` — including escaped `\/`. So `${HOME//\//_}` got pattern=`\` and replacement=`/_`, completely wrong. Both `expand_braced_variable` and `compile_zsh.rs::parse_param_modifier` now find the FIRST UNESCAPED `/` and de-escape `\/` → `/` in pattern/replacement. Test: `test_param_replace_with_escaped_slash`.

### `${a@OP}` bash modifier silently returned empty

- `${var@U}`, `${var@L}`, `${var@Q}`, etc. are bash-only. zsh emits "bad substitution". zshrs returned empty silently. Added a check in `expand_braced_variable` for `@` after a plain identifier that emits the zsh-format error. Test: `test_param_at_modifier_rejected`.

### `{10..1..-2}` negative step in brace sequence

- zsh's negative step REVERSES the natural-direction sequence: `{10..1..-2}` → `2 4 6 8 10` (reverse of `{10..1..2}`). zshrs did `i -= step` with negative step, infinite-looping or producing wrong results. Use `step.abs()` for generation, then `results.reverse()` if step was negative. Tests: `test_brace_negative_step_reverses`, `test_brace_negative_step_ascending`.

### `$#1` bare positional length form

- `$#NAME` fast-path only matched identifier names. `$#1` (length of $1) and other digit-only positionals fell through. Extended the matcher to accept `^\$#[0-9]+$` and route through `BUILTIN_PARAM_LENGTH`. Test: `test_dollar_hash_positional`.

### `-0.0` printed as `0.` (lost sign)

- IEEE -0.0 carries a sign bit. `format_zsh_subst`'s int-cast path used `*f as i64` which discards the sign. Detect the negative-zero case explicitly and emit `-0.`. Test: `test_arith_negative_zero_keeps_sign`.

### `declare -p NAME` for exported scalars used `typeset` not `export`

- zsh prints `export NAME=value` for plain exported vars and `export -i n=5` for typed exports. zshrs always emitted `typeset` / `typeset -ix`. Added export-detection (env::var lookup OR `var_attrs.export`) and folded the `x` letter into the `export` keyword. Tests: `test_declare_p_exported_uses_export_prefix`, `test_declare_p_int_export_uses_export_dash_i`.

### `${a[2,3]:-default}` returned full string instead of substring

- The OOB-modifier path I added in iter 71 fired for ALL bracket-with-modifier forms, including string range subscripts. For `a=foo; ${a[2,3]:-default}`, the lookup returned empty (range isn't a single index), so the `:-default` fired. Skip the OOB block when index has comma / `@` / `*`. Also fixed the underlying string-range-subscript bug — the existing code did `(idx-1) as usize` even though `v.start` from getindex is already 0-indexed. Test: `test_string_range_subscript_with_default`.

### `((a |= 0xff))` and other compound bitwise/shift assigns

- ArithCompiler only recognized `+=`/`-=`/`*=`/`/=`/`%=`. `|=`, `&=`, `^=`, `<<=`, `>>=` parsed but never wrote back. Extended `compile_arith`'s "needs_eval" sniff to also route any expression containing those tokens through `BUILTIN_ARITH_EVAL` (MathEval has full operator support and writes through `extract_string_variables`). Tests: `test_arith_compound_or_assign`, `test_arith_compound_shift_left_assign`, `test_arith_compound_shift_right_assign`, `test_arith_compound_xor_assign`.

### `${a[10]}` for short string returned last char (saturation bug)

- `slice_scalar`'s `i.min(len)` saturated OOB indices to the last char. `${a[10]}` for "hello" returned "o". zsh returns empty. Added explicit OOB checks (`start > len` or `start < -len`) that return empty before the saturating resolve. Tests: `test_string_oob_index_returns_empty`, `test_string_negative_oob_index_returns_empty`.

### `set -o allexport` ignored

- zsh's `allexport` option auto-exports every assignment to the env. zshrs registered the option but never consulted it during scalar assignment. Added the option check in `BUILTIN_SET_VAR` — also auto-exports when the var was previously declared exported (was missing for plain `a=newvalue` after `export a`). Test: `test_allexport_option_auto_exports`.

### `Inf` / `NaN` capitalization in stored vars

- After `((a/=0))` on a float, MathEval stored the result via `format_zsh` which used Rust's Display. Result: `inf` / `NaN.0` instead of zsh's `Inf` / `-Inf` / `NaN`. Special-case IEEE specials in `format_zsh` to match zsh's capitalization. (No test added — covered indirectly by the `${a/=0}` variants in the iter 71 tests.)

### `$(($a*2))` (no spaces around `*`) returned 0

- `expand_string`'s var-name reader accepted `*` as a valid char ANYWHERE in an identifier, not just as a single-char special. So `$a*2` consumed `a*2` as one var name, looked up nonexistent `a*2`, returned empty. Then arith on `*2` gave 0. Gated `*`/`@`/`#`/`?` to only match as the FIRST char of var_name. Test: `test_arith_dollar_var_with_star`.

## Closed (seventy-third-pass)

### `local NAME` (no value) didn't reset to empty in function scope

- `a=hi; foo() { local a; echo "[$a]"; }; foo` should print `[]` (zsh: local shadows with empty value, parent value restored on exit). zshrs preserved the parent value because my iter-66 "preserve existing" guard fired here too. Gated on `local_scope_depth > 0 && !is_global` — INSIDE a function, bare `local NAME`/`typeset NAME` always resets to empty. The local_save_stack already preserves the parent value for restore-on-exit. Tests: `test_local_no_value_resets_to_empty`, `test_typeset_no_value_resets_in_function_scope`, `test_typeset_g_keeps_parent_value`.

### `${!var}` bash indirect accepted instead of rejected

- `${!var}` is a bash extension that zsh emits "bad substitution" for; zsh's native indirect is `${(P)var}`. zshrs implemented bash semantics. Replaced the `${!name}` path (single-name form) with the zsh-format error. The `${!prefix*}` / `${!prefix@}` listing forms remain — zsh's behavior there is fuzzier and the test suite uses them. Test: `test_bash_indirect_expansion_rejected`.

### Exit status not masked to 8 bits

- POSIX/zsh: exit codes are taken mod 256. `(exit 256)` should yield `$? == 0`, `(exit 257)` → 1. zshrs returned the raw value. Added `(raw_code as u32) & 0xff` mask in `builtin_exit`. Tests: `test_exit_status_masked_to_byte`, `test_exit_status_257_wraps_to_one`.

### `$((1#X))` and `$((37#5))` panicked instead of erroring

- `i64::from_str_radix(s, base)` panics when base is outside [2, 36]. zshrs passed the user-supplied base directly without validation, panicking on `$((1#1))` and `$((37#5))`. Added `(2..=36).contains(&base)` check that emits zsh's "invalid base (must be 2 to 36 inclusive)" error and returns 0. Two sites in `math.rs`: the `N#value` form and the `[#base]` arith-format form. Tests: `test_arith_invalid_base_no_panic`, `test_arith_base_too_large_no_panic`.

### `${(P):-test}` returned empty instead of "test"

- The `(P)` flag dereferences the value as a parameter name. With `${(P):-test}`, var_name is empty so the default fires and returns "test" — but then `(P)` was applied to "test" which isn't a set var, returning empty. Track whether the default fired and skip the `Parameter` flag in that case (the default value IS the literal result, not a name to look up). Test: `test_param_paren_p_with_empty_name_default`.

### `${#NAME:-default}` returned 0 (length of unset name) instead of 7

- The `${#...}` length form didn't recognize the trailing `:-default` / `-default` modifiers — it just looked up the name and returned its length (0 when unset). Added a name-then-`:-`/`-` parser inside the length path that expands the default when needed and returns its char count. Tests: `test_length_of_default_unset`, `test_length_of_default_no_colon_unset`, `test_length_no_default_when_set`.

### `${a::N}` empty-offset substring returned empty

- `${a::N}` is shorthand for `${a:0:N}` (offset 0, length N). The substring branch's gate required the rest to start with a digit or `-`; `:` was rejected. Extended the gate to also accept a leading `:` (empty offset → 0). Test: `test_substring_empty_offset`.

### `${a::-1}` negative length didn't truncate from end

- Negative length means "skip last N chars". For `a=foo`, `${a::-1}` should be `fo`. zshrs cast negative length via `as usize` (saturating to 0 or huge), returning empty or full. Added explicit negative-length branch: end = total + len, take = end - start. Tests: `test_substring_negative_length`, `test_substring_neg_length_with_offset`.

### `(@O)` / `(@o)` array-context flag in DQ partially supported

- Added `ZshParamFlag::At` enum variant and `@` handling in `parse_zsh_flags`. The DQ-context flag-strip block now keeps array-only flags (Sort/Reverse/Unique/etc.) when `@` is explicitly present. Full DQ-with-`@` element-by-element splicing still needs more work; this iteration handles parsing + flag retention. (Full-suite verification: 512 tests green.)

## Closed (seventy-fourth-pass)

### `${a:?}` error used bash phrasing "parameter null or not set"

- zsh emits "parameter not set" for both `${a:?}` and `${a?}`. zshrs used the bash form. Replaced both occurrences in `apply_var_modifier` and `expand_braced_variable`. Test: `test_param_qmark_no_msg_uses_zsh_format`.

### Paren patterns `(?)`, `(*)`, `(foo|bar)` not recognized in replace

- The has-glob trigger in `BUILTIN_PARAM_REPLACE` only fired on `?`/`*`/`[`/`]`. `(...)` patterns fell into the literal-string path and matched nothing. Added `(` to the trigger set and changed glob-to-regex to keep `(`/`)`/`|` as regex group/alternation operators (instead of escaping them as literals). Tests: `test_pattern_paren_question_mark_matches_one_char`, `test_pattern_paren_star_matches_anything`, `test_pattern_alternation_in_replace`.

### `pushd` / `popd` printed dir stack in non-interactive mode

- zsh's `-c` mode silently performs the cd; only explicit `dirs` prints. zshrs printed the stack on every push/pop. Gated the print on `stdin().is_terminal()` so non-interactive sessions stay silent. Test: `test_pushd_popd_silent_in_noninteractive`.

### `dirs -v` used space-padding instead of TAB

- zsh's `dirs -v` separates index from path with a real `\t` character (tab-aligned). zshrs used `{:2}  ` space-pad. Switched to `{}\t{}`. Test: `test_dirs_v_uses_tab_separator`.

### `print_dir_stack` showed absolute paths instead of `~/...`

- zsh's dir-stack listing replaces `$HOME` prefix with `~`. zshrs printed full `/Users/wizard/...` paths. Added a tilde-compress helper. (No standalone test — covered by the `dirs` interactive-mode probe.)

### `((a *= 1.5))` int → float promotion

- ArithCompiler couldn't parse float literals; mixed-mode `int *= float` produced a wrong int result. Extended `compile_arith`'s "needs_eval" sniff to route any expression containing `.`, `e`, or `E` through `BUILTIN_ARITH_EVAL` (MathEval handles floats correctly). Test: `test_arith_int_times_float_promotes`.

### Bare `${assoc}` returned empty instead of joined values

- For `declare -A h; h[k]=v; ${h}` — zsh returns joined values (`v`); zshrs returned empty. Two issues: (1) `declare -A` creates `variables[name]=""` as a side effect that satisfied the variables.get() call before the assoc check; (2) the assoc->scalar path returned `String::new()` even when entries existed. Fixed: skip the variables.get() lookup when an assoc with the same name has entries, AND return joined values from assoc on the fallback path. Test: `test_assoc_bare_returns_joined_values`.

### `[!fo]` class negation not recognized in replace patterns

- zsh accepts both `[!class]` and `[^class]` for negation; regex only accepts `^`. zshrs's glob-to-regex passed `!` through as a literal, so `[!fo]` matched the literal char `!`. Translate a leading `!` after `[` to `^`. Tests: `test_pattern_class_negation_with_bang`, `test_pattern_class_negation_caret_still_works`, `test_pattern_class_with_negation_matches_others`.

## Closed (seventy-fifth-pass)

### `\$` and `\` escape lost / corrupted in unquoted echo

- `echo \$` printed literal `\$` instead of `$`. `echo \$a` printed bare `\` (with `$a` mangled). For `a=foo; echo \$a`, the `\` was passed through as literal then echo's escape interpreter saw `\f` later in the stream and emitted form-feed. Pre-process `\$`, `\``, `\"`, `\'`, `\\` into `\x00X` literal markers in BUILTIN_EXPAND_TEXT's Default mode (same as the DQ mode already does). Tests: `test_escape_dollar_sign_literal`, `test_escape_dollar_var_literal`, `test_escape_backtick_literal`.

### `$((1+2))$((3+4))` arith-subst concat dropped tail

- Same shape as the iter-67 cmd-subst-concat bug. `strip_arith_subst` checked only that `inner` had a balanced count of `(` / `)` — `1+2))$((3+4` has `(((` and `)))` so the count is +1 -3 +2 = 0 → matched, ran the whole malformed expr, errored "illegal character: )" and fell through to 0. Walk inner counting depth and reject if depth ever drops below zero (`))` mid-string means we closed the outer `$((` early). Tests: `test_arith_subst_concat`, `test_arith_subst_concat_three`.

### `declare -p` for assoc-with-export wrongly used `export -A`

- `declare -Ax h` should print `typeset -Ax h=( )` (zsh reserves the `export` keyword for scalars/integers). Added a `has_non_scalar_attr` check (A/a/F/E) that keeps the typeset form. Test: `test_declare_p_assoc_export_uses_typeset`.

### `declare -p` for float-exp printed `-F` instead of `-E`

- `typeset -E a=3.14` (scientific format) was tracked as `VarKind::Float` but the `-E` vs `-F` distinction was lost. Added `float_exp: bool` to `VarAttr` and emit `-E` when set. Test: `test_declare_p_float_E_flag`.

### `${arr[N]+set}` (no-colon `+`) returned the value, not `set`

- The OOB-modifier path I added in iter 71 only handled `:-`/`:+`/`:?`/`:=` (colon variants — test for empty). The no-colon variants test SET-NESS (key present / index in bounds), not emptiness. Added `array_element_is_set` helper and `-`/`+`/`?` handlers in the bracket-modifier path. Tests: `test_array_element_no_colon_set`, `test_array_element_no_colon_set_oob`, `test_assoc_element_no_colon_set`, `test_assoc_element_no_colon_unset`.

### `${(t)arr}` missing `-unique` marker

- `typeset -aU arr` should report `array-unique`; zshrs emitted just `array`. Added `-unique` to `VarAttr::format_zsh`. Test: `test_t_flag_array_unique`.

### `[[ a -nt b ]]` returned true when one file missing

- bash's "missing == infinitely-old" semantics; zsh strictly requires BOTH files to exist. Removed the `(Some, None) => true` fallback and `(None, Some) => true` for `-ot`. Tests: `test_test_nt_both_must_exist`, `test_test_ot_missing_is_false`.

### `[[ $a == $b ]]` returned false because RHS wasn't variable-expanded

- The `==` / `=` / `!=` path treated the RHS as a literal pattern (untokenize then LoadConst), bypassing variable expansion. So `$b` was matched as the literal string `"$b"`, never the value. Fix: detect `$` / backtick in the RHS and route through `compile_word_str` (with DQ wrapping to suppress filesystem-glob); literal RHS still uses the fast path. Tests: `test_double_bracket_var_eq_var`, `test_double_bracket_var_eq_var_unequal`, `test_double_bracket_glob_pattern_still_works`.

### Empty assoc `=( )` had double space

- `declare -A h; declare -p h` printed `typeset -A h=(  )` (2 spaces); zsh: `typeset -A h=( )` (1 space). Special-case empty assoc formatted line. (Empty arrays keep zsh's 2-space form.)

### Comma operator in `((..))` dropped subsequent expressions

- `((a += 5, a *= 2))` ran only the first — ArithCompiler's compound-assign emit takes one op and discards the rest. Extended `compile_arith`'s "needs_eval" sniff to route any expression containing `,` through `BUILTIN_ARITH_EVAL`. Tests: `test_arith_comma_compound_assigns`, `test_arith_comma_two_vars`.

### `test`/`[ ]` `-a`/`-o` connectives unsupported

- POSIX `test 5 -gt 3 -a 3 -lt 4` should AND the two sub-tests. zshrs's match-on-args pattern bottomed out at the catchall returning 1. Added explicit `-o`/`-a` splitter at the catchall (OR has lower precedence) that recursively evaluates each side. Tests: `test_test_dash_a_and`, `test_test_dash_o_or`, `test_test_dash_a_short_circuit_fails`, `test_test_dash_o_both_fail`.

### `float NAME=…` defaulted to `-F` (fixed) instead of `-E` (scientific)

- zsh's `float` builtin uses `-E` by default; explicit `-F` opts into fixed-decimal. zshrs always stored `-F` form. Added `-F`/`-E` flag detection in `builtin_float`, store value with the appropriate format, and set `var_attrs.float_exp` so `declare -p` round-trips. Tests: `test_float_default_E_format`, `test_float_F_explicit_fixed`.

### `fpath` array empty even though FPATH env was inherited

- `$#fpath` returned 0 in zshrs because the executor's `fpath` (Vec<PathBuf>) field was populated from FPATH but the user-visible `arrays["fpath"]` was not. So `fpath+=(/foo)` replaced with a 1-entry array instead of appending to the inherited 43 entries. Mirror `self.fpath` into `arrays["fpath"]` at executor init. Tests: `test_fpath_inherited_from_env`, `test_fpath_append_keeps_existing`.

### `"$a"bar` (quoted-var followed by literal) returned empty

- The bare-var fast-path in `compile_word_str` matched after `untokenize` stripped DNULL markers, so `"$a"bar` (raw `\u{9e}$a\u{9e}bar`) became `$abar` and the fast-path looked up nonexistent `abar`. Skip the fast-path when the raw word contains DNULL/SNULL quote markers — the bridge below handles the segment-split correctly. Tests: `test_dq_var_concat_with_literal_suffix`, `test_dq_var_concat_with_literal_prefix`, `test_dq_var_with_underscore_suffix`, `test_dq_var_double_concat`.

## Closed (seventy-fifth-pass continued — second batch)

### `$(<<<"hi" cat)` herestring misread as `$(<file)` shorthand

- `run_command_substitution` shortcuts `$(<filename)` to `read-file`; the `<<<"hi"` herestring matched the leading `<` and was passed to the read-file path which errored "no such file or directory: <<\"hi\" cat". Tighten the prefix-strip with `.filter(|s| !s.starts_with('<'))` so only a SINGLE leading `<` triggers the shorthand. Test: `test_herestring_inside_command_substitution`.

### `function { body }` (no parens) anonymous function unsupported

- zsh accepts both `function () { body } args` and `function { body } args` for anonymous functions. zshrs's `parse_funcdef` required the `()` to identify the anonymous form, so the no-parens shorthand silently parsed the trailing args as the next command. Drop the `saw_paren` guard — empty-name path in parse_funcdef now synthesizes the auto-call regardless. Tests: `test_anonymous_function_no_parens`, `test_anonymous_function_no_parens_multi_arg`.

### `$ZSH_SUBSHELL` didn't increment in `(...)` subshells

- zshrs's snapshot/restore-based `(...)` doesn't fork, so `entersubsh()` (which bumps the counter) was never called. Increment the counter inside `subshell_begin` before snapshotting; the snapshot captures the parent-side value so `subshell_end` restores it. Test: `test_zsh_subshell_increments`.

### `printf -- fmt args` printed `--` as the format

- POSIX `--` end-of-options marker wasn't recognized; `printf -- "%s\n" hi` produced `--\nhi\n`. Strip a leading `--` before the `-v VAR` parse / format-string lookup. Test: `test_printf_double_dash_end_of_options`.

### `echo *` glob sort was always byte-order (case-sensitive)

- Under a Unicode locale, zsh sorts case-foldedly (`Aaa bbb Ccc Ddd`), not ASCII (`Aaa Ccc Ddd bbb`). zshrs's `expand_glob` finalized with `expanded.sort()` — pure byte compare. Added `glob::locale_aware_name_cmp` that folds case under non-C locales (via LC_ALL/LC_COLLATE/LANG sniff) and falls back to byte order under C/POSIX. Wired into `expand_glob`'s final sort and `MatchEntry::compare`. Test: `test_glob_sort_locale_aware`.

### `typeset -i N name=value` ignored the base argument

- `typeset -i 16 a=255` should display `$a` as `16#FF`; zshrs stored decimal `255`. Added `int_base: Option<u32>` to `VarAttr`, parsed both `-iN` (attached digits) and `-i N` (separate arg) forms, and emit `BASE#DIGITS` (with `A`-`Z` for digits ≥10) at assignment time via new `format_int_in_base` helper. Test: `test_typeset_integer_base_output`.

### `cd` with no args used OS env HOME, not shell-state HOME

- `HOME=/tmp; cd; pwd` printed `/Users/wizard` because `do_cd` called `dirs::home_dir()` which reads the OS env (unaffected by non-exported shell-local assignments). Changed to a closure that prefers `self.variables["HOME"]` then env. Test: `test_cd_uses_shell_home`.

### `cd ~` used OS env HOME, not shell-state HOME

- Same root cause for the tilde-prefix path. `expand_tilde_named` for bare `~` only checked `std::env::var("HOME")`. Read shell-state `variables["HOME"]` first. Test: `test_cd_tilde_uses_shell_home`.

### CDPATH searched only with explicit `-s`

- zsh implicitly searches CDPATH when the literal path isn't a directory in cwd; zshrs gated this behind the `-s` (use_cdpath) flag. Reworked the path-resolution branch to: if path doesn't start with `/`/`.` AND isn't a cwd directory, search `$CDPATH` (shell-state preferred over env) then the `cdpath` array. Test: `test_cdpath_implicit_search`.

### `~` literal in double quotes was being expanded

- `echo "~"` printed `/Users/wizard`; zsh keeps `~` literal inside `"..."`. The shared `expand_string` tilde-handler had no DQ-context guard. Added `&& self.in_dq_context == 0` to the tilde branch — the existing `in_dq_context` counter (incremented around `expand_string` for DQ contents) gates expansion. Tests: `test_tilde_literal_in_double_quotes`.

### `${arr%pat}` / `${arr#pat}` only stripped one element of the array

- `${a%.txt}` for `a=(a.txt b.bin c.txt)` should yield `a b.bin c`; zshrs joined to scalar first then stripped the joined string, returning `a.txt b.bin c` (only the trailing `.txt` got stripped). Same root cause for `#`/`##`/`%%`. Reworked `BUILTIN_PARAM_STRIP` to detect the `arrays[name]` / `@` / `*` cases and iterate per-element. Tests: `test_array_suffix_strip_per_element`, `test_array_prefix_strip_per_element`, `test_array_long_suffix_strip_per_element`, `test_array_long_prefix_strip_per_element`.

## Closed (seventy-sixth-pass)

### `kill -0 PID` (process-existence check) errored

- `kill -0` is the POSIX/zsh-compatible way to ask "is PID alive?" — no signal sent, kill(pid, 0) returns 0 / ESRCH. zshrs errored "invalid signal: -0" because Signal::SIG0 isn't a libc Signal enum variant. Added a `signal_zero` flag in `builtin_kill` that routes the `-0` parse through a direct `libc::kill(pid, 0)` call. Test: `test_kill_zero_process_check`.

### `print --hi` printed `--hi` instead of erroring

- zsh's `print` errors on unknown flags ("bad option: -h"); zshrs's print parser was lenient — when ANY char in the flag wasn't recognised, the whole token was pushed as a positional. Made it strict: error on the first unrecognised char. Added `-` to the known-flag set so `print -- -hi` and the `--` end-of-options idiom continue to work. Tests: `test_print_strict_unknown_flag_errors`, `test_print_double_dash_terminator`.

### `<<\EOF` heredoc terminator parse-errored

- zsh treats `<<\EOF` (backslash-prefix on the terminator) as shorthand for `<<'EOF'` — disables variable / cmd-sub / arith expansion in the body. zshrs's lexer detected SNULL / DNULL quoting markers but not the BNULL marker (`\u{9f}`) emitted for backslash-escaped chars, so the terminator string still contained the BNULL byte and the body never matched the closing line — hit "here document too large or unterminated". Added BNULL to both the quoted-detection check and the terminator strip-set. Test: `test_heredoc_backslash_terminator_disables_expansion`.

### `trap CMD 0` didn't run the EXIT trap

- POSIX numeric-signal-alias: `trap CMD 0` is equivalent to `trap CMD EXIT`. zshrs stored the trap under signal name `0`, which the EXIT-trap-runner (which keys on `"EXIT"`) never queried. Added a numeric→name normalisation in `builtin_trap`: `0` → `EXIT`, libc-derived numbers → canonical names (so `kill -l USR1`'s output round-trips). Test: `test_trap_signal_zero_is_exit`.

### `echo X$?` (special-var after literal prefix) printed literal `X$?`

- Same root cause for `X$#`, `X$$`, `X$*`, `X$!`, `X$-`. The lexer META-marks these chars (`?` → `\u{97}`, `*` → `\u{87}`, `#` → `\u{84}`, `!` → `\u{9c}`, `-` → `\u{9b}`, second `$` → `\u{85}`) so when `compile_zsh.rs::find_expansion_end` looked for the end of the `$?`-style expansion, the matcher (which only tested LITERAL chars) fell through to the default "advance by 1", leaving the META-encoded special-param char in the trailing literal segment. Extended the matcher to recognise both the literal char AND its META code-point. Test: `test_special_param_concat_after_literal`.

### `printf "%x" -1` printed `0`

- `arg.parse::<u64>()` rejected the leading `-` and unwrapped to 0. POSIX printf wraps negatives as unsigned (-1 → 0xFFFFFFFFFFFFFFFF). Parse as i64 first then `as u64` to get C-style two's-complement wrap. Same fix applied to `%X` and `%o`. Test: `test_printf_x_negative_wraps_unsigned`.

### `printf "\NNN"` (no leading 0) printed literal `\NNN`

- POSIX printf accepts both `\NNN` (1-3 octal digits, the standard form) and `\0NNN` (legacy bash-style with leading 0). zshrs's escape branch only matched `\0…`, so `\102` (= 'B') stayed literal. Extended the match to any digit `0`-`7` as the first octal char. The leading-zero form still works: `\0102` is up to 3 chars total including the `0`, so it consumes `010` (= backspace) and leaves `2` as literal — matching zsh's output. Tests: `test_printf_octal_escape_no_leading_zero`, `test_printf_octal_leading_zero_three_total_digits`.

### `() { echo $0 } anon` printed `_zshrs_anon_0`

- zsh: anonymous functions display `(anon)` for `$0`. zshrs synthesizes internal names `_zshrs_anon_N` / `_zshrs_anon_kw_N` for the two anon syntaxes; the internal name was leaking into `$0`. In `call_function`, when `name.starts_with("_zshrs_anon_")` substitute the cosmetic `(anon)` for the value of `$0`. Test: `test_anon_function_dollar_zero_is_anon_string`.

### `set -E` and `set -T` errored "invalid option"

- zsh's `set` accepts `-E` (ERR_RETURN: return on non-zero status inside a function) and `-T` (TRAPS_ASYNC: run traps after each command). zshrs's `builtin_set` matcher had no `-E`/`-T` cases; both produced "invalid option". Added accept-silently mappings to `err_return` and `trapasync` options so user scripts that set the flag don't bail. Test: `test_set_capital_E_accepted`.

### `[[ -o no_such_option ]]` was silent (no diagnostic)

- zsh: emits `no such option: NAME` to stderr (test still returns false). zshrs's `BUILTIN_OPTION_SET` just returned false silently. Added a known-option lookup against `ZSH_OPTIONS_SET` (the same canonical-set used by `setopt`/`unsetopt`) plus the `no`-prefix-strip so `[[ -o nounset ]]` and `[[ -o nonounset ]]` invert correctly. Unknown names now log "no such option" to stderr matching zsh. Test: `test_double_bracket_o_unknown_option_warns`.

### Heredoc body `[42]` triggered NOMATCH glob expansion

- `cat <<EOF\n[42]\nEOF` should produce `[42]` on cat's stdin, but zshrs routed the body through the full word-expansion pipeline (expand_string + brace + glob), and `[42]` looks like a one-char glob pattern that never matches. Added mode 4 ("HeredocBody") to `BUILTIN_EXPAND_TEXT` which runs only `expand_string` (variable / cmd-subst / arith) and skips the brace + glob steps. The compile-side already passed mode 0 (Default); switched to mode 4 for the unquoted-terminator branch. Test: `test_heredoc_body_no_glob_expansion`.

### `$_` always returned the shell binary path

- zsh / bash convention: `$_` holds the last argument of the previously-executed command (`echo hi; echo $_` → `hi\nhi`). zshrs's `_` was only ever set to the binary path at startup. Added `pending_underscore: Option<String>` to the executor and promote it on every builtin dispatch (in `pop_args`) AND every external exec (in `host.exec`) — the previous command's last arg becomes `$_` BEFORE the next command's args are read, so `echo $_` reads the prior value. Test: `test_dollar_underscore_tracks_last_command_arg`.

### `${arr[@]:h}` (and :t, :r, :l, :u, etc.) didn't iterate per-element

- zsh applies path-modifier suffixes per-array-element: `a=(/a/b/c /d/e/f); echo "${a[@]:h}"` should produce `/a/b /d/e`. zshrs's subscript-resolution path returned `arr.join(" ")` for `[@]`/`[*]` and never reached the modifier loop, so the `:h` was silently dropped. Added a per-element `apply_history_modifiers` walk inside the `index == "@"`/`"*"` branch that fires when `after_bracket` starts with `:` and looks like a history modifier. Test: `test_array_at_subscript_history_modifier_per_element`.

### `${var:h}` didn't strip trailing slashes before head

- `${a:h}` for `a=/tmp/` should yield `/` (drops trailing-slash + `tmp`). zshrs found the trailing slash with `rfind('/')` and returned `/tmp`. Added a `trim_end_matches('/')` pass before locating the last segment so `/tmp/` and `/tmp` both resolve to `/`. Same fix to `:t` so `foo/` :t is `foo`. Test: `test_h_modifier_strips_trailing_slashes`.

### `${a[1][1]}` (chained subscript) returned the full element

- zsh: `${a[1][1]}` for `a=(hello)` selects array element 1 (`hello`) then character 1 (`h`). zshrs treated the second `[1]` as noise after the first subscript resolved. Added a chained-subscript handler in the array-subscript branch: if `after_bracket` starts with `[`, parse the inner index (numeric or `start,end` range), apply to the looked-up element's chars, and return. Test: `test_chained_subscript_array_then_char`.

### `print -e` and `print -E` were silently accepted

- zsh's `print` rejects `-e` AND `-E` ("bad option") — the escape-interpretation flags belong to `echo`, not `print`. zshrs's print known-flag set included both. Removed `e`/`E` from the print known-flag set so these now fall through to the strict "bad option" error matching zsh. Test: `test_print_rejects_dash_e_and_dash_E`.

### `$((0o15))` Rust/Python octal prefix was silently accepted as 0

- zsh rejects `0o…` octal-prefix; only `0x` (hex), `0b` (binary), and bare-leading-zero (with `setopt octalzeroes`) are recognised. zshrs's math lexer fell through `Some('o')` to the default branch and returned 0. Added an explicit `Some('o') | Some('O')` case that sets `self.error` to zsh's exact diagnostic ("bad math expression: operator expected at `…'") and returns a stub Num. Test: `test_math_rejects_0o_octal_prefix`.

### `trap "" SIG` (signal-ignore) wasn't listed by `trap`

- POSIX distinguishes `trap "" SIG` (ignore signal — store empty action) from `trap - SIG` (reset to default — remove action). zshrs collapsed both to "remove from table", so `trap "" USR1; trap` printed nothing. Now only `-` removes; the empty-string ignore form is stored verbatim and shown by `trap` as `trap -- '' SIG`. `run_trap` skips execution when the action is empty so the ignore semantics actually work. Test: `test_trap_empty_string_listed_as_ignore`.

### `printf "%d" 3.14` returned 0 instead of 3

- POSIX printf truncates floats to int for `%d`/`%i`. zshrs's parser was i64-only — the decimal point made `arg.parse::<i64>()` fail and `unwrap_or(0)` produced 0. Added f64 fallback that truncates via `as i64`. Same path now correctly handles `-5.99` → `-5`. Test: `test_printf_d_truncates_float_to_int`.

### `set -h`, `set -k`, `set -p`, `set -B`, `set -H` errored "invalid option"

- These are zsh-standard short-flag aliases (HASH_CMDS, KSH_TYPESET, PRIVILEGED, BRACE_CCL, HIST_REDUCE_BLANKS). zshrs's `builtin_set` matcher had no cases for them. Added accept-silently mappings to the canonical option names so user scripts that toggle them don't bail out at startup. Test: `test_set_dash_h_and_k_accepted`.

### `${#arr[N]}` returned 0 instead of element char-count

- `${#a[1]}` for `a=(hello)` should return `5` (chars in "hello"). zshrs's `${#…[…]}` branch only handled `[@]`/`[*]` (array-element-count); numeric indices fell through and returned 0. Added a `lookup_array_element(name, index).chars().count()` fallback for the numeric / non-`@`/`*` case. Test: `test_array_element_length_via_hash`.

### `printf "%.s"` (zero precision via empty digits) printed the arg

- POSIX/zsh: `%.s` is the same as `%.0s` — a period with no digits means precision 0, which suppresses string output (`printf "%.s" ignore` → empty). zshrs only set `prec_val` when the precision string parsed to a number, so empty precision left `prec_val = None` and the full arg printed. Track a `saw_period` flag and default to `Some(0)` when the period was consumed but no digits followed. Test: `test_printf_dot_s_zero_precision_suppresses_arg`.

### `print -nN` left a stray NUL byte after output

- `print -n` should suppress the terminator entirely; the `-N` (NUL-separator) flag was overriding `-n` and emitting a final `\0`. Reordered the terminator selection so `no_newline` wins over `null_terminate`. Now `print -nN hi` outputs exactly `hi` with no trailing byte. Test: `test_print_dash_n_suppresses_null_terminator`.

### `(set -e; false); echo` killed the parent shell

- zsh's errexit aborts the subshell only; the parent continues with the subshell's exit status. zshrs's `BUILTIN_ERREXIT_CHECK` called `std::process::exit` unconditionally, tearing down the parent shell when an inner subshell hit a non-zero status under `set -e`. Added a `subshell_snapshots.is_empty()` guard so the exit only fires at top level (the in-process subshell continues to natural end with the parent intact). Full subshell-internal abort would require VM-level halt support and is deferred. Test: `test_set_e_in_subshell_doesnt_kill_parent`.

### `*(om)` glob qualifier wasn't sorting by mtime

- zsh's `*(om)` orders matches by modification time NEWEST-FIRST (the time qualifiers default to descending; `Om` is the oldest-first flip). Two bugs combined:
  1. The post-filter alpha sort in `expand_glob` ran AFTER `filter_by_qualifiers`, clobbering the qualifier-driven order.
  2. `looks_like_glob_qualifiers` was missing `O` in its valid-char set, so `*(Om)` parsed as a literal pattern with unmatched `)` instead of as a qualifier set.
  Fixed both: skip the alpha sort when the qualifier set contains `o`/`O`, and added `O` to the valid char set. Same default-descending semantics now applied to `oa` (atime) and `oc` (ctime) too. Tests: `test_glob_om_sort_newest_first`, `test_glob_Om_sort_oldest_first`.

## Closed (eighty-eighth-pass)

### `*(l2)` link-count glob qualifier not implemented

- zsh pattern.c qualifier `l[+-]N` matches files by hard-link count. Was missing from our `filter_by_qualifiers` handler. Added the parser block (cmp + digit run) and `MetadataExt::nlink()` filter. Also added `l` to the `looks_like_glob_qualifiers` valid-chars set so `*(l2)` parses as a qualifier set instead of falling back to literal pattern. Test: `test_glob_l_link_count_qualifier`.

### `a=$(false); echo $?` returned 0 (cmd-subst status not propagated to $?)

- zsh: cmd-subst's exit status leaks into $?, so `a=$(false); echo $?` prints 1. We were always returning 0 for the assignment. Three-part fix: (1) `run_command_substitution` now sets `self.last_status` from the inner cmd's status; (2) `BUILTIN_SET_VAR` returns `Value::Status(captured)` from the executor's last_status (instead of constant 0); (3) compile_assign emits `Op::SetStatus` (was `Op::Pop`) so vm.last_status reflects the propagated value. Test: `test_cmd_subst_status_propagates_to_assign`.

### `${(no)a[@]}` (sort-modifier-before-sort) was applied sequentially

- zsh's flag-string is order-agnostic: `n`/`i`/`a` are sort-MODIFIERS that pair with `o`/`O`. `(no)` and `(on)` should both produce numeric ascending. We were applying them as separate sort operations so `n`'s natural-sort got overwritten by the subsequent `o`'s alpha-sort. Fixed by detecting `n`/`i` BEFORE the `o`/`O` in the flags string when no inline sub-flag was given. Test: `test_sort_flag_with_numeric_modifier_either_order`.

### `${(j:sep:)$(cmd)}` over-applied join by splitting on whitespace first

- The cmd-subst-as-flag-operand branch (added in batch 11) split the captured output on whitespace BEFORE joining with sep. zsh: `(j:::)` is a no-op on a scalar — the cmd-subst output is a single string, not an array. Result: newline-separated output got crammed onto one line. Fixed: drop the split-then-join in the Join arm; cmd-subst → scalar → (j) no-op. Tests: `test_param_j_flag_on_cmd_subst_no_op`, `test_param_jf_split_then_join_cmd_subst`.

### `${a:^b}` / `${a:^^b}` array-zip operators not implemented

- zsh subst.c SUB_ZIP_SHORT (`:^`) interleaves up to min(len). SUB_ZIP_LONG (`:^^`) cycles the shorter array up to max(len). Both yield space-joined output. Was previously listed in the "Still open" GAPS section. Added detect at top of `expand_braced_variable` for plain-identifier names. Tests: `test_array_zip_short_form`, `test_array_zip_long_form_cycles`.

### `${a:$((${#a}-2))}` substring offset with nested `${...}` got rejected

- The compile-time substring shape detection refused offsets containing `${...}` to "leave nested only in length". But `$((${#a}-2))` legitimately has nested `${...}` inside an arith form. Refined the check: when the operand starts with digit/`$`/`-`/`(` (substring-shape signal), allow nested forms — the runtime SubstringExpr handler arith-evaluates them via `expand_string`. Test: `test_substring_offset_with_nested_arith`.

### `${${...}/pat/$var}` nested-replace path didn't expand `$var` in repl

- The nested-expansion `/pat/repl` branch passed both `pat` and `repl` to `String::replace` without running them through `expand_string`. zsh expands $-refs in both. Added `expand_string` calls. Test: `test_nested_replace_expands_dollar_in_repl`.

### `${arr[@]:offset}` array slice collapsed splice in assignment context

- `b=("${a[@]:1}")` should give `b` 2 elements (when `a` had 3); was giving 1 because BUILTIN_PARAM_SUBSTRING returned a joined scalar regardless of `[@]`. Compile path now re-attaches `[@]`/`[*]` to the name (parse_param_modifier dropped it). Runtime detects the suffix and returns `Value::Array` when `force_array`. Same fix for the EXPR variant. Without this, the canonical "shift-via-slice" idiom `while ((#a > 0)); do ...; a=("${a[@]:1}"); done` looped forever. Tests: `test_array_slice_at_preserves_splice_in_assignment`, `test_array_consume_loop_terminates`.

### `printf "abc" > file` left file empty AND leaked output to stdout

- Rust's `print!` is block-buffered when stdout is a non-tty (file via dup2). The `redirect_scope` restored the original stdout fd via dup2 BEFORE the buffer flushed, so buffered data ended up on the original terminal and the file stayed empty. `echo "abc"` worked because `println!` triggered line-buffer flush. Added explicit `std::io::stdout().flush()` at the end of `builtin_printf`, `builtin_echo`, `builtin_print` so non-newline output reaches the redirect target before scope restoration. Test: `test_printf_redirect_to_file_writes_data`.

### `typeset -T PATH path :` didn't read inherited $PATH from env

- The tied-pair init only consulted `self.variables`; vars like `PATH` that live in process env (not our shell-level map) read as empty so `path` was a 0-elem array. Fixed: fall back to `std::env::var(name)` when self.variables doesn't have it. Test: `test_typeset_t_reads_existing_env_value`.

### `unset path` (tied-array side) didn't clear $PATH (scalar side)

- zsh's tied-pair semantics: unsetting either side zeroes both. We only removed the named var, leaving the tied counterpart intact. Added bidirectional cleanup in `builtin_unset`: removing array side also unsets scalar (env + variables map); removing scalar side also unsets array. Test: `test_typeset_t_unset_propagates_to_tied`.

### `${h[(I)*]}` returned single key instead of all matches on assoc

- `(I)` and `(R)` flags on assoc subscript should return ALL matching keys/values space-joined; `(i)`/`(r)` return the FIRST match. We were always returning a single match. Direct port of zsh subst.c haspats path. Test: `test_assoc_capital_i_returns_all_matching_keys`.

### zsh-special params `SECONDS`/`UID`/`HISTCMD`/etc. treated as unset for `${X-default}`

- These have dynamic getters but aren't in `self.variables`, so `${SECONDS-default}` returned "default" instead of the live value. zsh treats them as always-set. Added matched whitelist (`SECONDS`, `EPOCHSECONDS`, `EPOCHREALTIME`, `RANDOM`, `LINENO`, `HISTCMD`, `PPID`, `UID`, `EUID`, `GID`, `EGID`, `SHLVL`) to all three `var_is_set` decision points (flag-aware path, no-modifier path, and `BUILTIN_PARAM_DEFAULT_FAMILY`). Also added `HISTCMD` to the dynamic getter (returns session history count). Test: `test_special_param_default_treats_as_set`.

### `trap "..." ZERR` / `trap "..." ERR` were no-op stubs

- `BUILTIN_ERREXIT_CHECK` only acted on `errexit`; the trap registered for ZERR/ERR was never fired. zsh's signals.c fires ZERR (and the alias ERR) whenever a command exits non-zero, before the errexit decision. Added `traps.get("ZERR").or(traps.get("ERR"))` lookup at the top of `BUILTIN_ERREXIT_CHECK` and run the body via `execute_script` (with last_status saved/restored to prevent recursion on trap-body failures). Tests: `test_zerr_trap_fires_on_nonzero_status`, `test_err_trap_alias_for_zerr`.

### `read -e` / `read -E` were no-op stubs

- zsh's bin_read calls fputs(buf, stdout) under both -e and -E. -e prints the line and DOESN'T assign; -E prints AND assigns. Both were swallowed in our flag-char loop with a `// TODO` comment. Implemented per zsh: -e returns 0 after the echo (no assignment); -E falls through to the assignment block. Tests: `test_read_minus_E_echoes_and_assigns`, `test_read_minus_e_echoes_only`.

### `print -C N` used tab separator instead of zsh's space-padded columns

- zsh's `-C N` pads each column to the widest entry and joins with two-space separator (so `print -C 2 a b c d` reads `a  c` / `b  d`). We were using a single tab join, which renders wider (8 chars typically) and ignored column-width padding entirely. Reworked to compute per-column widths and emit `item + pad-to-width + "  "` for each non-last column. Trailing partial rows don't pad after the last present item. Test: `test_print_minus_C_column_format`.

### `${a//\:/-}` — backslash-escaped non-meta char treated as literal `\:`

- zsh's pattern handling strips the backslash from `\X` when X is NOT a glob meta. Our `BUILTIN_PARAM_REPLACE` preserved the backslash, so the pattern looked for literal `\:` in the value (never matched). Added a pre-pass that strips backslash from non-meta escapes; preserves `\?`/`\*`/`\[`/`\]`/`\(`/`\)`/`\|`/`\\` for the regex compile downstream. Test: `test_param_replace_strips_backslash_escape_in_pat`.

### `${(P)$(...)}` — (P) indirect on cmd-subst result returned the captured output verbatim

- The cmd-subst-as-flag-operand branch (added in batch 11) didn't honor (P) — it returned the captured output as the value, not the value of THE VARIABLE NAMED BY THE OUTPUT. zsh: `a=hi; ${(P)$(echo a)}` → `hi` (NOT `a`). Detect Parameter flag, look up the captured-output string as a variable name. Test: `test_param_p_indirect_with_cmd_subst`.

### `a[$n]=()` / `a[$#a]=()` — variable subscript in element-remove ignored

- The compile path for subscripted-array assign emitted the LITERAL key string. For literal indices like `a[3]=()` this worked; but `a[$n]=()` reached the runtime as the literal "$n" which failed int-parse and the removal was a no-op. Compile path now routes the key through `compile_word_str` (var/cmd-subst expansion) when it contains `$` or `` ` ``. Test: `test_array_subscript_remove_with_var_index`.

### `typeset -A h=(...)` inside function leaked to parent's assoc

- `local_save_stack` and `local_array_save_stack` existed; no `local_assoc_save_stack`. So `typeset -A h=(b 2)` inside a function modified the outer `h` permanently. Added new save stack with the same lifecycle as the array stack — saved at typeset time, restored on function exit. Both `call_function` paths (legacy and bytecode) updated. Test: `test_local_assoc_array_shadows_outer`.

### `${(flag)$(cmd)}` — cmd-subst as flag operand returned empty in DQ

- The flag handler had a branch for `${(flag)${...}}` (nested expansion as operand) but not for `$(...)` (cmd-subst as operand). zsh subst.c runs the cmd-subst first, then applies flags to the captured output. Added `rest.starts_with("$(")` branch that calls `run_command_substitution` and applies U/L/Split/Join/SplitWords/SplitLines flags. Test: `test_param_flag_with_cmd_subst_operand`.

### `abs(-5)` / `min(3,5)` / `max(3,5)` returned `5.` (float) instead of `5` (int)

- All math functions returned `MathNum::Float`, even when the input was integer. Float-to-string formatting produced trailing `.` for whole numbers ("5."). Added int-preserving fast path for `abs`/`min`/`max`/`int`/`floor`/`ceil`/`trunc` — when all args are `MathNum::Integer`, return `MathNum::Integer`. Float inputs still produce float output. Test: `test_math_abs_min_max_preserve_int`.

### `b=("${a[@]}")` joined elements when assigning array-to-array

- Earlier batch 6 fix forced JOIN_STAR on `[@]` when `assign_context_depth > 0` to handle scalar `b="${a[@]}"`. But array init (`b=(...)`) shares the same flag, so array-to-array splice collapsed: `a=("1 2" "3 4"); b=("${a[@]}")` → `b=("1 2 3 4")` (1 element). Added separate `scalar_assign_depth` counter — only bumped for scalar assignment RHS. Test: `test_array_assigns_array_via_at_splice`.

### `**/*` recursive glob sorted by basename instead of full path

- For non-recursive globs, zsh sorts by basename (with locale-aware case-folding). For recursive `**/*`, zsh sorts by FULL path so depth-first walk order is preserved (`dir/f sub sub/g`, not basename `f g sub` which makes no sense at multiple levels). Added pattern-based dispatch: full-path sort when pattern contains `**/`, basename sort otherwise. Test: `test_recursive_glob_sorts_full_path`.

### Subshell EXIT trap fired AT PROCESS EXIT instead of subshell exit

- zsh forks for `(...)` so the trap runs in the child process when the subshell ends. We run subshells in-process; without firing the trap at `subshell_end`, the parent's process-exit fired ALL accumulated EXIT traps after the parent's last command. Added `traps` field to `SubshellSnapshot`, fire-and-remove the EXIT trap at subshell_end (before restoring parent's traps), execute via `with_executor(|exec| exec.execute_script(&body))` so the inner script doesn't recurse. Subshell-only traps don't leak — parent's traps are restored after firing. Tests: `test_subshell_exit_trap_fires_before_parent_continues`, `test_subshell_trap_doesnt_leak_to_parent`.

### `"${(o)a[@]}"` / `"${(O)a[@]}"` / `"${(n)a[@]}"` skipped sort in DQ context

- zsh subst.c: array-only flags (`o`/`O`/`n`/`i`/`u`) are stripped in DQ context UNLESS the user explicitly wrote `[@]`/`[*]` subscript. Our `parse_zsh_flag` strips `[@]`/`[*]` from `name` (the fast-path requires identifier-only), losing the splice-context information by the time the runtime DQ-strip decision runs. Encoded the at-subscript context through a new `\u{03}` sentinel in the runtime flags string so the handler can re-recognise `had_at_subscript`. Test: `test_sort_flags_with_at_subscript_in_dq`.

### `((a = cond ? T : F))` — ternary assignment dropped silently

- ArithCompiler's emit path doesn't implement `?:`. Without the trigger, `((a = ... ? ... : ...))` left `a` unset (no error). Added `?` to the needs_eval check so the expr routes through MathEval (which has full ternary support). Test: `test_arith_ternary_assignment`.

### `case W in (P|Q)) BODY ;; esac` — wrapped pattern with `|` failed

- zsh's case grammar accepts both bare `(P) BODY` (leading `(` is the optional marker, single `)` closes the arm) AND wrapped `(P)) BODY` (the `(...)` is the pattern wrapper, the second `)` closes the arm). Our parser only consumed ONE Outpar after patterns, so the wrapped form left the second `)` for the body to choke on. Added `had_leading_paren && Outpar` consume after the arm-close. Test: `test_case_paren_wrapped_pattern_with_alternation`.

### `typeset -a a` clobbered existing array to empty at top scope

- The bare-declaration path always called `self.arrays.insert(name, Vec::new())`. zsh's typeset.c only zeroes a new binding at top scope; existing values are preserved unless you're inside a function (where bare `typeset NAME` shadows). Added `in_function || !exists` guard. Test: `test_typeset_a_preserves_existing_array_at_top_scope`.

### `a=(...); typeset -aU a` didn't dedupe — array got cleared

- Same root cause as above (clobber to empty). After the fix, also added an immediate dedupe pass when `-U` is given on an existing array, mirroring the dedupe block at line 21330+ that fires after var_attrs is set. Test: `test_typeset_aU_dedupes_existing_array`.

### `${(U)${(s. .)s}[1]}` ignored the `[N]` subscript after the inner expansion

- The nested-expansion handler returned the flag-applied joined-scalar without parsing a trailing `[N]` subscript. zsh treats inner-with-(s::) as an array; `[N]` selects an element. Added `[N]` parser after the inner close — splits on space, indexes (1-based, negative-from-end), then re-applies case-transform flags to the picked element. Test: `test_nested_expansion_subscript_after_flag`.

### `. file.sh ARG1 ARG2` didn't pass extra args as positionals to sourced script

- zsh's source/`.` builtin passes `args[1..]` as `$1`/`$2`/... to the sourced file. We were ignoring extras — the script saw the parent's positionals (or empty in `-c` mode). Save outer positional_params, install args[1..] as new positionals, restore on exit. Tests: `test_source_passes_extra_args_as_positionals`, `test_source_preserves_outer_positionals`.

### `b="${a[@]}"` captured only the first element, not the joined array

- In an assignment context, both `[@]` and `[*]` join the array to a single string (zsh subst.c forces single-string output for scalar RHS). Our `${NAME[@]}` always emitted `BUILTIN_ARRAY_ALL` (Array splice). Compile path now forces `BUILTIN_ARRAY_JOIN_STAR` when `assign_context_depth > 0`. Test: `test_array_splice_in_scalar_assign_joins`.

### `$a[@]` / `$a[*]` (no braces) joined instead of splicing

- zsh treats bare `$NAME[@]`/`$NAME[*]` identically to the braced versions. `array_splice_ref` only matched `${NAME[@]}`/`${NAME[*]}`. Extended to also accept the no-braces form. Test: `test_array_bare_splice_no_braces`.

### `((i=a[2]))` set i to the joined-scalar of $a, not the second element

- ArithCompiler doesn't pre-resolve `name[idx]` on the RHS — the assignment landed `a`'s value (joined "1 2 3") instead of `a[2]`. Added `inner_arith.contains('[')` to the needs_eval check so MathEval (BUILTIN_ARITH_EVAL → evaluate_arithmetic) handles it; that path runs `pre_resolve_array_subscripts` before the math eval. Test: `test_arith_assign_from_array_subscript`.

### `setopt extendedglob; echo /tmp/dir/^pat` skipped negation when `^` was after `/`

- trigger_glob detection only fired for `^` at the start of the word, not for `^` at the start of any path component. Same for the bridge's expand_glob trigger. Both updated to also detect `/^`. Test: `test_glob_caret_at_path_component_with_extendedglob`.

### `[[ a == a && (b == b || c == c) ]]` parse-errored at the inner `(`

- The lexer sets `incondpat=true` after `==`/`!=`/`=~` so the RHS pattern can include glob chars. `incondpat` was only reset on `]]` — not on `&&`/`||`/`(`/`)`/`!`, so the next `(` after `&&` was lexed as a literal glob char (gettokstr) and the whole remainder collapsed into one String token. Direct port of zsh's cond.c par_cond_3 which treats those tokens as cond-pattern terminators. Test: `test_cond_double_bracket_grouping_parens`.

### Subshell umask leaked to parent

- zsh forks for `(...)` so `umask 077` inside dies with the child. We run subshells in-process; without snapshot+restore, the subshell's umask leaked. Added `umask` field to `SubshellSnapshot` (read via `libc::umask(0o022); umask(saved)`), restored on subshell_end via `libc::umask`. Test: `test_subshell_umask_restored_on_exit`.

### `${h[(I)key]}` on assoc searched VALUES instead of KEYS

- zsh subst.c: on associative arrays, `(i)`/`(I)` search KEYS and return the matching key (last match for `(I)`); `(r)`/`(R)` search VALUES and return the matching value. Our `assoc_subscript_flag` always searched values and used the (i)/(I) flag only to switch RETURN type. Fixed by routing `(i)`/`(I)` to key search. Test: `test_assoc_subscript_i_flag_searches_keys`.

### `{one,${a},three}` — outer brace not expanded when inner had var ref

- The segment-concat fast path concatenated `{one,`, `${a}`, `,three}` and pushed the joined scalar but never invoked `expand_braces`. zsh's pipeline brace-expands AFTER substitution. Compile-side detection added: when a literal segment contains `{` or `}`, emit `BUILTIN_BRACE_EXPAND` after the concat (followed by the existing `BUILTIN_GLOB_EXPAND` if glob meta also present). Test: `test_brace_expand_with_inner_var_ref`.

### `$D/*` / `$D/(a|b)` — glob expansion skipped when var ref preceded glob meta

- The segment-concat fast path (Phase 1 step 4) emitted CONCAT for words mixing var refs and glob metachars but never called `expand_glob` on the assembled scalar. zsh's word-expansion pipeline always pathname-expands the post-substitution string. Added `BUILTIN_GLOB_EXPAND` (id 343) — pops a scalar pattern, runs `expand_glob`, pushes Value::Array. Compile path detects glob meta in LITERAL segments only (so `$?`/`$#`/etc. don't trigger) and emits the builtin after the final concat. Tests: `test_glob_with_var_prefix_expands_paths`, `test_glob_with_var_prefix_alternation`.

### `${(j[+])a}` / `${(s[|])s}` — bracket-pair flag delimiters leaked close char

- zsh subst.c `get_strarg` accepts matched bracket pairs as flag delimiters: `[`/`]`, `{`/`}`, `(`/`)`, `<`/`>`. Both flag parsers (`parse_zsh_flags` and the `BUILTIN_PARAM_FLAG` inline parser) used the OPEN char as both opener and closer, so `${(j[+])a}` consumed `[` as opener, then read the rest expecting another `[` (never found) and produced `a+]b+]c`. Added pair-aware close translation. Test: `test_param_join_split_bracket_pair_delim`.

### `:Q` history modifier didn't strip backslash escapes

- zsh hist.c `remquote` removes single/double quote pairs AND backslash escapes (`\X` → `X`). Both `:Q` paths only stripped paired quotes; `a="a\\ b"; echo ${a:Q}` left `\ ` intact instead of giving `a b`. Replaced the simple `replace('\'',"")` with a stateful walk that tracks SQ/DQ and consumes `\X` outside SQ. Test: `test_param_q_modifier_strips_backslash_escapes`.

### `((h[a]++))` / `((h[a]+=v))` on assoc-array elements errored "lvalue required"

- zsh math.c LVAL_NUM_SUBSC keeps the subscript receiver's lvalue identity through compound operators. Our `pre_resolve_array_subscripts` substituted `h[a]` with its current value first, so `5++` reached MathEval and errored. The existing compound handler in `evaluate_arithmetic` only matched indexed arrays. Extended to detect the assoc case (`is_assoc = self.assoc_arrays.contains_key(&name)`) and walk the value through map.get/insert. Tests: `test_arith_assoc_subscript_postinc`, `test_arith_assoc_subscript_compound_assign`.

### `((++a[i]))` / `((++h[k]))` — pre-increment on subscript silently no-op'd

- The compile-side `subscripted_arith_compound_check` only matched POST-op shapes (`name[idx]++`/`+=`/etc.); pre-op (`++name[idx]`) fell through to ArithCompiler which couldn't write back. Added a new `parse_subscript_arith_pre_inc` parser and merged into the runtime compound handler with `is_pre` flag. Compile-side check also accepts the `++NAME[IDX]` shape. Pre-op returns NEW value (matches zsh); post-op returns OLD. Tests: `test_arith_array_subscript_pre_inc`, `test_arith_assoc_subscript_pre_inc`.

### Glob `~` exclusion at PATH level matched RHS as a fresh CWD glob

- `setopt extendedglob; echo $D/*.txt~*README*` should drop README.txt from the match set, but ours included it. The path-level handler recursively `expand_glob`'d the RHS in CWD instead of matching it as a PATTERN against each LHS candidate. Fixed by switching from a `HashSet`-based path equality check to per-candidate `glob_match_static` against basename and full path — direct port of zsh's pattern.c P_EXCLUDE which uses `pattry` per-candidate. Test: `test_glob_tilde_exclude_at_path_level`.

### Associative-array key insertion order was random

- `${(k)h}`, `${(kv)h}`, and `for k v in ${(kv)h}` returned keys in HashMap iteration order, not insertion order. zsh's params.c stores assoc entries in HashTable hnodes preserving insertion order. Switched `ParamValue::Assoc` and `ShellExecutor::assoc_arrays` inner type from `HashMap<String,String>` to `indexmap::IndexMap<String,String>`. Test: `test_assoc_keys_preserve_insertion_order`.

### `for k v in arr` (multi-name for) only assigned to first name

- zsh parse.c par_for accepts multiple identifier tokens before `in`; each iteration consumes N elements and assigns to N variables. Parser now collects all leading identifiers; compiler emits N-stride iteration with empty-string fill on short tail (mirrors exec.c forexec). Single-name path keeps the original 2-byte SET_VAR shape — no perf regression. Tests: `test_for_multi_var_pairs_consume_array`, `test_for_multi_var_three_consume_triples`, `test_for_multi_var_kv_iterates_assoc`.

### Nested `${${a%.txt}#hel}` dropped outer strip operator

- The nested-expansion handler dispatched outer `:MOD` and `/pat/repl` but fell through `#`/`##`/`%`/`%%`, returning the inner result unchanged. zsh subst.c reuses the same getarg machinery for inner and outer; we now mirror by calling `strip_match_op` on the inner result for all four operators. Test: `test_nested_expansion_strip_after_inner`.

### Nested `${(s. .)${(j. .)a}}` ignored outer flag entirely

- When `rest` after the flag block started with `${`, the flag-aware path treated it as a literal var name and returned empty. Added a recursive branch: detect leading `${`, find the matching `}`, recurse `expand_braced_variable` on the inner content, then apply the outer flags (U/L/C/Split/Join) to the inner result. Strip operators after the inner `${...}` also dispatch correctly. Test: `test_nested_expansion_outer_flag_applied_to_inner`.

### `${(l:5::0:)42}` padded with spaces instead of `0`

- The pad-flag parser only recognised the `l:LEN:FILL:` shape; zsh subst.c also accepts `l:LEN::S2:` where S1 is empty and S2 acts as the fill character (and l:LEN:S1:S2: where S1 prefixes once, S2 fills repeatedly). Reworked the parser to collect both strings and pick the fill: S1 if non-empty, else S2 if provided, else space. Test: `test_param_pad_zero_with_empty_string1`.

## Closed (seventy-seventh-pass)

### `history -c` had non-zsh error format

- zsh's `history` is a synonym for `fc -l`; it doesn't accept `-c` (bash's clear-history flag). zsh emits `history:1: bad option: -c`. zshrs had a custom "clear not supported in this mode" string. Aligned to zsh's diagnostic format. Test: `test_history_dash_c_zsh_error_format`.

### POSIX char classes (`[[:alpha:]]`, `[[:digit:]]`) didn't match

- The `glob` crate doesn't recognize the `[:class:]` syntax — it sees `[[:alpha:]]` as a literal pattern with stray `:` and `]`, never matches. Added `expand_posix_char_classes` pre-processor that translates known classes (`alpha`, `alnum`, `digit`, `xdigit`, `lower`, `upper`, `space`, `blank`, `cntrl`, `print`, `graph`, `punct`) to their enumerated ranges (`a-zA-Z`, `0-9`, etc.) before the pattern reaches `glob::glob_with`. Tests: `test_glob_posix_char_class_alpha`, `test_glob_posix_char_class_alpha_letters`.

### `case x in [a-z]) ;; [A-Z]) ;; esac` parse-errored on second arm

- Bracket-class patterns in subsequent case arms triggered `expected ')' in case pattern`. After parsing the first arm's body, the lexer advanced past `;;` BEFORE the parser set `incasepat=1` for the next round, so `[A-Z]` got tokenized as `Inbrack` (test/array subscript) instead of being part of a glob pattern. Fix: set `incasepat=1` BEFORE the zshlex advance on each terminator (`;;`, `;&`, `;|`) so the next pattern's `[` is lexed in pattern context. Test: `test_case_multi_pattern_with_brackets`.

### `type for` / `type while` reported "not found"

- zsh treats reserved words as a distinct type — `type for` reports "for is a reserved word". zshrs's `builtin_type` only checked aliases / functions / builtins / external paths, falling through to "not found" for keywords. Added a RESERVED_WORDS check at the top of the per-name loop so all 22 zsh keywords (`do`, `done`, `esac`, `then`, `elif`, `else`, `fi`, `for`, `case`, `if`, `while`, `until`, `select`, `function`, `repeat`, `time`, `in`, `foreach`, `end`, `coproc`, `nocorrect`, `noglob`) report the keyword status before any other lookup. Test: `test_type_for_reserved_word`.

### `"$#@"` expanded to "3@" instead of "3"

- zsh shorthand: `$#@` and `$#*` both mean "count of positional params" (same as `${#@}`/`${#*}`). zshrs's segment-splitter (`find_expansion_end`) only consumed `$#` then treated the trailing `@`/`*` as literal in the next segment. Same root cause for `X$#Y` (length of `Y`) — the `Y` was being dropped from the var-name lookup. Extended `find_expansion_end` to look ahead after `#` (literal or META-#): if next char is `@`/`*`/META-* consume it as part of the expansion; if next char is identifier-start (alpha or `_`) consume the whole identifier as the var name. Tests: `test_dollar_hash_at_in_double_quotes`, `test_dollar_hash_name_concat`.

### `unalias notdef` had non-zsh error format

- zsh: `unalias:1: no such hash table element: NAME`. zshrs had its own custom format (`unalias: NAME: not found`). Aligned to zsh's exact wording so user scripts that grep the error get the same string. Test: `test_unalias_missing_zsh_format`.

### `$OPTIND` defaulted to empty string

- POSIX: `getopts` reads OPTIND starting at 1 before any call. Scripts that probe `$OPTIND` before invoking `getopts` saw empty string in zshrs (zsh: `1`). Initialized OPTIND=1 and OPTERR=1 in the executor's variables map so first-read returns the canonical defaults. Test: `test_optind_default_one`.

### `setopt nosuchoption` was silent

- zsh emits `setopt:1: no such option: NAME` to stderr and returns 1. zshrs's `builtin_setopt` had a comment saying "zsh doesn't error on bad names" — that's wrong. Added a `ZSH_OPTIONS_SET.contains()` check after `normalize_option_name` and an early `eprintln!` + `return 1` for unknown names. Test: `test_setopt_unknown_option_errors`.

### `$((RANDOM))` returned 0

- `$RANDOM` worked (gets resolved by `get_variable`'s special-param branch) but `$((RANDOM))` returned 0 because `MathEval` looks up names in a static `string_variables` HashMap that didn't include the dynamic special params. Same root for `$((SECONDS))`, `$((EPOCHSECONDS))`, `$((LINENO))`. Fix: clone `self.variables` into an extras map and pre-inject `get_variable("RANDOM")` etc. before passing to `MathEval` — each arith eval now sees a fresh value (RANDOM also re-resolves per call so two arith-substs in a row return distinct values). Test: `test_random_resolves_in_arithmetic`.

### `$_` returned the shell binary path before any command

- zsh starts with `$_` empty (it ignores the OS-env value the parent process set when execing the shell). zshrs's `get_variable` fell through to `env::var("_")` which returned the path the parent used. Initialized `_` to empty in the executor's variables map so the first read returns the canonical empty string. Test: `test_dollar_underscore_starts_empty`.

### `[[ "foo()" == "foo()" ]]` (parens inside DQ) failed to match

- Quoted glob metas inside `[[ ... == ... ]]` patterns must match literally (zsh: `(`/`)`/`|` are alternation grouping under EXTENDED_GLOB, but quoted forms are literal). zshrs's `escape_quoted_glob_metas` only backslash-escaped `*`/`?`/`[`; left `(`/`)`/`|`/`~`/`#`/`^` unquoted, so the pattern matcher saw them as alternation tokens. Extended the escape set to all six. Test: `test_double_bracket_pattern_with_quoted_parens`.

### `command -V function` missing "from zsh" suffix

- zsh: `command -V foo` for a user function reports `foo is a shell function from zsh`. zshrs reported the truncated `foo is a shell function`. Aligned the format to match zsh's output. Test: `test_command_v_function_shows_source`.

### `(( arr[1]++ ))` (and `+=`/`-=`/`*=` etc.) didn't update the element

- zsh: `(( a[1]++ ))` reads element 1, increments, writes back. zshrs's `subscripted_arith_assign_check` only matched the bare `=` form (`(( a[1]=v ))`); compound-assigns and ++/-- fell through to MathEval which can't write through `a[idx]` (it pre-resolves `a[1]` to its value, then `0++` errors "lvalue required"). Added: 
  - `subscripted_arith_compound_check` in compile_zsh.rs to route the compound forms through `BUILTIN_ARITH_EVAL`.
  - `parse_subscript_arith_compound` in exec.rs (handles `++`, `--`, `+=`, `-=`, `*=`, `/=`, `%=`, `&=`, `|=`, `^=`, `<<=`, `>>=`, `**=`).
  - Read-modify-write logic in `evaluate_arithmetic` that mirrors the bare-`=` special case but with the compound operator. Post-increment/decrement returns the OLD value (matching zsh / C semantics). Tests: `test_arith_subscripted_post_increment`, `test_arith_subscripted_compound_plus_eq`, `test_arith_subscripted_post_increment_returns_old`.

### `*(.)` glob qualifier matched symlinks-to-files

- zsh: `.` qualifier means "plain regular file" — symlinks-to-files are excluded (use `@` for those). zshrs's filter used `is_file()` on followed metadata, so a symlink to a regular file passed the test. Now also checks `symlink_metadata().file_type().is_symlink()` to filter out links first. Test: `test_glob_dot_qualifier_excludes_symlinks`.

### Extended-glob `pat~excl` exclusion not implemented

- zsh's `setopt extendedglob` enables `*.txt~b.txt` (match `*.txt` excluding `b.txt`). Was returning "no matches found". Added a top-level `~` detector at the top of `expand_glob`: split into LHS/RHS, expand both halves, return `LHS \ RHS`. Honors nullglob/nomatch when the difference is empty. Also extended the bridge-path glob trigger in `BUILTIN_EXPAND_TEXT` to fire when a word starts with `^` or contains `~` AND extendedglob is set. Test: `test_extendedglob_tilde_exclusion`.

### Extended-glob `^pat` negation not implemented

- zsh's `^pat` (under extendedglob) matches everything that does NOT match `pat`. Was being passed through as a literal. Added a leading-`^` detector in `expand_glob`: walks the dir, filters out matches of the pattern, returns the remainder (sorted, dot-files excluded as zsh does). Test: `test_extendedglob_caret_negation`.

### `alias -L name` printed `name=value` instead of `alias name=value`

- zsh's `alias -L` is "list in re-input form" — output should round-trip back through the alias builtin. zshrs's bare-name lookup branch ignored the `list_form` flag. Added `if list_form { println!("alias {}", body) }` path. Test: `test_alias_dash_L_emits_alias_prefix`.

### `setopt nocaseglob` was silently ignored

- `setopt nocaseglob` normalises to `caseglob=false` in the options HashMap (the `no` prefix is the negation marker stripped by `normalize_option_name`). But `expand_glob` only read `nocaseglob` directly, so the option never took effect. Read BOTH `caseglob` (default-true) and `nocaseglob` keys to honor either form. Test: `test_setopt_nocaseglob_honored`.

### `$(false)` as the only word triggered "command not found:"

- An empty command-substitution result that becomes the entire command word should be a no-op (exit status preserved). zshrs hit `host_exec_external` with an empty `cmd` and emitted `command not found:` (no name) with status 127. Added an `if cmd.is_empty() && rest.is_empty()` early return at the top of host_exec_external. Test: `test_empty_cmdsubst_no_command_not_found`.

### `type local` / `declare` / `typeset` / `readonly` / `export` reported "shell builtin"

- zsh's `type local` reports `local is a reserved word` — these are precommand modifiers parsed at the syntactic level, not regular builtins. zshrs's reserved-word table only included flow-control keywords (`if`, `while`, etc.); the declaration keywords were missing. Added: `local`, `declare`, `typeset`, `readonly`, `export`, `integer`, `float` to the RESERVED_WORDS list in `builtin_type`. Test: `test_type_reserved_word_local_declare`.

### `whence -v local` / `repeat` etc. also reported "shell builtin"

- Same reserved-word gap in the `whence` builtin's `is_reserved_word` table. Added the same declaration keywords (`local`, `declare`, `typeset`, `readonly`, `export`, `integer`, `float`) plus zsh-specific keywords `repeat`, `foreach`, `end`, `nocorrect`, `noglob` that were missing from both tables. `whence -v local` now matches zsh's `local is a reserved word`. Test: `test_whence_reserved_word_local`.

### `$_` leaked internal `return N` arg as the function's call-form last-arg

- After `foo() { return 42 }; foo; echo $_`, zsh reports `$_ = foo` (the function name, since no args were passed). zshrs reported `$_ = 42` (the `return 42` arg) because the function-internal `pop_args` for `return` updated `pending_underscore`, and that leaked back to the caller. Fix: at the END of `call_function`, overwrite `$_` and `pending_underscore` with the function's CALL-form last arg (or the function name if no args). The internal command args don't escape function scope. Test: `test_dollar_underscore_after_function_call`.

### `[[ -l file ]]` (and other unknown unary conditions) silently returned false

- zsh: `[[ -l file ]]` (no `-l` test in zsh — `-h` is the symlink test) emits `unknown condition: -l` to stderr and returns false. zshrs's `emit_file_test` default arm for unknown ops just emitted `Pop; LoadFalse` with no diagnostic. Now emits the error message at compile-time so users see the same warning zsh prints. Test: `test_unknown_cond_emits_diagnostic`.

### `${a[N]:offset:length}` returned the full element instead of the substring

- zsh: `a=(hello); ${a[1]:0:1}` should return `h` (substring of element 1). zshrs's bracket-handler routed the `:0:1` modifier through the colon-default branch (`:-`) which doesn't handle digit-prefixed offset/length. Added a `:DIGIT[:DIGIT]` substring branch BEFORE the colon-default handlers — only fires when the char after `:` is a digit (so `:-default` continues to be the default-if-empty form, not a negative offset). Test: `test_array_element_substring`.

### `print -P "%B"` appended an unwanted `\e[0m` reset

- zsh: prompt expansion does NOT auto-reset attributes at end. `print -P "%B"` outputs exactly `\e[1m\n` — the bold escape and a newline. zshrs's prompt expander unconditionally appended `\e[0m` when any attribute (`bold`, `underline`, `fg_color`, etc.) was active at end-of-expansion. Removed the auto-reset block so the user controls when to clear with explicit `%b`/`%f`/`%k`. Test: `test_print_dash_P_no_trailing_reset`.

### `$_` empty after no-arg `true` / `false` / `:`

- zsh: `true; echo $_` prints `true` (the command name, since no args). zshrs's `pop_args` updates `pending_underscore` only from `args.last()`; for arg-less commands no update fired. Backfilled the command name in BUILTIN_TRUE/FALSE/COLON when args is empty. Test: `test_dollar_underscore_after_no_arg_command`.

### `[[ -t fd ]]` (is-fd-a-tty) emitted "unknown condition: -t"

- zsh: `[[ -t 0 ]]` checks if stdin is a tty. zshrs's `emit_file_test` had no case for `-t` — it fell to the default unknown-condition branch and emitted the new diagnostic spuriously. Added a `-t` case that pushes the fd-string and routes through new `BUILTIN_IS_TTY` (calls `libc::isatty`). Test: `test_dash_t_fd_is_tty`.

### `echo */` stripped trailing slash from directory matches

- zsh: `echo */` outputs each directory with a trailing `/`. The Rust `glob` crate strips trailing slashes from match results, so zshrs returned `sub` instead of `sub/`. Re-append `/` to each result when the input pattern ended in `/`. Test: `test_glob_trailing_slash_preserved`.

### `echo - hi` printed `- hi` instead of `hi`

- zsh's echo treats a bare `-` (single char) as a no-op flag — silently consumed. zshrs's flag parser skipped tokens shorter than 2 chars, so the lone `-` became a positional arg. Added an explicit `if arg == "-"` skip in the flag-walk. `--` (two dashes) is still NOT a recognized flag — stays literal. Test: `test_echo_bare_dash_is_noop_flag`.

## Closed (eighty-first-pass)

### `shift -1` silently no-op'd instead of erroring

- zsh: `shift -1` errors `argument to shift must be non-negative` exit 1. zshrs's arg parser checked `arg.chars().all(is_ascii_digit)` for the count branch (which skips `-1` because of the leading `-`), then fell through to `array_names.push("-1")`. Since there was no array named `-1`, the shift was silently no-op'd. Added an explicit negative-numeric check before the digit-only branch that emits zsh's diagnostic and returns 1. Test: `test_shift_negative_count_errors`.

### `LINENO=99` silently set the variable instead of erroring

- zsh has a hard-wired set of intrinsic read-only specials (`PPID`, `LINENO`, `ZSH_ARGZERO`, `argv0`, `ARGC`, `_`) that can never be assigned to from script — assignment errors `read-only variable: NAME` exit 1. zshrs's `BUILTIN_SET_VAR` handler only consulted the user-managed `readonly_vars` set + `var_attrs.readonly`, so the intrinsics passed through. Added an `is_intrinsic_ro` matches!() check at the top of the readonly gate. Test: `test_lineno_intrinsic_readonly`.

### `set -Z` (and `set +Z`) silently accepted unknown letters

- zsh: `set -Z` errors `can't change option: -Z` exit 1. zshrs's multi-letter flag parser had a silent `_ => {}` fallback in the per-char `match`, accepting any unknown letter as a no-op. Replaced with an explicit error arm; also extended the recognized-letter list to cover zsh's full single-letter table (a, b, B, C, d, e, f, g, h, k, m, n, p, r, s, t, u, v, x, y, A, E, F, G, H, K, L, N, P, R, T, U, X, Y) so the existing knobs aren't broken by the new strict path. Same fix mirrored to the `+` arm. Test: `test_set_unknown_letter_errors`.

### `set -o nonexistentopt` silently inserted junk into the option map

- zsh: `set -o badopt` errors `no such option: badopt` exit 1. zshrs called `normalize_option_name(opt)` and inserted whatever name it returned into `self.options` — including names that aren't real zsh options. This left stale junk in the option map AND silenced typos. Added a `ZSH_OPTIONS_SET.contains(name)` guard before insertion in both the `-o` and `+o` arms; on miss, emit zsh's diagnostic and return 1. Test: `test_set_o_unknown_option_errors`.

### `unset` (no args) returned 0 silently

- zsh: bare `unset` errors `not enough arguments` exit 1 — at least one variable name is required. zshrs returned 0 silently, masking accidental empty `unset $maybe` (where `$maybe` expanded to nothing). Added an `args.is_empty()` early-error branch before the `-f`/`-v`/`-m` flag walk. Test: `test_unset_no_args_errors`.

### `disown` (no args, no current job) returned 0 silently

- zsh: bare `disown` with no current job errors `no current job` exit 1. zshrs had an `if let Some(job) = self.jobs.current()` block that ran the disown when a job existed, but the `if-let` simply fell through to `return 0` when there was no job — silent success on what should be an error. Restructured to error on the `None` arm. Test: `test_disown_no_current_job_errors`.

### `command` (no args) errored "redirection with no command"

- A previous batch added an unconditional error for bare `command` based on a misread of zsh's docs. Verified live: `command` (no args, no redirections) exits 0 silently in both zsh and bash; the "redirection with no command" diagnostic only fires when redirections are present without a command name (a parser-level concern, not the builtin's). Reverted the builtin-level error to a silent `return 0`. Test: `test_command_no_args_silent` (replaces the wrong `test_command_no_args_redirection_error`).

### `[ a -ZZ b ]` silently returned 1 instead of "unknown condition: -ZZ"

- zsh: `[ a -ZZ b ]` errors `[:1: unknown condition: -ZZ` exit 2 — the alphabetic operator at args[1] isn't a recognized comparator (`-eq`/`-ne`/etc., `=`/`!=`, `<`/`>`, `-nt`/`-ot`/`-ef`, `-a`/`-o`). zshrs's 3-arg path only checked the numeric-comparator subset, falling through to the AND/OR connective splitter for everything else; the splitter found nothing to split and returned 1. Added an "unknown alphabetic 3-arg operator" arm next to the numeric-comparator arm. Test: `test_test_unknown_3arg_op_errors`.

### `[ \( a ]` silently returned 1 instead of "argument expected"

- zsh: an unmatched `(` in `test`/`[` syntax errors `[:1: argument expected` exit 2. zshrs ignored paren depth at the top level, falling through to the single-string `[s]` arm (which evaluates `(` as truthy) or the catch-all 1-return. Walked the args once tracking paren depth; if depth ≠ 0 at end, emit zsh's diagnostic and exit 2. Conservative — only fires on actual mismatch, so legitimate `[ \( -n a \) -a \( -z "" \) ]` is unaffected. Test: `test_test_paren_mismatch_errors`.

### `kill` and `kill -9` (no PID) printed bash-style multi-line usage banner

- zsh: bare `kill` and `kill -SIG` (no PID) both error `kill:1: not enough arguments` exit 1 — terse zsh format. zshrs printed `kill: usage: kill [-s signal | -n num | -sig] pid ...` followed by `       kill -l [sig ...]` — bash-style two-line banner without the shell-name prefix. Replaced both `eprintln!` paths (no-args and missing-PID) with the zsh-format `zshrs:kill:1: not enough arguments`. Test: `test_kill_no_args_zsh_format`.

### `trap "" BADSIGNAL` silently registered a never-firable trap

- zsh validates the signal name before installing the handler — unknown errors `trap:1: undefined signal: NAME` exit 1 and the trap is NOT installed. zshrs's loop blindly inserted whatever uppercased token came in, so `trap "" BADSIG` quietly polluted the trap table. Added a known-signal allowlist (zsh's full SIG-prefixed table plus the pseudo-signals `EXIT`/`ZERR`/`DEBUG`/`ERR`/`RETURN` plus numeric forms) before insertion; on miss, emit zsh's diagnostic and return 1. Test: `test_trap_undefined_signal_errors`.

### `trap -l` printed bash-style numbered SIGNAL list (not zsh's silent empty)

- zsh's `trap` builtin doesn't recognize `-l` as a flag — it just falls into the no-args path which prints currently-installed traps (empty in `-f` mode). zshrs implemented `-l` bash-style, emitting a 6-row 5-col table of `1) SIGHUP   2) SIGINT …`. Replaced with `return 0;` to match zsh's silent output. Test: `test_trap_l_silent`.

### `vared` (no args) printed `vared: not enough arguments` (no zsh prefix)

- zsh prefixes error diagnostics with `<shellname>:<builtin>:<line>:` — `vared:1: not enough arguments`. zshrs emitted bare `vared:` (no shell name, no line number), breaking script consumers that grep for the canonical zsh error format. Both eprintln sites in `builtin_vared` now use `zshrs:vared:1:`. Test: `test_vared_no_args_zsh_format`.

### `unset NAME` for read-only NAME silently removed the entry

- zsh: `unset NAME` for a read-only NAME errors `read-only variable: NAME` exit 1 — the unset is rejected, not silently consumed. zshrs's `builtin_unset` blindly stripped the entry from `variables`/`arrays`/`assoc_arrays` without consulting the read-only bit, so `readonly x=1; unset x` left x unset and exit 0 — a compat regression that broke scripts probing for readonly state. Added a per-name read-only check (intrinsic specials + `readonly_vars` + `var_attrs.readonly`) before the remove() calls. Test: `test_unset_readonly_errors`.

### `typeset -Q x=1` and `declare -Q x=1` silently succeeded

- zsh: unknown typeset/declare flag errors `typeset:1: bad option: -Q` (or `declare:1:`) exit 1. zshrs's per-char flag loop had a silent `_ => {}` fallback in both the `-` and `+` arms, so unknown letters were accepted as no-ops and the variable was set without any attribute. Replaced both fallbacks with explicit `bad option:` errors; uses the `invoked_as` parameter so `declare -Q` and `typeset -Q` produce the right name. Test: `test_typeset_unknown_flag_errors`.

### `printf` (no args) printed bash-style usage banner

- zsh: `printf` -> `printf:1: not enough arguments` exit 1. zshrs emitted `printf: usage: printf format [arguments]` — bash-style usage line without the shell-name prefix. Replaced both eprintln sites in `builtin_printf` with `zshrs:printf:1: not enough arguments`. Test: `test_printf_no_args_zsh_format`.

### `let "1+"` / `$((5+))` reported bare "not enough operands" instead of zsh's "bad math expression" wording

- zsh: math-expression parser errors are wrapped in `bad math expression: <reason>` so script consumers can grep for the canonical prefix. zshrs's `MathContext::op` arm emitted a bare `not enough operands` diagnostic, missing the wrapper. Changed `src/math.rs` to emit `bad math expression: operand expected at end of string` for the binary-op underflow case (matches zsh's exact phrasing for `let "1+"` AND `$((5+))`). Test: `test_arith_trailing_op_uses_zsh_wording`.

### `let "("` reported bare "')' expected" instead of zsh's wrapped wording

- zsh: `let "("` -> `bad math expression: ')' expected`. zshrs emitted `')' expected` without the wrapper. Updated `MathContext::InPar` arm to use the wrapped phrasing. Test: `test_arith_open_paren_uses_zsh_wording`.

### `where __notacmd__` and `which __notacmd__` reported "shell built-in command" exit 0

- zsh: `where`/`which` for an unknown name -> `not found` exit 1. zshrs's `is_builtin()` helper has a `name.starts_with('_')` bypass for completion functions (so `_foo` lookups don't fall through to disk). The `whence` core path used `is_builtin()` directly, so `where __notacmd__` matched the `_` prefix, reported `__notacmd__: shell built-in command` and returned 0. Tightened the `whence` builtin-check arm to `BUILTIN_SET.contains(name)` (skip the `_`-prefix bypass) so completion functions still resolve via the function-lookup arm but unknown `_`-names report `not found`. Tests: `test_where_unknown_command_not_found`, `test_which_unknown_command_not_found`.

### `history XX` (non-numeric arg) reported "no such event: 1" instead of "event not found: XX"

- zsh: `history XX` (search-by-text with no match) -> `fc:1: event not found: XX` exit 1. zshrs's no-tty no-session branch had a hardcoded `no such event: 1` regardless of the user's input — wrong wording for non-numeric args AND wrong event identifier (always "1" instead of the user's text). Added a `search_query` branch: if the parsed positional was non-numeric, emit zsh's `event not found: XX`; otherwise stay on the existing `no such event: 1` path. Test: `test_history_text_query_uses_event_not_found`.

### `unalias xyz abc` returned on first miss (hiding subsequent misses)

- zsh: `unalias` continues processing remaining names after a miss, emitting one diagnostic per unknown entry and returning the last failing exit code. zshrs's loop returned on the first miss, hiding the rest from script consumers (`unalias xyz abc` only printed the `xyz` error and exited 1, never checking `abc`). Restructured the loop to collect status without early-exit. Test: `test_unalias_continues_after_first_miss`.

### `read -Q v` silently accepted unknown flag

- zsh: `read -Q v` -> `read:1: bad option: -Q` exit 1. zshrs's per-char flag loop in `builtin_read` had a silent `_ => {}` fallback, so unknown letters were accepted and the read ran as if -Q were valid. Replaced the fallback with an explicit `bad option:` error that exits 1 before reading. Test: `test_read_unknown_flag_errors`.

### `getopts` (no args) printed bash-style usage banner

- zsh: bare `getopts` -> `getopts:1: not enough arguments` exit 1. zshrs printed `zshrs: getopts: usage: getopts optstring name [arg ...]` — bash-style banner without the line-number-prefixed format. Replaced with `zshrs:getopts:1: not enough arguments`. Test: `test_getopts_no_args_zsh_format`.

### `wait %999` (id never created) silently returned 0

- zsh: `wait %N` for an N never assigned to a job -> `wait:1: %N: no such job` exit 127. zshrs's earlier "silent on missing %ID" rule (added to keep the `cmd & wait %1` idiom working) was too broad — `wait %999` with no jobs ever started silently returned 0 too. Distinguished via $! sentinel: errors when the session has never set $! (no bg ever run); silent only when bg was used (so `cmd & wait %1` still works after the bg child completes). Test: `test_wait_unrealistic_jobspec_errors`.

### `exec -c`/`exec -l`/`exec -a foo` (no command) silently returned 0

- zsh: `exec FLAG` (any flag form without a following command) -> `exec requires a command to execute` exit 1. zshrs collapsed all "no command" cases to silent return 0, masking flag-only typos. Bare `exec` (no flags, no command) still returns 0 silently per POSIX (the env-modify form). Now errors when any of `-c`/`-l`/`-a NAME` were specified without a command. Test: `test_exec_flag_only_no_command_errors`.

### `printf "%Z\n" 1` printed the diagnostic but returned 0

- zsh: invalid printf directive errors `printf:1: %Z: invalid directive` exit 1 — but zshrs printed the diagnostic and still returned 0, hiding the failure for $?-checking scripts. Added a local `had_error` flag through the format-spec walker; the `_ => { … }` arm sets it on any unknown specifier (and on `%a`/`%v`/`%V` which zsh also rejects). Function now returns 1 if any error fired. Test: `test_printf_invalid_directive_exits_nonzero`.

### `kill -INVALID 1` printed bash-style "kill: invalid signal: -INVALID"

- zsh: `kill -INVALID 1` -> `kill:1: unknown signal: SIGINVALID` followed by `kill:1: type kill -l for a list of signals` exit 1. zshrs emitted the bash-style `kill: invalid signal: -INVALID` (with leading dash, no SIG prefix, no hint line). Replaced the `_-flag` else arm with the two-line zsh format (already used by the `-L`-as-signal arm). Test: `test_kill_bad_signal_uses_zsh_format`.

## Closed (eighty-second-pass)

### `source` (no args) printed bash-style banner with no shell-name prefix

- zsh: bare `source` -> `source:1: not enough arguments` exit 1. zshrs printed `source: filename argument required` — bash-style banner without the canonical zsh `<shellname>:<builtin>:<line>:` prefix that scripts grep for. Refactored to `builtin_source_named(args, invoked_as)`; `builtin_source` is now a thin wrapper. The bare-source path emits zsh's terse format. Test: `test_source_no_args_zsh_format`.

### `. ""` printed "is a directory" with exit 1 instead of "no such file or directory" exit 127

- zsh: `. ""` -> `.:1: no such file or directory:` exit 127 (treats the empty path as a missing file with empty trailing identifier). zshrs's POSIX path resolver mapped "" to the current working directory, then `fs::read_to_string("")` returned `Is a directory`, so the diagnostic became `is a directory: ` exit 1 — wrong wording AND wrong exit. Special-cased empty-path early in `builtin_source_named` to emit zsh's diagnostic + exit 127. Test: `test_source_empty_path_uses_no_such_file`.

### `[ a b c d ]` (4+ args, no operator) silently returned 1

- zsh: `[ a b c d ]` -> `1: condition expected: a` exit 2 (more than 3 operands without a recognized connective is a syntax error). zshrs's default arm fell through every operator/connective check and returned 1, which a consumer would read as "false" rather than "syntax error". Added a `args.len() >= 4` arm at the very end of the default block that emits zsh's diagnostic and exits 2. Test: `test_test_4plus_args_no_op_errors`.

### `[ \( \) ]` (matching empty parens) silently returned 1

- zsh: `[ \( \) ]` -> `[:1: argument expected` exit 2 (parens around an empty expression is ill-formed). zshrs's recursive `inner.is_empty()` recursion landed at the bare-args-empty `1` arm and silently succeeded false. Added an `inner.is_empty()` check inside the strip-parens-and-recurse path that emits zsh's diagnostic and exits 2 before recursing. Test: `test_test_empty_paren_errors`.

### `zle -l` listed built-in widgets in non-interactive scripts

- zsh: in `-c`/`-f` non-interactive mode the ZLE module isn't loaded, so `zle -l` outputs nothing and returns 0. zshrs eagerly preloaded its built-in widget table on every interp startup, so `zle -l` always emitted the full list — diverging from zsh's silent-empty output and breaking scripts that grep for "zle module not loaded" semantics. Added an `atty::is(Stream::Stdin)` guard in both `-l` and `-la`/`-lL` arms; non-tty mode returns 0 immediately. Test: `test_zle_l_silent_in_script`.

### `umask 999` reported "bad symbolic mode operator: 9" instead of "bad umask"

- zsh: `umask 999` (digits but invalid octal — 9 isn't a valid octal digit) -> `umask:1: bad umask` exit 1. zshrs's `from_str_radix(v, 8)` failed, then the input fell into the symbolic-form parser, which interpreted `9` as a class char that wasn't followed by `+`/`-`/`=` and emitted `bad symbolic mode operator: 9` — wrong error category entirely. Added a `looks_numeric` (all-digits) precheck before the symbolic-form parser that emits `bad umask` instead. Test: `test_umask_bad_numeric_format`.

### `umask u=Z` reported generic "invalid mask" instead of specific "bad symbolic mode permission: Z"

- zsh: `umask u=Z` -> `umask:1: bad symbolic mode permission: Z` exit 1 — pinpoints the unknown rwx char. zshrs collapsed any symbolic-form failure to `umask: invalid mask: u=Z`. Refactored the rwx-char loop to track a `bad_perm: Option<char>` and emit zsh's specific diagnostic on miss before falling back. Test: `test_umask_bad_symbolic_permission`.

### `pwd -X` printed cwd as if -X were a valid flag

- zsh: `pwd -X` -> `pwd:1: bad option: -X` exit 1. zshrs's per-char flag loop had a silent `_ => {}` fallback, accepting any letter and continuing to print the cwd. Replaced with explicit `bad option: -X` error that exits 1 before the print. Test: `test_pwd_unknown_flag_errors`.

### `cd /tmp /etc` (two-arg substitution form, OLD not in $PWD) silently used args[0] as target

- zsh: `cd OLD NEW` is the path-substitution form — replaces OLD with NEW in $PWD and cd's there. If OLD isn't in $PWD, errors `cd:1: string not in pwd: OLD` exit 1. zshrs's two-arg branch only fired the substitution when OLD was in cwd; otherwise it silently fell through and treated args[0] as the target (bash-style `cd path1 path2 = cd path1` semantics). Added an explicit `string not in pwd:` error on miss. Test: `test_cd_two_args_substitution_or_error`.

### `readonly -X x=1` silently inserted "-X" as a readonly variable

- zsh: `readonly -X x=1` -> `readonly:1: bad option: -X` exit 1. zshrs's loop treated any non-`-p` argument as either an `=`-form binding or a bare name. So `-X` got inserted into `readonly_vars` and `var_attrs` as a junk readonly entry, masking the typo. Added a `starts_with('-')` flag-arg check before the binding/bare-name branches that emits zsh's `bad option:` diagnostic. Test: `test_readonly_unknown_flag_errors`.

### `type __notexist__` reported "is a shell builtin" instead of "not found"

- zsh: `type __notexist__` -> `__notexist__ not found`. zshrs's `is_builtin()` helper has a `_`-prefix bypass for completion functions, so any `_*` name was falsely classified as a builtin. The `type` builtin used `is_builtin()` directly, mirroring the same bug already fixed in `whence`/`where`/`which`. Tightened the `type` builtin-check arm to consult `BUILTIN_SET.contains(name)` directly. Test: `test_type_underscore_unknown_not_builtin`.

### `unsetopt nonexistentopt` silently inserted junk into the option map

- zsh: `unsetopt nonexistentopt` -> `unsetopt:1: no such option: nonexistentopt` exit 1. zshrs blindly inserted whatever name `normalize_option_name` returned into `self.options`, leaving stale junk in the map and silencing typos (mirror of the `setopt`-bug fixed earlier). Added a `ZSH_OPTIONS_SET.contains(name)` guard before insertion in the default arm of the per-arg loop. Test: `test_unsetopt_unknown_option_errors`.

### `exit 1 2 3` silently swallowed extra args

- zsh: `exit 1 2 3` -> `exit:1: too many arguments` exit 1. zshrs silently parsed args[0] as the code and ignored the rest. NOTE: zsh's bytecode actually CONTINUES past the failed exit (the rest of the script runs); zshrs's compiler unconditionally jumps to script end after `BUILTIN_EXIT`, so we can't replicate "continue past failed exit" here — the best we can do is emit the diagnostic before exiting, which catches the typo instead of silently swallowing. Test: `test_exit_too_many_args_diagnoses`.

### `trap "" 99` silently registered an out-of-range numeric signal

- zsh: numeric signals must be in (0, 63] (signal 0 is `EXIT`, max is `SIGRTMAX`). `trap "" 99` -> `trap:1: undefined signal: 99` exit 1. zshrs's known-sig validator accepted ANY parseable u32, registering a never-firable trap silently. Bounded the numeric arm to `n > 0 && n <= 63`. Test: `test_trap_numeric_signal_out_of_range_errors`.

### `exec --bad` (long-option-style typo) silently no-op'd

- zsh: `exec --bad` -> `exec requires a command to execute` exit 1. zshrs's flag walker hit `_ => {}` for unknown letters, so `--bad` was silently consumed without setting any flag, the cmd_args stayed empty, and the existing flag-only check (gated on `clear_env || login_shell || argv0.is_some()`) didn't fire because none of those got set. Tightened the check by scanning the input args for any `-`-prefixed token; if seen with empty cmd_args, the missing-command error fires. Test: `test_exec_long_option_typo_errors`.

### `print -S foo bar` silently concatenated into history

- zsh: `print -S` is the split-shell-words history form and takes EXACTLY one positional. `print -S foo bar` -> `print:1: option -S takes a single argument` exit 1. zshrs treated `-S` as `add_to_history = true` (same as `-s`) and concatenated all args into the history entry silently. Added a separate `split_word_history: bool` track for `-S`; if multiple positionals follow, emit zsh's diagnostic and exit 1 before adding. Test: `test_print_S_takes_single_arg`.

### `autoload -Z foo` and `autoload -l` silently accepted unknown flags

- zsh: `autoload -Z` -> `autoload:1: bad option: -Z`; `autoload -l` -> `autoload:1: bad option: -l` exit 1. zshrs's silent `_ => {}` fallback in the flag char-loop accepted any letter, masking typos AND the bash-style `-l` flag that zsh doesn't have. Replaced the fallback with explicit `bad option:` error. Test: `test_autoload_unknown_flag_errors`.

### `[ -lt 5 3 ]` (binop at args[0]) silently returned 1

- zsh: `[ -lt 5 3 ]` -> `[:1: unknown condition: -lt` exit 2. The op-at-front looks like a unary condition zsh doesn't recognise. zshrs's 3-arg path only checked `args[1]` for the operator, so `args[0]=-lt args[1]=5 args[2]=3` slipped past every match arm and hit the catch-all `1`. Added a 3-arg arm that triggers on a known binop at args[0] and emits zsh's diagnostic. Test: `test_test_3arg_op_at_pos0_errors`.

### `[ a -lt 3 5 ]` (4 args with valid binop) emitted "condition expected: a" instead of "too many arguments"

- zsh: `[ a -lt 3 5 ]` -> `[:1: too many arguments` exit 2 — the binop is correctly placed but the operand count is wrong. zshrs's 4+-arg arm always emitted `condition expected: <args[0]>`, the wrong category for valid-but-overlong binop expressions. Split the 4+-arg arm: if args[1] is a known binop, emit `too many arguments`; otherwise stay on `condition expected:`. Test: `test_test_4args_with_binop_emits_too_many`.

### `[ "" "" ]` (two operands, no operator) silently returned 1

- zsh: `[ "" "" ]` -> `1: parse error: condition expected:` exit 2 (two operands without a connective is ill-formed). zshrs's 2-arg path only handled `[s1, s2]` for known string-comparator forms; the catch-all silently returned 1. Added a 2-arg arm that fires when neither operand is `-`-prefixed nor a paren, emitting zsh's parse-error diagnostic. Test: `test_test_two_operands_no_op_errors`.

### `autoload -X` (no function name) silently no-op'd

- zsh: `autoload -X` (no function name) -> `autoload:1: bad autoload` exit 1 — `-X` requires a function context. zshrs's loop set `execute_now=true` but the empty `functions` vec skipped both the listing branch (gated on `!execute_now`) AND the execute branch (no functions to load), returning 0 silently. Added an explicit `functions.is_empty() && execute_now` arm that emits zsh's diagnostic. Test: `test_autoload_X_no_function_errors`.

### `shift 5 a` on a 1-element array silently shifted what it could

- zsh: `a=(1); shift 5 a` -> `shift:1: shift count must be <= $#` exit 1 (the per-array bound is enforced). zshrs's array-shift loop iterated `for _ in 0..count` and just `arr.remove(0)`'d up to the array length, leaving partial state and returning 0. Added a precheck pass over array_names that compares `count > arr.len()` and errors before any mutation. Test: `test_shift_array_count_too_many_errors`.

### `jobs -Z` silently ignored the flag instead of erroring "requires one argument"

- zsh: `jobs -Z` (without a process-name arg) -> `jobs:1: -Z requires one argument` exit 1 (`-Z` sets the shell's process name; required arg). zshrs's `'Z' => {}` arm silently consumed the flag. Replaced with explicit error. Test: `test_jobs_Z_requires_argument`.

### `zformat` (no args) printed bare "zformat:" instead of zsh's prefixed format

- zsh: bare `zformat` -> `zformat:1: not enough arguments`. zshrs emitted `zformat: not enough arguments` (no shell-name or line-number prefix). Updated the eprintln to use `zshrs:zformat:1:`. Test: `test_zformat_no_args_zsh_format`.

### `[ a -lt ]` (operand + binop, missing right operand) silently returned 1

- zsh: `[ a -lt ]` -> `1: parse error: condition expected: a` exit 2. zshrs's 2-arg path for `[s, "-lt"]` had no explicit arm; fell through to the catch-all `1`. Added a 2-arg arm that triggers when args[0] is a non-flag operand AND args[1] is a known binop, emitting zsh's parse-error. Test: `test_test_two_args_binop_missing_operand`.

### `kill -0 1` printed bash-style "kill: 1: Operation not permitted (os error 1)" instead of zsh's lowercased format

- zsh: `kill -0 1` (no permission to signal pid 1) -> `kill:1: kill 1 failed: operation not permitted` (lowercased reason, no `(os error N)` suffix, `kill <pid> failed:` framing). zshrs emitted Rust's `Display` of the OS error verbatim. Reformatted to strip the `(os error …)` tail, lowercase the reason, and use zsh's `kill <pid> failed:` shape. Test: `test_kill_zero_failed_uses_zsh_format`.

### Function recursion overflowed the Rust stack instead of erroring "maximum nested function level reached"

- zsh: deep recursion is bounded by `FUNCNEST` (default 500) and errors `<name>: maximum nested function level reached; increase FUNCNEST?` exit 1. zshrs had NO enforcement — `foo() { foo; }; foo` and the builtin-shadow form `echo() { echo hi; }; echo hi` both crashed the process with `fatal runtime error: stack overflow`. Added the guard at both `call_function` (the hot path) and `dispatch_function_call` (the fallback). Cap is 100 by default (the bytecode VM is host-recursive at ~40KB/frame, so the 8MB Rust stack tops at ~150 frames; 100 leaves headroom). Users with deeper need can raise `FUNCNEST` AND `RUST_MIN_STACK`. Test: `test_funcnest_recursion_guard_no_overflow`.

### `[ "5" \> "3" ]` and `[ "5" \< "3" ]` silently returned 0 (string-compared) instead of erroring

- zsh's POSIX `[`-test does NOT accept `<` or `>` as string comparators — they're redirection operators outside `[`/`]`. `[ "5" \> "3" ]` -> `1: condition expected: >` exit 2. zshrs's match arms had `[a, "<", b]` and `[a, ">", b]` doing string compares (a bashism), hiding the syntax error. Replaced both arms with the zsh diagnostic. The `[[`-cond compiler still handles them as proper string comparators where they ARE valid. Test: `test_test_lt_gt_not_string_comparators`.

## Closed (eighty-third-pass)

### `setopt -h` (single-letter shortcut) errored "no such option" instead of accepting silently

- zsh: single-letter `-X` / `+X` flags on setopt are shortcuts for option names from the option-letter table — `setopt -h` is a no-op accepted silently (`h` maps to `hashcmds`). zshrs's default arm rejected ANY `-`-prefixed arg as an unknown option name. Added a `len() == 2 && (-|+)` short-circuit that accepts the single-letter form silently before the unknown-option check. Test: `test_setopt_single_letter_silent`.

### `fc -l 1 2 3` (3+ positional args) emitted "no events in that range" instead of "too many arguments"

- zsh: `fc -l N M` is a range query; 3+ positionals -> `fc:1: too many arguments` exit 1. zshrs's range path collapsed any 2+-arg case to `no events in that range`, missing the explicit count check. Added a `positional.len() > 2` arm before the existing `== 2` range arm. Test: `test_fc_l_3plus_args_too_many`.

### `type ""` (empty name) reported the first PATH entry as the resolved file

- zsh: `type ""` -> ` not found` exit 1. zshrs's PATH walker computed `dir + "/" + ""` which `std::path::Path::exists` reports as TRUE (the directory itself exists), falsely matching `type ""` to the first PATH entry. Skip the lookup entirely for empty names. Test: `test_type_empty_name_not_found`.

### `ulimit -f abc` (non-numeric value) silently dropped the value and printed "unlimited"

- zsh: `ulimit -f abc` -> `ulimit:1: invalid number: abc` exit 1. zshrs's `arg.parse().ok()` silently discarded non-numeric input, leaving `value` unset and printing the existing limit. Replaced with explicit `match arg.parse::<u64>()` that errors on miss. Test: `test_ulimit_invalid_number_errors`.

### `[ a == a ]` accepted `==` (bashism), masking POSIX `[`-test error

- POSIX `[`-test only accepts `=` for equality — `==` is the `[[`-cond extension. zsh: `[ a == a ]` -> `1: = not found` exit 1 (zsh's parser sees `==` and tries to look up the second `=` as a command). zshrs's match arm `[a, "=", b] | [a, "==", b]` accepted both. Split into separate arms: `[_, "==", _]` errors with the zsh diagnostic; `[a, "=", b]` continues to do string compare. The `[[`-cond compiler still handles `==` as a proper string comparator. Test: `test_test_double_equals_rejected`.

### `fc --help` reported "bad option: --" instead of "bad option: -h"

- zsh skips the leading `-` of long-option-style typos and reports the FIRST recognisable letter as the bad option: `fc:1: bad option: -h`. zshrs's loop hit the first char `-` of `--help` and reported `bad option: --` — wrong identifier. Added a `'-' => {}` arm that consumes the extra dash silently so the next char becomes the diagnostic target. Test: `test_fc_long_option_reports_first_letter`.

### `echo $((37#1))` and `echo $((2#5))` printed bogus 0 after the error

- zsh aborts the surrounding command on arith errors — `echo $((37#1))` (base out of range) and `echo $((2#5))` (digit out of range for the base) emit the diagnostic but do NOT print `0`. zshrs's evaluator returned `"0"` from the error arm and the caller continued to print it, so script consumers saw the diagnostic AND the bogus value. Added a `process::exit(1)` after the diagnostic when the message starts with "bad math expression" or "invalid base" (the canonical "give up" signals). Test: `test_arith_invalid_base_aborts_command`.

### `$((2#5))` silently produced 0 instead of erroring "operator expected at \`5'"

- zsh: `$((2#5))` (5 is not a valid binary digit) errors `bad math expression: operator expected at \`5'`. zshrs's `i64::from_str_radix(val_str, base).unwrap_or(0)` silently dropped the parse error, masking the typo. Replaced with explicit `match` that emits zsh's diagnostic when the digit is out of range for the declared base. Test: `test_arith_bad_digit_for_base_errors`.

### `wait ""` (literal empty arg) silently continued instead of erroring

- zsh: `wait ""` -> `wait:1: job not found:` exit 127 (treats empty as a failed job-spec lookup with empty identifier). zshrs's earlier "silent on empty" branch (added for the `wait $!` no-bg-job idiom) was too broad — `$!` defaults to "0" (a literal pid value), not "", so the silent-empty branch only handles the literal-`""` case which zsh actually errors on. Replaced with the zsh diagnostic. Test: `test_wait_empty_string_errors`.

### `read -d ""` panicked the shell on NUL-containing input

- zsh: `read -d ""` reads up to NUL; the captured value may contain NUL bytes. zshrs unconditionally called `env::set_var` which panics on NUL bytes (file-name validation), aborting the whole shell with a Rust backtrace. Guarded all `env::set_var` calls in `builtin_read` with a `processed.contains('\0')` check; NUL-containing values still update `self.variables` but skip the env export. Test: `test_read_d_empty_no_panic_on_nul`.

### `[ a := a ]` (made-up infix op) silently returned 1 instead of "condition expected: :="

- zsh: `[ a := a ]` -> `[:1: condition expected: :=` exit 2. zshrs's 3-arg arms only checked `-`-prefixed ops; non-`-`-prefixed operator-ish tokens at args[1] (`:=`, etc.) fell through every check. Added a 3-arg arm that fires when args[1] is non-`-`-prefixed AND contains a non-alphanumeric char AND isn't in zsh's known operator list. Test: `test_test_unknown_3arg_infix_op_errors`.

### `print -u 99 hello` printed to stdout instead of erroring "bad file number: 99"

- zsh: `print -u N` writes to fd N; if N isn't open, errors `print:1: bad file number: N` exit 1 with no output. zshrs's `let _ = fd` discarded the requested fd entirely and always wrote to stdout. Added an `fcntl(fd, F_GETFD)` precheck for fd ∉ {1,2}; closed fds emit zsh's diagnostic and exit 1 before the print runs. Test: `test_print_u_bad_fd_errors`.

### `kill -0 abc` printed bash-style "kill: abc: invalid pid" instead of zsh's "kill:1: illegal pid: abc"

- zsh: `kill -0 abc` (non-numeric pid) -> `kill:1: illegal pid: abc` exit 1. zshrs emitted bash-style `kill: abc: invalid pid` — no shell-name prefix, different wording. Updated the parse-error arm in the direct-PID branch. Test: `test_kill_illegal_pid_zsh_format`.

### `wait abc def` continued to def after first bad arg, exceeding zsh's diagnostic count

- zsh: `wait abc def` reports the first bad arg (`job not found: abc`) and STOPS — doesn't continue to `def`. zshrs's `continue` looped to the next arg, emitting two errors. Replaced `continue` with `return 127` so the first miss aborts the wait. Test: `test_wait_stops_after_first_bad_arg`.

### `vared -p` (no value after flag) errored "not enough arguments" instead of "argument expected: -p"

- zsh: `vared -p` (with no value) -> `vared:1: argument expected: -p` exit 1 — pinpoints the missing flag-value. zshrs's `if i + 1 < args.len()` guard silently dropped the flag and let the empty-var-name path emit the wrong diagnostic. Restructured `-p` and `-r` arms to error explicitly on missing value. Test: `test_vared_missing_value_after_flag_errors`.

### `history -d 99` reported "no such event: 1" instead of "no such event: 99"

- zsh: `history -d N` (or any numeric arg) reports the user's event ID in the no-such-event error. zshrs hardcoded `no such event: 1` regardless of the user's value. Threaded `count` through to the error path; non-default count is used as the event identifier. Test: `test_history_d_event_id_propagates`.

### `zstyle -X` silently fell through instead of erroring "invalid option"

- zsh: unknown zstyle flag -> `zstyle:1: invalid option: -X` exit 1. zshrs's `_ => {}` silent fallback let any unknown flag drop through to the set-style path with `pattern=-X` — silently registering junk styles. Replaced with explicit error. Test: `test_zstyle_unknown_flag_errors`.

### `bindkey -Z` silently dropped into list-mode instead of erroring

- zsh: unknown bindkey flag -> `bindkey:1: bad option: -Z` exit 1. zshrs's silent `_ => {}` fallback consumed any flag, falling through to list-mode (which printed the keymap silently). Replaced with explicit error. Test: `test_bindkey_unknown_flag_errors`.

### `zparseopts` (no args) silently returned 0 instead of erroring

- zsh: bare `zparseopts` -> `zparseopts:1: not enough arguments` exit 1. zshrs returned 0 silently. Added an early `args.is_empty()` check. Test: `test_zparseopts_no_args_errors`.

### `pwd extra arg` printed cwd instead of erroring "too many arguments"

- zsh: `pwd` only takes flags; positional args -> `pwd:1: too many arguments` exit 1. zshrs ignored positional args and printed cwd. Added a `positional_count` tally; non-zero errors before the cwd lookup. Test: `test_pwd_too_many_args_errors`.

### `umask 0Ab` reported "bad symbolic mode operator: 0" instead of "bad umask"

- zsh: `umask 0Ab` (digit-prefixed but not all-digits) -> `bad umask` exit 1 — the digit prefix is a strong signal of an attempted numeric mode that failed parsing. zshrs's symbolic walker treated `0` as the start of a class+operator parse and emitted `bad symbolic mode operator: 0` (wrong category). Extended the `looks_numeric` precheck to also catch digit-prefixed inputs. Test: `test_umask_digit_prefix_uses_bad_umask`.

### `fc -l 1 abc` reported "no events in that range" instead of "event not found: abc"

- zsh: in a range query, if either bound is non-numeric, errors `event not found: <text>` for that bound. zshrs's range path lumped non-numeric bounds into the generic `no events in that range` (wrong category — text-name miss vs out-of-range have distinct diagnostics). Added per-bound numeric checks before the range error. Test: `test_fc_l_two_args_non_numeric_errors`.

### `fc -r` and `fc -d` re-executed the previous command instead of erroring "would recurse endlessly"

- zsh: bare `fc -r` and `fc -d` (no positional) re-edit the prior command — which IS `fc` itself in `-c` mode, hence the recurse-endlessly abort. zshrs's recurse-guard required `args.is_empty()`, so `fc -r` (which has args=[`-r`]) slipped past and ran the previous command. Removed the `args.is_empty()` requirement; non-list-mode + no-positional + non-tty stdin is sufficient. Test: `test_fc_r_d_recurse_endlessly_aborts`.

### `[ -z "" -X x ]` (4-arg with unary flag + junk) reported "condition expected: -z" instead of "too many arguments"

- zsh: a 4-arg test with a known unary flag at args[0] (`-z`, `-n`, `-d`, etc.) followed by an operand and extra junk -> `[:1: too many arguments` exit 2 — the flag IS recognized; the count is the problem. zshrs's catch-all 4+arg arm only triggered "too many arguments" for known binops at args[1]; unary-flag layouts fell through to the generic "condition expected: -z" (wrong category). Extended the disambiguation arm with a `unary_flag_at_0` check covering zsh's full unary-test letter set. Test: `test_test_4args_unary_flag_too_many`.

### `jobs %1` (no jobs) silently produced no output instead of "no such job"

- zsh: `jobs %N` for an N that doesn't exist -> `jobs:1: %N: no such job` exit 1. zshrs's filter-by-id loop silently dropped non-matching ids and emitted nothing. Added a precheck that validates each requested id against the current job list before listing. Test: `test_jobs_unknown_id_errors`.

### `fg %999` and `bg %999` reported "no such job" instead of "no job control in this shell"

- zsh in `-c` mode has no real job-control regardless of the `monitor` option. `fg %N` / `bg %N` always error `<fg|bg>:1: no job control in this shell.` exit 1. zshrs's option-based check didn't work because the `monitor`/`interactive` options are default-on even in `-c` mode (zsh's option-display lies about job-control state). Switched to `atty::is(Stream::Stdin)` — real interactive shells have a tty on stdin, `-c` mode does not. Test: `test_fg_bg_no_job_control_in_script`.

### `fc -e` (no editor arg) ran the recurse-endlessly path instead of erroring "argument expected: -e"

- zsh: `fc -e` with no following editor arg -> `fc:1: argument expected: -e` exit 1. zshrs's `'e'` arm let the missing-arg case fall through (i+=1 with no bounds-check guard), so the loop ended with no editor set and the no-positional path triggered the recurse-endlessly diagnostic. Added an explicit error when `i >= args.len()` after the increment. Test: `test_fc_e_missing_editor_arg_errors`.

### `history -d -1` reported "no such event: 1" instead of "no such event: 0"

- zsh: a negative count (`-N` / `-d -N`) resolves to event 0 in count-from-end semantics with empty history. zshrs reported the absolute count value (1 for `-1`). Tracked an explicit-negative-count flag through the option-parse loop; the no-events path uses event_id=0 when set. Test: `test_history_d_negative_resolves_to_zero`.

### `history -S` silently dropped through to no-such-event instead of erroring "bad option: -S"

- zsh: `history -S` is a bash-only "save" flag that zsh's history (= `fc -l`) doesn't accept; -> `history:1: bad option: -S` exit 1. zshrs silently consumed the flag and emitted `no such event: 1` from the `-c` mode no-history path. Added `-S` to the bash-incompatibility list. Test: `test_history_S_bad_option`.

### `let "!"` reported "stack empty" instead of zsh's "operand expected" wording

- zsh's MathContext emits `bad math expression: operand expected at end of string` when a unary op has no operand. zshrs's bare `stack empty` had no match for scripts grepping zsh's canonical wording. Updated `src/math.rs` to emit the wrapped phrasing. Test: `test_let_unary_op_no_operand`.

## Closed (eighty-fourth-pass)

### `cd -- /tmp` errored "string not in pwd: --" instead of cd'ing to /tmp

- zsh: `--` is the end-of-options marker; everything after is positional. zshrs's substitution-form path treated `--` as the OLD arg of `cd OLD NEW` and errored. Added an explicit `--` consumer that flips an `after_dashdash` flag, after which tokens are pushed to positional_args verbatim. Test: `test_cd_dashdash_end_of_options`.

### `fc -p` errored "would recurse endlessly" instead of silent success

- zsh: `fc -p` (push history stack) is silent success in `-c` mode — it doesn't run the prior command. zshrs treated `-p` as a no-op flag, and the no-positional non-list-mode recurse-abort then fired. Track a `silent_no_op_flag` for `-p`/`-P`/`-a`/`-I`/`-L`/`-m` (their presence signals an explicit non-edit-form invocation); exempt these from the recurse-abort and short-circuit to silent return 0 when no positional. Test: `test_fc_p_silent_success`.

### `kill -s 0 $$` errored "invalid signal: 0" instead of accepting (existence check)

- zsh accepts numeric values to `-s`; `-s 0` is the existence-check form (same as `-0`). zshrs's `-s` arm was name-only and rejected `0` as an invalid signal name. Extended the `-s` arm with a numeric-fast-path: `0` triggers existence-check; other numeric values match against the signal_map by number; non-numeric falls back to name lookup. Test: `test_kill_s_zero_signal`.

### `unset _` errored "read-only variable: _" instead of accepting

- zsh: `_` (last-arg auto-update) is NOT intrinsic-readonly despite being a shell-internal special. Both assignment and `unset` are allowed. zshrs's intrinsic-readonly list incorrectly included `_`, so `unset _` errored. Removed `_` from both intrinsic-RO matches (BUILTIN_SET_VAR and `builtin_unset`). Test: `test_unset_underscore_allowed`.

### `bindkey -A nokm` (one arg) silently returned 0 instead of erroring

- zsh: `bindkey -A NEW EXISTING` requires two keymap names; with fewer than 2 -> `bindkey:1: not enough arguments for -A` exit 1. zshrs's `-A` stub returned 0 immediately without consuming arg(s) or validating count. Now consumes two iterator entries; on miss, errors with zsh's diagnostic. Test: `test_bindkey_A_requires_two_args`.

### `zstyle -T :foo style` errored "invalid option: -T" instead of accepting

- zsh: `-T` is "test style with default-true for unset" — like `-t` but returns 0 when style is unset. zshrs's unknown-flag fallback (added when fixing `-X` rejection) rejected `-T` as invalid. Added a dedicated `-T` arm and `-b`/`-a`/`-e`/`-m` accept-silently arms for the other zsh-extension flags zshrs doesn't fully wire up. Test: `test_zstyle_T_unset_default_true`.

### `trap "" RETURN` accepted but zsh rejects it

- zsh's actual runtime rejects `RETURN` as a signal name despite some documentation hints at it (the parser's `getsignum` doesn't include it). zshrs's known-sig allowlist included `RETURN`. Removed it from the list to match zsh's rejection. Test: `test_trap_return_undefined`.

### `print -u abc hi` printed to stdout instead of erroring "number expected after -u: abc"

- zsh: `-u N` requires a numeric fd; non-numeric -> `print:1: number expected after -u: <arg>` exit 1. zshrs's `unwrap_or(1)` silently dropped non-numeric input and printed to stdout. Replaced with explicit parse + error before the print runs. Test: `test_print_u_non_numeric_errors`.

### `fc -l x y z` reported "too many arguments" instead of "event not found: x"

- zsh: 3+ positionals where args[0] is non-numeric -> `event not found: <args[0]>` (text-name miss takes precedence over count-error). zshrs's >2-positional arm always emitted `too many arguments`. Added a numeric-check on args[0]; non-numeric routes to the event-not-found diagnostic. Test: `test_fc_l_3plus_text_first_arg_errors_event_not_found`.

### `zstyle X` (single non-flag arg) silently returned 0 instead of "not enough arguments"

- zsh: `zstyle PATTERN STYLE [VALUE...]` requires at least pattern+style (or a flag-form). `zstyle X` -> `zstyle:1: not enough arguments` exit 1. zshrs's set-style path required `args.len() >= 2` silently. Added an `args.len() == 1 && !args[0].starts_with('-')` precheck. Test: `test_zstyle_one_arg_not_enough`.

### `zformat -f result` (no format string) returned 1 silently

- zsh: insufficient args to `zformat -f` -> `zformat:1: not enough arguments` exit 1. zshrs returned 1 silently with no diagnostic. Added the eprintln before the return. Test: `test_zformat_f_too_few_args_errors`.

### `set --help` errored "can't change option: --" instead of treating as end-of-options

- zsh treats `--xxx` (long-option-style) on `set` as `--` (end-of-options); remaining args become positional. zshrs's per-char letter loop hit the leading `-` of `--help` first and errored "can't change option: --". Added a `--`-prefix short-circuit that consumes the rest of args as positional. Test: `test_set_long_option_treated_as_endmark`.

### `zstyle -g`/`-s`/`-t`/`-T` (insufficient args) returned 1 silently

- zsh: too-few-args to a zstyle get/test flag -> `zstyle:1: not enough arguments` exit 1. zshrs's branches returned 1 silently (no diagnostic). Restructured each arm to error explicitly before the indexing. Test: `test_zstyle_get_too_few_args_errors`.

### `shift ""` errored "shift count must be <= $#" instead of accepting as count 0

- zsh: `shift ""` treats empty arg as count 0 — silent no-op. zshrs's `chars().all(is_digit)` matched empty vacuously and the count defaulted to 1, then erred when positionals were short. Added an explicit empty-arg arm that sets count=0. Test: `test_shift_empty_arg_silent`.

### `exec -a` (no name following) errored generic "exec requires a command to execute" instead of "exec flag -a requires a parameter"

- zsh: `-a NAME` requires a name argument; no-following-arg -> `exec flag -a requires a parameter` exit 1 — pinpoints the missing flag-value, not the generic "no command" error. zshrs's flag walker bumped `i` without a bounds-check, then the missing-command branch emitted the generic diagnostic. Added an `i >= args.len()` check after the increment that emits zsh's specific message. Test: `test_exec_a_requires_parameter`.

### `bindkey -d` errored "bad option: -d" instead of accepting silently

- zsh: `bindkey -d` resets all keymaps to defaults — silent success. zshrs's unknown-flag fallback (added when fixing `-Z` rejection) rejected `-d` as bad. Added a dedicated `-d` arm. Test: `test_bindkey_d_resets_keymaps`.

### `history ""` reported "no such event: 1" instead of "event not found:"

- zsh: `history ""` (empty positional) -> `fc:1: event not found:` (with empty trailing identifier). zshrs's all-digits arm matched the empty string vacuously, defaulted count to 20, and the no-events branch reported `no such event: 1`. Added a `!s.is_empty()` guard on the digit-only arm so empty strings route to the search-query path instead. Test: `test_history_empty_arg_event_not_found`.

### `read -u abc` and `read -u` (missing/non-numeric fd) silently dropped to fd 0

- zsh: `read -u abc` -> `read:1: number expected after -u: abc`; `read -u` (no arg) -> `read:1: argument expected: -u`. zshrs's `unwrap_or(0)` dropped both error paths silently and read from fd 0. Replaced with explicit parse + bounds checks. Test: `test_read_u_non_numeric_or_missing`.

### `kill -l -X` reported "unknown signal: SIG-X" instead of "SIGX"

- zsh strips the leading `-` (in addition to the `SIG` prefix) of unknown signal names: `kill -l -X` -> `unknown signal: SIGX`. zshrs preserved the `-` and reported `SIG-X` (visually weird). Added a `trim_start_matches('-')` to the lookup path. Test: `test_kill_l_dash_prefix_strips`.

### `kill -n abc` printed bash-style "kill: invalid signal number: abc" instead of zsh's "kill:1: invalid signal number"

- zsh: `kill -n abc 1` -> `kill:1: invalid signal number: abc` exit 1. zshrs emitted bash-style `kill: invalid signal number: abc` — no shell-name or line-number prefix. Updated both error paths in the `-n` arm. Test: `test_kill_n_invalid_signal_zsh_format`.

### `alias -gs foo=bar` accepted both flags silently instead of erroring "illegal combination of options"

- zsh: `-g` (global alias) and `-s` (suffix alias) are mutually exclusive — an alias is either global OR suffix, not both. `alias -gs foo=bar` -> `alias:1: illegal combination of options` exit 1. zshrs's flag walker set both flags and continued. Added a post-parse check that fires before the alias action. Test: `test_alias_g_s_mutually_exclusive`.

### `kill 999999999` printed "kill: ESRCH: No such process" instead of zsh's "kill PID failed: no such process"

- zsh: `kill PID` for a non-existent PID -> `kill:1: kill PID failed: no such process` exit 1 with the OS error reason lowercased and stripped of errno-code framing. zshrs emitted Rust's `nix::errno::Errno::Display` form `kill: ESRCH: No such process` — leaks the errno symbol. Reformatted the send_signal error path with the same lowercased + last-colon-segment cleanup used in `kill -0`. Test: `test_kill_unknown_pid_zsh_format`.

### `typeset -A h; h=(a 1 b)` (odd k/v count) silently dropped the orphan key

- zsh: assoc-init with an odd number of values -> `bad set of key/value pairs for associative array` exit 1, no assignment. zshrs's `if let Some(v) = it.next()` silently dropped the orphaned key, leaving the assoc partially populated. Added an explicit `values.len() % 2 != 0` precheck. Test: `test_assoc_odd_kv_pairs_errors`.

### `disown %999` and `disown -l`/`-h` silently returned 0 instead of erroring

- zsh: `disown %N` for non-existent N -> `disown:1: %N: no such job` exit 1; `disown -l`/`-h` are NOT recognized flags (treated as job specs) -> `disown:1: job not found: -l` exit 1. zshrs's flagless impl emitted bash-style `disown: -l: no such job` and silently dropped non-existent ids. Restructured the loop: `%N` strips and validates against jobs; numeric routes to `%N: no such job`; non-numeric (including `-l`) errors `job not found:`. Tests: `test_disown_unknown_jobspec_errors`, `test_disown_dash_flag_treats_as_jobspec`.

### `zstyle -` errored "invalid option: -" instead of "not enough arguments"

- zsh: bare `-` (no recognized option letter) -> `zstyle:1: not enough arguments` (degenerate flag-only invocation). zshrs's catch-all unknown-flag fallback emitted `invalid option: -`. Added a dedicated `-` arm. Test: `test_zstyle_dash_only_not_enough_args`.

### `fc -t` (no time-format arg) ran the recurse-endlessly path instead of erroring "argument expected: -t"

- zsh: `-t TIMEFMT` requires a format string; missing -> `fc:1: argument expected: -t` exit 1. zshrs's `'t'` arm bumped `i` without bounds-check; the loop ended with no positional and the recurse-abort fired. Added an `i >= args.len()` check that emits zsh's specific diagnostic. Test: `test_fc_t_missing_arg_errors`.

### `functions FOO` (FOO undefined) errored "no such function: FOO" instead of silent

- zsh: `functions FOO` for non-existent FOO emits nothing and returns 0 (it's a query, not an enforce). zshrs erred `no such function: FOO`. Replaced the error with `continue` so unknown names skip silently. Test: `test_functions_unknown_silent`.

### `kill -- 999` treated `--` as a signal name and errored "unknown signal: SIG-"

- zsh: `--` is end-of-options on `kill`; subsequent args are PIDs. zshrs's catch-all `-X` flag walker parsed `--` as `-` (separator) then `-` as the signal name, errored `unknown signal: SIG-`. Added an `after_dashdash` flag at the top of the parse loop that switches to PID-collection mode. Test: `test_kill_dashdash_end_of_options`.

## Closed (eighty-fifth-pass)

### `fc ""` triggered infinite recursion (re-executed `fc ""` until stack overflow)

- zsh: `fc ""` -> `fc:1: event not found:` exit 1 (no match, no prior-command execution). zshrs's prefix-match found the most recent history entry (which is `fc ""` itself) and recursively re-executed it, triggering stack overflow. Added an `arg.is_empty()` fast path before the prefix search that emits zsh's diagnostic and exits 1. Test: `test_fc_empty_string_no_recursion`.

### `umask z=r` reported "invalid mask: z=r" instead of "bad symbolic mode operator: z"

- zsh: `umask z=r` -> `umask:1: bad symbolic mode operator: z` exit 1 (treats unknown class char as the operator-position diagnostic, distinct from "bad permission" for unknown rwx chars). zshrs's `_ => ok=false` collapsed all class errors to the generic `invalid mask:`. Tracked `bad_class: Option<char>` in the per-class loop; on hit, emits zsh's specific diagnostic. Test: `test_umask_bad_class_char`.

### `cd /tmp /etc /usr` (3+ args) silently used args[0] as target

- zsh: `cd ARG1 ARG2 ARG3` -> `cd:1: too many arguments` exit 1 (cd takes at most 2 args; the substitution form OLD NEW). zshrs's two-arg substitution path silently fell through with extras. Added a `positional_args.len() > 2` check before the path_arg lookup. Test: `test_cd_3plus_args_too_many`.

### `[ -z -n a ]` reported "unknown condition: -n" instead of "too many arguments"

- zsh: `[ -z -n a ]` (unary-flag + unary-flag + arg layout) -> `[:1: too many arguments` exit 2 — `-z OPERAND` is the 2-arg form; the extra `a` is the surplus. zshrs's unknown-binop arm fired first and reported `unknown condition: -n` (wrong category — `-n` IS a recognized unary flag, just misplaced). Added a 3-arg arm checking for the unary+unary+arg layout BEFORE the unknown-binop check. Test: `test_test_3args_unary_unary_arg_too_many`.

### `shift -X` silently treated `-X` as an array name

- zsh: `shift -X` (unknown flag besides `-p`) -> `shift:1: bad option: -X` exit 1. zshrs's catch-all pushed the flag string into array_names, masking typos and trying to shift a non-existent array `-X`. Added a `starts_with('-') && len > 1` arm BEFORE the array-name fallback that emits zsh's diagnostic. Test: `test_shift_unknown_flag_errors`.

### `print -f` (no format string) silently fell through with no format set

- zsh: `-f` requires a format string; missing -> `print:1: argument expected: -f` exit 1. zshrs's `if i < args.len()` silently fell through, leaving format unset and proceeding as if `-f` weren't present. Added an `i >= args.len()` check that emits zsh's specific diagnostic. Test: `test_print_f_missing_arg_errors`.

### `ulimit -l` (locked memory) errored "bad option: -l" instead of returning the limit

- zsh: `-l` queries the locked-memory limit (RLIMIT_MEMLOCK on Linux; "unlimited" on macOS where the kernel doesn't enforce). zshrs's flag-letter table didn't include `-l`. Added a `-l` arm that maps to RLIMIT_AS as a safe stand-in for the get-only path (real RLIMIT_MEMLOCK could be wired with cfg(target_os="linux") later). Test: `test_ulimit_l_accepted`.

### `read -d` (no delimiter arg) silently used default newline delimiter

- zsh: `-d` requires a delimiter argument; missing -> `read:1: argument expected: -d` exit 1. zshrs's bounds-less `i+=1` left delimiter at default and continued reading. Added an `i >= args.len()` check that emits zsh's specific diagnostic. Test: `test_read_d_missing_arg_errors`.

### `kill -s ""` (empty signal name) reported "invalid signal:" with trailing space

- zsh: `kill -s ""` -> `kill:1: -: signal name expected` exit 1. zshrs's name lookup of empty matched no signals and produced `invalid signal: ` (with trailing whitespace). Added an early-empty arm that emits zsh's specific diagnostic. Test: `test_kill_s_empty_name_errors`.

### `let "1?"` reported "':' expected" instead of distinguishing operand-missing from colon-missing

- zsh distinguishes `let "1?"` (input ran out mid-ternary, no operand AND no colon) from `let "1?2"` (operand present, colon missing). Former -> `bad math expression: operand expected at end of string`; latter -> `bad math expression: ':' expected`. zshrs's earlier `':' expected` for both was wrong-category for the missing-operand case. Added a stack-length check after the inner mathparse: stack grew → operand parsed (colon-expected); stack same → no operand (operand-expected). Test: `test_let_ternary_missing_colon_vs_operand`.

### `umask -X` printed the current umask instead of erroring "bad option: -X"

- zsh: `umask -X` -> `umask:1: bad option: -X` exit 1. zshrs's silent `_ => {}` arm accepted any flag and proceeded to print/set the umask. Added a `starts_with('-') && len > 1` arm that emits zsh's diagnostic. Test: `test_umask_unknown_flag_errors`.

### `fc 1 2 3 4 5 6` (edit mode, 3+ positionals) ran prefix search instead of erroring "too many arguments"

- zsh: edit-mode fc takes at most 2 positional bounds (`fc FIRST [LAST]`); 3+ -> `fc:1: too many arguments` exit 1. zshrs's edit path took args.first() and ignored the rest, falling into the prefix-search path. Added a `positional.len() > 2` precheck. Test: `test_fc_edit_too_many_args_errors`.

### `fc -l 1 abc 2` (text-name in middle) reported "too many arguments" instead of "event not found: abc"

- zsh: text-name miss takes precedence over count-error in the 3+arg fc-list path. zshrs only checked `positional[0]` — if the non-numeric was in the middle, "too many arguments" fired instead. Extended the scan to find the FIRST non-numeric across all positions. Test: `test_fc_l_3plus_text_in_middle`.

### `history -d 1 2` and `history -d 1 2 3` reported "no such event: N" instead of zsh's range/too-many wording

- zsh: history (= `fc -l`) — 2 numeric positionals -> `fc:1: no events in that range`; 3+ -> `fc:1: too many arguments`. zshrs's loop just kept overwriting `count` with each numeric arg and reported `no such event: <last>` regardless. Track `positional_count`; check >2 (too many) and ==2 (range) BEFORE the no-such-event path. Test: `test_history_d_multi_args_error_categories`.

### `[ -e /tmp 5 ]` (unary flag + operand + extra) silently returned 1 instead of erroring "too many arguments"

- zsh: `[ -FLAG OPERAND EXTRA ]` -> `[:1: too many arguments` exit 2 — the parse expected `-FLAG OPERAND` (2-arg form), so the extra arg is the surplus. zshrs's 3-arg arm only matched flag-FLAG-arg layouts (`-z -n a`); the flag-operand-extra layout fell through to the catch-all `1`. Loosened the check: any 3-arg with a known unary flag at args[0] is "too many arguments". Test: `test_test_3args_unary_op_extra`.

### `fc 1 5` (2 numeric positionals, edit form) reported "event not found: 1" instead of "would recurse endlessly"

- zsh: edit-mode `fc N M` re-edits commands N..M; with empty -c session, that's the recurse-endlessly path. zshrs's prefix-search used `N` and reported `event not found: N` (wrong category for the range-edit form). Added a `positional.len() == 2 && both_numeric` precheck that emits zsh's recurse diagnostic. Test: `test_fc_2_numeric_positionals_recurse`.

## Closed (eighty-seventh-pass — C-source-driven port)

### `${${a:l}:r}` nested expansion now applies outer modifier/replace to inner result

Direct port of zsh's hist.c modifier dispatch and substitution dispatch for `${${...}:MOD}` / `${${...}/pat/repl}`. zshrs's nested-expansion handler at line 13673 only checked for `[idx]` subscript after the inner closing brace; modifier chains (`:r`, `:t`, `:h`, `:e`, `:l`, `:u`, `:s/...`) and replace operators (`/pat/repl`, `//pat/repl`) were silently dropped.

Examples now matching zsh:
- `${${a:l}:r}` for `a=Hello.World` → `hello` (lowercase then strip extension)
- `${${(j: :)a}:r}` for `a=(file.txt other.csv)` → `file.txt other` (join then strip last extension)
- `${${a}//l/L}` for `a=hello` → `heLLo` (global replace)
- `${${HOME}/wizard/USER}` → `/Users/USER` (single replace in indirect)

The fix also corrects an `is_history_modifier` call site issue — the helper checks the FIRST char (so `:r` returns false because `:` isn't in the recognized list); strip the leading `:` before testing.

Tests: `test_nested_expansion_modifier_chain`, `test_nested_expansion_replace`.

### `local arr=( "a b" c )` quote-aware split — preserves whitespace inside quoted elements

zshrs's typeset/declare/local array path naively `split_whitespace()`'d the body, breaking quoted strings: `local arr=( "x y" z )` produced 3 elements `[x, y, z]` instead of zsh's 2 elements `["x y", z]`. Direct port of zsh's lex.c word-splitting for assignment RHS via a quote-aware scanner that honors `"..."`/`'...'` boundaries (and strips the quote chars from the result).

Limitation: `"$@"` splicing inside `local arr=( "$@" )` still doesn't work — the splice happens BEFORE this path sees the args, and the parser's `$@`-as-words mechanism only fires for the regular `arr=(...)` (non-typeset) compile path. Tracked separately.

Test: `test_typeset_array_quote_aware_split`.

### Function-scope EXIT trap fires on function return, preserves outer trap

Direct port of zsh's exec.c `dotrapargs(SIGEXIT, ...)` deferred-fire pattern. An EXIT trap set INSIDE a function fires when the function RETURNS (not when the shell exits), and the outer EXIT trap is preserved across the call. zshrs's `call_function` didn't track function-scope traps, so:
- `foo() { trap "echo X" EXIT; }; foo; echo done` either fired X at SHELL exit (if no outer trap) or polluted the outer `traps["EXIT"]`.
- An outer `trap "echo OUTER" EXIT; foo() { trap "echo INNER" EXIT; }; foo` overwrote OUTER with INNER, so OUTER never fired at shell exit.

Fix:
1. Before function body: save current `traps["EXIT"]` into a local; remove it so an outer trap doesn't fire prematurely.
2. After function body: pull the (possibly newly-set) EXIT trap, fire it now (script-pipeline) with the function's `last_status` in scope, restore the saved outer trap.

Tests: `test_function_scope_exit_trap_fires_on_return`, `test_function_scope_exit_trap_preserves_outer`.

### `return` with no arg now uses live `vm.last_status`, not stale `executor.last_status`

zsh's bin_return (Src/builtin.c) returns the status of the most recently executed command when no arg is given — `foo() { false; return; }` returns 1, not 0. zshrs's `builtin_return` read `self.last_status`, which is the EXECUTOR's view; that value only gets synced from the VM at statement boundaries, so during the `return` builtin it was stale (held the value from BEFORE the function call started, typically 0).

Direct port of the existing pattern used by other status-sensitive builtins: read `vm.last_status` (the live value) at the registration site and sync it into `exec.last_status` before delegating to `builtin_return`.

Test: `test_return_no_arg_uses_last_status`.

### `${a[@]:1:$((2+0))}` array slice with arith length now slices array elements (was char-slicing joined value)

zshrs's `BUILTIN_PARAM_SUBSTRING_EXPR` (the arith-expr variant of `${var:N:M}`) treated all names as scalars — it called `get_variable(name)` and char-sliced the result. For `a[@]`, that gave the IFS-joined string "a b c d e", and char-slicing position 1 with length 2 returned " b" (space + 'b'), not zsh's "b c" (elements 2-3 of the array).

The integer-only sibling `BUILTIN_PARAM_SUBSTRING` already had array-aware dispatch (strip `[@]`/`[*]`, lookup as array, slice elements). Direct port of that logic into the expr variant: `force_array` flag from suffix detection routes through `slice_array_zero_based` for the array case, or `slice_positionals` for `${@:N:M}`.

Test: `test_array_slice_with_arith_length_expr`.

### Glob qualifier `*(L+N)` size check now uses lstat for symlinks (matches zsh)

Direct port of zsh's pattern.c L qualifier. zsh uses lstat-based size — for a symlink, that's the LENGTH OF THE SYMLINK STRING (e.g. 9 bytes for "empty.txt"), NOT the target's size. zshrs's prefetched metadata had both followed (`m`) and symlink (`sm`) variants; the L qualifier was reading `m.len()` which gave the target size, so a symlink to an empty file appeared empty (size 0).

Fix: prefer the symlink metadata `sm` when present, fall back to `m`. Now `*(L+0)` correctly includes symlinks (their path-string length is always > 0); `*(L0)` correctly excludes them.

Test: `test_glob_qualifier_size_uses_lstat_for_symlinks`.

### Glob qualifier `*(:r)` — history modifiers applied per match

Direct port of zsh's pattern.c qualifier modifier handling. `*(:r)` strips the extension from each match (`a.txt` → `a`); `*(:t)` keeps only the basename; `*(:e)` returns the extension. zshrs's `filter_by_qualifiers` had no `:` arm, so modifiers fell through to the catch-all and were ignored. Fix: detect `:` in the qualifier scan, consume the rest of the qualifier list as a modifier chain, and apply via the existing `apply_history_modifiers` helper to each match.

Test: `test_glob_qualifier_history_modifier`.

### Glob qualifier `*(.,/)` — top-level `,` is OR (clause alternation)

Direct port of zsh's pattern.c qualifier parsing. zshrs's `filter_by_qualifiers` chained all qualifier filters with AND, so `*(.,/)` (files OR dirs) errored "no matches found" because no file is BOTH a regular file AND a directory. Fix: detect a top-level `,` (honoring `[...]`/`(...)` nesting), split the qualifier into clauses, run the full AND filter on each clause, UNION the results in original-file order with dedup. Single-clause path is unchanged. Verified against zsh: `*(/,@)` (dirs OR symlinks), `*(.,/)` (files OR dirs) both produce union output.

Test: `test_glob_qualifier_comma_or`.

### `zshrs -f +o nomatch -c '...'` now parses `-o`/`+o NAME` like zsh's main.c arg loop

zshrs previously rejected `+o nomatch` with "+o: No such file or directory" — neither `+o` nor the option name was in the recognized-flags filter, so `+o` fell through to the "treat as script file" path. Direct port of zsh's main.c arg-parse loop: collect `-o NAME` (set option) / `+o NAME` (unset option) pairs before the filter; apply them in `apply_cli_flags` as `executor.options.insert(name, value)`.

The `no` prefix is part of the canonical option name (`nomatch`, `noglob`, `nounset`) for setopt/unsetopt purposes — only `[[ -o ... ]]` query canonicalization strips it. Mirror by storing verbatim (just lowercased + `_`/`-` separator-stripped). User-reported regression: `echo *(/.)` errored "no matches found" with `nomatch` still on, instead of leaving the literal alone with `+o nomatch`.

Test: `test_cli_o_flag_sets_options`.

### `/etc/(passwd|hostname)` glob alternation at the path level — primary zsh feature

Direct port of zsh's pattern.c `P_BRANCH` `|` handling at the path level. `/etc/(passwd|hostname)` matches paths where the last component is `passwd` OR `hostname` (no extendedglob required, unlike `~` exclusion). zshrs's compile path didn't list `(...|...)` as a glob trigger, so the parens reached the OS as literal chars and produced no match.

Two-part fix:
1. `compile_zsh`: `unquoted (` + `|` + `)` triggers the glob path alongside `*`/`?`/`[`. Without this, `echo /etc/(passwd|hostname)` was emitted as a literal arg.
2. `expand_glob`: pre-expand top-level `(...|...)` into multiple alternatives via a new `expand_glob_alternation` helper that respects `[...]`/`(#...)` nesting. Each alternative is recursively globbed (so mixed literal + glob like `(a|b)*` works); literal alternatives that don't exist on disk are filtered out (zsh: alternation produces matching paths, not literal alternatives). Final result deduped and sorted (zsh's lexicographic glob result order).

Test: `test_glob_alternation_at_path_level`.

### `arr=( $(echo "a:b:c") )` with `IFS=:` now produces 3 elements (was collapsing to 1)

zshrs's compile path was emitting `BUILTIN_WORD_SPLIT` TWICE for `arr=( $(...) )` — once inside `compile_word_str` for the unquoted `$()` AND once in the array-element loop in `compile_assign`. The first split correctly produced a `Value::Array(["a", "b", "c"])` from the IFS-split. The second split called `vm.pop().to_str()` on that Array, which joined-with-space ("a b c"), then split that on IFS=":" — finding no `:` chars, returning a single element. Final result: `arr=("a b c")` (1 element) instead of `arr=(a b c)` (3 elements).

Fix: bump `assign_context_depth` for the duration of each `compile_word_str(elem)` call in the Array branch, so the inner `compile_word_str`'s WORD_SPLIT is suppressed (`!in_dq && !in_assign` becomes false). The outer loop's WORD_SPLIT then runs once per element, correctly.

Test: `test_array_assign_with_cmd_subst_ifs_split`.

### Subscript flags `(w)N` (word index) and `(s/sep/)N` (no-op for `[N]`) now recognized

Direct port of zsh's zshparam(1) "Subscript Flags". `parse_subscript_flags` rejected anything outside `r/R/i/I/e/k/n` so `(w)2` and `(s/l/)2` were treated as bogus subscripts and routed to the math evaluator (which then failed on `(w)2`).

- `(w)N` on a scalar splits by IFS (whitespace) and returns the Nth word; on an array, equivalent to `[N]` since the value is already split.
- `(s/sep/)N` is a NO-OP for scalar `[N]` integer indexing per zsh's actual behavior — verified by spot-check: `a=hello; ${a[(s/l/)1]}` returns `h`, same as `${a[1]}`. The `(s)` flag only affects word-list contexts (`${(s/sep/)var}` without index, or `[@]` form). Strip the flag, parse the index normally, fall through to char slicing.

Tests: `test_subscript_w_flag_word_index`, `test_subscript_s_flag_is_noop_for_int_index`.

### `${a//(#i)L/X}` now honors inline pattern flags (case-insensitive replace)

Direct port of zsh's pattern.c — inline pattern flags `(#i)` / `(#l)` / `(#I)` apply to the replacement operator too, not just `[[ ... = pat ]]`. The same `parse_pattern_flags` helper that glob_match_static uses now runs before BUILTIN_PARAM_REPLACE compiles its regex; `(#i)` adds the `(?i)` regex prefix so the match is case-insensitive.

zshrs previously left `(#i)L` as literal regex chars (no match) so `${a//(#i)L/X}` for `a=hello` returned `hello` instead of `heXXo`.

Test: `test_param_replace_case_insensitive_inline_flag`.

### `IFS=: read x y <<< "a:b:c"` now puts `b:c` in `y` (separator preserved in remainder)

Direct port of zsh's bin_read in builtin.c. When input has more fields than vars, the last var receives the unsplit REMAINDER from the position after the (N-1)th separator — meaning the separator chars between fields N..end are PRESERVED literally. zshrs previously split into a `Vec<&str>` and `join(" ")`d, collapsing all separators to spaces (so `IFS=: read x y <<< "a:b:c"` produced `y="b c"` instead of zsh's `y="b:c"`).

Also fixed: the default IFS in zshrs is `" \t\n\0"` (with NUL). The new "whitespace IFS" check (`is_whitespace_ifs`) initially used `is_ascii_whitespace()` which excludes NUL, so the default-IFS path's collapse-runs + strip-boundaries semantics never fired. Added `c == '\0'` to the whitespace-class check.

Implementation: scan the input for the (N-1)th separator boundary, slicing the original string at that point — head goes to the first N-1 vars (split on IFS, preserving empties for non-whitespace IFS), tail goes verbatim to the Nth var. For whitespace IFS, leading and trailing whitespace are trimmed first (so `read x y <<< "  a  b  c  "` produces `x=a y="b  c"`).

Tests: `test_read_preserves_separator_in_last_var`, `test_read_collapses_default_ifs`.

### `"${a/o/O}"` array replace now joins-then-replaces in DQ; `"${a[@]/o/O}"` per-element

Mirror of the array strip DQ split applied to the replace operator. zsh's pattern.c routes through getmatch (joined scalar in DQ) vs getmatcharr (per-element otherwise) for both strip and replace. zshrs's BUILTIN_PARAM_REPLACE always per-element-replaced for arrays. Fix: pass `dq_context_depth` from compiler as a 5th arg; when set AND the var is array, join via space then apply the replace once. `had_at` field on `ParamModifierKind::Replace` overrides DQ for explicit `[@]` subscript (forcing per-element). `[*]` keeps the bare-DQ semantics.

Tests: `test_dq_array_replace_join_first`, `test_dq_array_replace_at_subscript_per_element`.

### `$(([#16]255))` output radix in arith — `[#N]` adds `N#` prefix, `[##N]` drops it

Direct port of zsh's math.c output-radix handling (lines 786-832 in patcompswitch's `[` case): single-`#` form keeps the `N#` prefix on the result, double-`##` drops it. Base must be 2..=36. zsh's convbase (Src/params.c:5586) special-cases base==10 to skip the prefix entirely (matches `[#10]42` → `42`).

zshrs previously left `[#16]255` as a literal in the math expression, so the `$(())` expansion either parsed it as a subscript or errored. Added a prefix-strip + radix-capture step in `evaluate_arithmetic` BEFORE handing off to `MathEval`, then formatted the integer result with `format_int_in_base` honoring the no-prefix flag.

Test: `test_arith_output_radix_with_prefix`.

### `integer -i N name=val` for output radix; `trap -p` rejection (zsh-compat)

- **`integer -i 16 x=255`** now stores 255 with attribute base=16, so `echo $x` prints `16#FF` per zsh's typeset -i semantics (Src/builtin.c). zshrs's previous arg loop treated each flag arg as a single token (`-i` only), so the next arg `16` fell into the assignment-or-name path and triggered "not an identifier: 16" since `1` is a digit. Direct port: when `-i` is followed by an all-digit arg (separate or same-token), consume it as the base. Same logic works for both `-i 16` and `-i16`.
- **`trap -p EXIT` now rejects** the bash-style `-p` flag — zsh's bin_trap (Src/builtin.c:7347) doesn't recognize it. Without the `-p` shortcut, the dispatch treats `-p` as the action arg, fails the action+signal requirement, and the shell falls through to "command not found: -p" — matches zsh exactly. Removed the bash-compat `-p` block.

Tests: `test_integer_dash_i_with_base_arg`, `test_trap_dash_p_not_a_flag`.

### Array `"${a%%pat}"` joined-then-stripped (DQ); `"${a[@]%%pat}"` per-element; brace {% raw %}`{{1..3},x,y}`{% endraw %} nested

Three closely-related fixes from the systematic differential audit. All driven by reading zsh source rather than guessing from output.

- **DQ-context array strip** (zsh subst.c: getmatch vs getmatcharr split): `"${a%%pat}"` for an array `a` joins via `$IFS` first, then strips the joined scalar — direct port of `getmatch` in pattern.c which operates on a single string. zshrs's BUILTIN_PARAM_STRIP fast path bypassed the BUILTIN_EXPAND_TEXT wrapper that bumps `exec.in_dq_context`, so the runtime DQ check was always 0 and the strip went per-element. Fix: pass `dq_context_depth` from the compiler as a 4th arg to BUILTIN_PARAM_STRIP, and bump `dq_context_depth` for the duration of `emit_param_modifier` when the raw word is DNULL-wrapped (mirrors the existing segments-loop bump).
- **Explicit `[@]` subscript override** (zsh subst.c: nojoin/spbreak path): `"${a[@]%%pat}"` forces per-element strip even inside DQ — `[@]` marks the array as splice-expanded. `parse_param_modifier` previously stripped the `[@]` from the name and lost the info. Added `had_at: bool` to `ParamModifierKind::Strip` so the emitter can override DQ to per-element when present. `[*]` (join-with-IFS) keeps the bare-DQ join-then-strip semantics.
- **Extendedglob `~` exclusion** (zsh pattern.c: P_EXCLUDE handling, line 155): `pat1~pat2` matches strings matching pat1 AND NOT matching pat2. Direct port of the top-level case via a new `find_top_level_tilde` helper that honors `[...]` and `(...)` nesting per the canonical scan.
- **Nested brace list with sequence** (zsh: brace-expansion is a token-balanced scan): {% raw %}`{{1..3},x,y}`{% endraw %} is a LIST at the top level that happens to contain a `..` sequence inside one of its commas. The previous detector preferred `..` over `,`, miscategorizing the outer braces as a sequence. Fix: scan for top-level `,` (depth-0) first; only fall through to sequence detection when no top comma is present.

Tests: `test_dq_array_strip_joins_scalar`, `test_dq_array_strip_at_subscript_per_element`, `test_extglob_tilde_exclusion`, `test_brace_nested_sequence_in_list`.

### Glob bracket `[!...]` / `[^...]` negation, POSIX class containment, extendedglob `pat#`/`pat##` postfix

Direct port of zsh's pattern.c — the canonical pattern-class compile (`patcompcls` in pattern.c) and the POUND/POUND2 postfix cases in `patcompswitch`. zshrs's hand-rolled glob→regex translator had three independent bugs that all affected pattern matching:

1. **`[!a]bc` matched `abc`.** zshrs copied `!` verbatim into the regex `[!a]bc`, which regex reads as "either `!` or `a`, then `bc`" — so any string starting with `a` matches. zsh's negation rule (POSIX `[!...]` ≡ regex `[^...]`) wasn't translated. Fix: when `!` is the first char inside `[...]`, emit `^` instead.

2. **`[![:digit:]]*` failed.** The bracket scanner stopped at the first `]`, so `[![:digit:]]` was misread as `[![:digit:]` (no closing). Fix: detect POSIX class `[:NAME:]` inside the bracket scan and walk past `:]` as a unit, so the next `]` after the class isn't taken as the outer close.

3. **`a##` / `[[:digit:]]##` left literal.** zshrs's translator pushed `a` then dropped the `##` postfix entirely. zsh's extendedglob `pat#` = zero-or-more (regex `*`), `pat##` = one-or-more (regex `+`). Direct port: after each atom (literal char, `?`, `[...]`, `(...)`), peek for `#`/`##` and emit `*`/`+` if extendedglob is set.

Tests: `test_glob_bracket_negation_with_bang`, `test_glob_posix_class_with_negation`, `test_extglob_one_or_more_postfix`, `test_extglob_zero_or_more_postfix`.

### `$LINENO` was always `1` — never incremented per line, never reset on function entry

- Two compounding bugs uncovered while porting zsh's `lineno` global from Src/input.c (line 330) and Src/init.c (line 1588). Filed by the iter-86 audit.

- **Bug 1 (lexer): hungetc/getc lineno asymmetry on `\n`.** zshrs's `getc` incremented `self.lineno` on `\n` reads from FRESH input, but the unget_buf re-read path bypassed that increment. `hungetc` correctly decremented on putback. Net: every newline ungetted once and re-read leaves `lineno` permanently one short. The parser ungets the inter-statement newline once between top-level statements, so `lineno` was stuck at 1 for every line of input. Fix: increment `lineno` in the unget_buf branch of `getc` symmetrically to the fresh-input path.

- **Bug 2 (compiler): no SET_LINENO emit, no function reset.** Even with the lexer tracking fixed, nothing wired the captured per-pipe `lineno` to a runtime `$LINENO` variable. Added a new `BUILTIN_SET_LINENO` (id 342) that writes the popped int to the variable table, and emit one call per top-level pipe in `compile_list`. For function bodies, set `ZshCompiler.lineno_offset = first_body_line - 1` so the emitted values are 1, 2, 3 relative to the body — direct port of zsh's `lineno = 1` reset on function entry at Src/init.c:1588.

- ID collision found: `BUILTIN_HAS_STICKY` and `BUILTIN_SET_LINENO` were both u16=326 (the file has two more duplicate IDs at 325 — `FILE_OLDER` and `IS_TTY`). The HAS_STICKY register at line 4848 silently overwrote the SET_LINENO handler at line 4491, masking the runtime call entirely. Picked id=342 (next clean slot above 341) and noted the broader duplicate-ID problem in the const's docstring for future cleanup.

- Tests: `test_lineno_increments_per_line_in_dash_c`, `test_lineno_resets_inside_function`.

### `${(M)var##pat}` / `(M)#pat` / `(M)%pat` / `(M)%%pat` returned the unstripped value instead of the matched portion

- Direct port of zsh's `get_match_ret()` (Src/glob.c:2550). The `SUB_MATCH` flag (set by `(M)`) inverts the strip return: instead of the unmatched portion (default), return the matched portion. Was filed during the iter-86 audit.
- zshrs's flag-aware path (`expand_braced_variable` line 13085) extracted var names as the longest leading-alphanumeric run, then dropped any trailing `##*o`/`#*o`/`%o*`/`%%o*` operator silently. So `${(M)a##*o}` returned the full unstripped value because the strip never ran. The non-M variants worked because they took a different code path through `BUILTIN_PARAM_STRIP`.
- Two-part fix:
  1. New `strip_match_op(v, op, pattern, m_flag)` helper in `exec.rs` mirroring zsh's `get_match_ret` logic. `m_flag=true` swaps which slice of the original is returned at the same boundary index `i` (longest/shortest match) — `v[..i]` (matched) vs `v[i..]` (unmatched) for prefix ops, and the inverse for suffix ops.
  2. Wire the strip detection into `expand_braced_variable` after var-name extraction: detect `##`/`#`/`%`/`%%` in `rest_after_var`, expand the pattern, call `strip_match_op` with `has_match_flag` from the `(M)` flag check above.
- Also handles the no-match case per zsh: with `(M)` and no match, return empty (the matched portion doesn't exist); without `(M)`, return original (strip is a no-op).
- Refactored existing `BUILTIN_PARAM_STRIP` to delegate to the same helper (was a 50-line inline closure with 4 nearly-identical match arms). Compile-time strip path stays `m_flag=false` since `parse_param_modifier` rejects flag forms and routes them through the bridge.
- Tests: `test_m_flag_with_double_hash_strip`, `test_m_flag_with_single_hash_strip`, `test_m_flag_with_percent_strip`, `test_m_flag_with_percent_percent_strip`, `test_m_flag_no_match_returns_empty`.

## Audit (eighty-sixth-pass — C-source-driven correction)

After closing 27 gaps black-box (probing zsh -f -c output without
reading C source), audited each fix against the canonical zsh
source under `~/forkedRepos/zsh/Src/`. Three fixes had material
divergences from the reference implementation:

### Float `%.17g` (batch 10) — replaced with libc::snprintf

zsh's `convfloat()` (Src/params.c:5690) calls `sprintf(buf, "%.*g", 17, dval)` directly. My handwritten implementation only patched the [1e-4, 1e16) range; very-small/very-large values still used Rust's shortest-roundtrip `{:e}` and the `1e16` threshold was wrong (zsh's `%g` switches at exp >= 17, not 16). Found mismatches on `0.0000123` (zsh: `1.2300000000000001e-05`, ours: `1.23e-05`), `1e-5`, and `9.9e16` (zsh: `99000000000000000.`, ours: `9.9e+16`). Replaced the entire branch system with a single `libc::snprintf("%.*g", 17, val)` call mirroring zsh verbatim. Now matches zsh byte-for-byte on a 20-case sweep across the full f64 range.

### Math operator/operand error (batches 4 & 5) — direct port of `checkunary()`

zsh's `checkunary()` (Src/math.c:1548) is a single function that handles BOTH error directions: "operand expected" (errmsg=1, binary op where operand expected) and "operator expected" (errmsg=2, operand where operator expected). My batch-4 fix only patched errmsg=1 inline, missing the symmetric case. Audit found `let "5 5"`, `let "5("`, `$((2#1011x))`, `$((2#10112))`, `$((16#ffg))`, `$((16#zz))` all silently accepted bogus input.

Combined with batch 5's base-N parse: zsh's `zstrtol_underscore` is GREEDY (consumes valid digits, stops at first invalid), but my `from_str_radix` was all-or-nothing — a single bad digit nuked the whole literal. Replaced both with direct ports: `lex_constant` does greedy base-N digit consumption; `check_unary` is a verbatim port of `checkunary()` including the 10-char truncation + `...` overflow marker. 18-case audit (combining both fix scopes) now matches zsh byte-for-byte.

### `(s::)` empty-field collapsing (batch 5) — boundary-aware rule

The "drop empties when no @" rule I implemented from observation was wrong on boundary empties. Reading zsh's `sepsplit()` (Src/utils.c:3962) plus its post-processing at subst.c:3273 revealed the actual rule: leading run of separators collapses to ONE empty (not zero or many); trailing run collapses to ONE empty; middle runs collapse to ZERO empties. `(@)` flag preserves all empties verbatim. My over-aggressive drop missed the boundary preservation: `,,a,,c,,` produced `[a, c]` (2) instead of zsh's `["", a, c, ""]` (4). Replaced with proper boundary-aware collapse — 6-case audit now matches zsh.

### Other fixes (24 of 27) — verified against C source, no changes needed

The remaining 24 fixes were spot-checked against the canonical implementation and matched (identifier validation rule from builtin.c:2547 / params.c:3204 = `idigit(*pname)` first-char check; `kill %abc` jobspec lookup from jobs.c:2143 `findjobnam` fallthrough; `unset -X` flag rejection via the `BUILTIN("unset", ..., "fmvn", ...)` declaration at builtin.c:129; etc.).

## New gaps surfaced by the audit (NOT in original 27)

These are pre-existing bugs the audit exposed, NOT regressions from the iter-86 fixes. Filed for a future iteration:

- `kill %?notfound` — pattern-search jobspec is being globbed before reaching `kill`, so `?` triggers `no matches found: %?notfound` instead of zsh's `kill:1: job not found: ?notfound`.
- `test - a` — single dash as first arg should error `parse error: condition expected: -`; ours silently exits 1.
- `test ! ! ]` — zsh succeeds (exit 0); ours exits 1. Some interaction between test's negation handling and the trailing `]`.
- `$LINENO` in scripts — always 1 in zshrs; should increment per line.
- `${(P)b}`-style indirection error wording divergences in some flag combos.
- `${(M)a##*o}` (M flag with ##/#/% strip) returns the full string instead of just the matched portion.
- `${a[@]:r}` array-context history modifier joins elements instead of preserving array shape.

## Closed (eighty-sixth-pass)

### Bare `typeset NAME` / `declare NAME` (no flags, no `=`) silently swallowed instead of printing the declaration

- zsh: at the top level, `typeset NAME` / `declare NAME` with no flags and no `=` prints the variable's current declaration if NAME is set — `a=( 1 2 3 )`, `a=hello`, etc. Same shape as `-p` but WITHOUT the `typeset`/`export` prefix (zsh's `typeset -p a` is `typeset -a a=(1 2 3)`; bare `typeset a` is `a=(1 2 3)`). Inside a function, bare `typeset NAME` instead localizes (shadows parent, resets to empty) — that's `local`-style declaration semantics. zshrs silently swallowed bare-name calls, dropping the listing at top level. Fix: detect "no type flags + all-args-without-`=` + all-args-name-an-existing-var + local_scope_depth == 0", promote to print_mode, and add a `print_no_prefix` flag that drops the leading `typeset`/`export` from each printed line. Function-scope behavior is unchanged. Tests: `test_bare_typeset_prints_declaration_at_top_level`, `test_bare_typeset_localizes_inside_function`.

### `$((0.1))` printed `0.1` instead of zsh's `0.10000000000000001` (and similar inexact-representable floats)

- zsh uses C's `%.17g` for non-integer arithmetic-substitution display, which always shows 17 significant digits. Inexact-representable values surface their actual stored f64 form: `0.1` stored as f64 is `0.1000000000000000055511...`, which `%.17g` renders as `0.10000000000000001`. Rust's `Display` for f64 is shortest-roundtrip, so it picks the shorter `0.1` even though that's not the exact value. Most other cases (`0.5`, `0.25`, `0.30000000000000004`, NaN, Inf) already agreed since `%g` strips trailing zeros and the shortest representation happens to equal the 17-sig-digit one. Added a manual `%.17g`-style formatter in `format_zsh_subst`: pick scientific if `exp < -4 || exp >= 17`, else fixed-point with `(16 - exp)` fractional digits, then strip trailing zeros from the mantissa via a `trim_g_zeros` helper. Test: `test_arith_subst_float_pct_17g_inexact_form`.

### `declare -a arr=( "abc" "def" )` kept quotes attached to elements (`"abc"` instead of `abc`)

- zsh's plain `arr=( "abc" "def" )` strips surrounding quotes at the shell-syntax level — quotes are word boundaries, not part of the value. The typeset/declare array-assignment path (separate code in zshrs's typeset_named) split the raw `arr=("abc" "def")` arg by whitespace and kept the quotes attached, so consumers saw `"abc"` as the literal first element. Same bug surfaced for `declare -a arr=( "[1]=second" "[3]=fourth" )` where the quotes were retained around the `[N]=...` form. Fix: in the typeset array-element collection loop, strip a matched pair of leading/trailing single or double quotes from each whitespace-split element. The quote-stripping is conservative — only strips when first AND last chars match (so internal quotes / mismatched pairs pass through unchanged). Test: `test_declare_array_strips_quoted_elements`.

### `$#a[N]` (unbraced length-of-element) printed `<count>[N]` instead of zsh's element length

- zsh treats `$#NAME[idx]` as sugar for `${#NAME[idx]}` — length of the selected array element (1-indexed). zshrs's compile-time fast path for unbraced `$#` handled `$#NAME` and `$#NAME[@]`/`$#NAME[*]` (array length) but punted on numeric subscripts: `a=(one two three); echo $#a[2]` printed `3[2]` (count followed by literal `[2]`). Fix: extend the fast path in `compile_zsh::compile_word` to detect `[idx]` after the bare name, push the equivalent `${#NAME[idx]}` braced form, and dispatch to `BUILTIN_EXPAND_TEXT` mode 4 (HeredocBody — calls `exec.expand_string` verbatim) so the full subscript-flag machinery is reused without re-implementing it inline. Test: `test_dollar_hash_array_subscript`.

### `{1..3..0}` (zero step) silently expanded to `1 2 3` instead of staying literal

- zsh: `{N..M..0}` is invalid (zero step is meaningless) and the entire token stays literal. zshrs's `abs_step.max(1)` clamped step 0 → 1 and produced `1 2 3`. Negative steps still reverse the natural sequence (per zsh's rule); only exactly 0 should short-circuit. Added an early return when the parsed step is 0. Test: `test_brace_zero_step_stays_literal`.

### `pushd /tmp; echo $PWD` returned the pre-pushd cwd; `dirs` showed `/private/tmp` instead of zsh's logical `/tmp`

- `pushd`/`popd` called `set_current_dir` (moving the OS-level cwd) but never synced `$PWD`/`$OLDPWD` in the shell's variable table. cd does this; pushd/popd skipped it. Two symptoms cascaded: (1) the shell-level `$PWD` continued to read the pre-pushd path even though the OS cwd had moved, breaking any code that consulted `$PWD` for "where am I"; (2) `dirs` read the OS cwd via `current_dir()` which canonicalizes — so on macOS where `/tmp` is a symlink to `/private/tmp`, `pushd /tmp; dirs` printed `/private/tmp ...`. zsh preserves the user-given logical path. Fix: in `pushd`/`popd`, after `set_current_dir`, write the logical path (user-given when -P not used) to `$PWD` and the prior path to `$OLDPWD` (mirroring `cd`'s behavior). In `dirs`, prefer `$PWD` over `current_dir()` for the current-dir entry, falling back only when `$PWD` is unset. Tests: `test_pushd_updates_pwd_variable`, `test_dirs_uses_logical_pwd_not_canonical`, `test_popd_restores_pwd_variable`.

### `export 1bad=val`, `typeset 1bad=5`, `integer 1bad=5`, `readonly 1bad=5`, `declare 1bad=5`, `local 1bad=5` silently accepted bogus identifiers

- zsh validates the lhs of every typeset-family assignment: leading char must be `[A-Za-z_]`, body must be `[A-Za-z0-9_]*`. Violations emit `<INVOKED>:1: not an identifier: <NAME>` (digit-leading) or `not valid in this context: <NAME>` (whitespace/special chars in `export`). zshrs accepted any string, polluting the variable table and (for `export`) the process environment with names unreachable from any standards-conforming shell. Added validation in three places: `builtin_export` (handles both `not an identifier` and `not valid in this context` wordings), `builtin_typeset_named` (covers typeset/declare/local — uses the `invoked_as` channel for the diagnostic prefix so `declare 1bad=5` says `declare:1:`), `builtin_integer`, and `builtin_readonly` (each has its own assignment loop). Subscript-form names (`a[i]=...`, `m[k]=...`) bypass the check — they route through the runtime arith eval path which validates the base name separately. Tests: `test_export_invalid_first_char_rejects`, `test_export_space_in_name_rejects`, `test_typeset_invalid_identifier_rejects`, `test_declare_invalid_identifier_uses_declare_prefix`, `test_integer_invalid_identifier_rejects`, `test_readonly_invalid_identifier_rejects`.

### `$((10#))` and `$((36#))` (empty digits after base) errored "operator expected at \`'" instead of returning 0

- zsh treats `N#` with an empty digit run as silently 0 (matches the rule for any empty arithmetic operand). zshrs's `from_str_radix("", base)` returned `Err`, which used to land in the out-of-range-digit error arm and emit a nonsense `at \`'` message. Fix: short-circuit to 0 when `val_str.is_empty()` BEFORE calling `from_str_radix`. The correct out-of-range error path (`2#5`, `2#22`) is preserved. Test: `test_arith_empty_base_digits_is_zero`.

### `${(s:,:)foo}` preserved empty fields (off-by-2) instead of dropping them like zsh

- zsh: bare `(s:sep:)` drops empty fields after splitting, e.g. `${(s:,:)"a,,b,,c"}` -> 3 elements `[a, b, c]`. The `(@)` flag overrides to preserve empties (`${(@s:,:)…}` -> 5 elements `[a, "", b, "", c]`). zshrs's flag loop split with `s.split(sep)` and kept every field unconditionally — array counts were off, and `printf "[%s]\n" ${(s:l:)hello}` printed an extra blank `[]` between `[he]` and `[o]`. Fix: scan flags for `@` once (position-independent), drop empty fields when absent. Pure `(s::)` empty-separator (char-split) and `(s::)` over true arrays both honor the same rule. Tests: `test_s_flag_drops_empty_fields_default`, `test_at_s_flag_preserves_empty_fields`, `test_s_flag_drops_consecutive_empties_in_split`.

### `$((2#22))` reported `at \`2'` instead of zsh's `at \`22'`

- zsh keeps its input pointer at the start of the bad digit sequence, so the entire out-of-range remainder shows up in the error: `2#22` -> `operator expected at \`22'`, not just the first `2`. zshrs's base-error path used `val_str.chars().next()` which clipped to one char. Replaced with `val_str` so the full bad digit run is preserved. The original behavior was correct for single-digit cases (`2#5` -> `5`) but lost information for `2#22`, `2#1011x` etc. Test: `test_arith_base_digit_full_remainder`.

### `kill %abc` (non-numeric jobspec) reported `kill: %abc: no such job` instead of zsh's `kill:1: job not found: abc`

- zsh's format strips the leading `%` and uses the `kill:LINE: job not found: NAME` form (consistent with the rest of zsh's builtin diagnostics). zshrs reported `kill: %abc: no such job` — wrong wording, kept the `%`, missing line number. Updated both fall-through paths (parse-failure on `%N` and `%N` not found in jobs table) to emit the zsh-shape message. Test: `test_kill_percent_text_jobspec`.

### `unset -X foo` silently swallowed the bad flag (no diagnostic, exit 0)

- zsh: `unset -X foo` -> `unset:1: bad option: -X` exit 1. zshrs's flag loop had a catch-all `_ if arg.starts_with('-') => {}` arm that masked typo'd flags. Replaced it with a bad-option rejection (preserving `--` as end-of-options sentinel for compat with `unset -- name`). Tests: `test_unset_bad_option_X`, `test_unset_dash_dash_end_of_options`.

### `let "*"` reported "operand expected at end of string" instead of zsh's "at `*'"

- zsh keeps its input pointer at the start of the bad operator and emits `operand expected at \`<remaining>'` for orphan-at-start binary ops (`*`, `/`, `%`, `**`, `&`, `|`, `^`, `&&`, `||`, `^^`, `==`, `!=`, `<`, `>`, `<=`, `>=`, `<<`, `>>`, ...). zshrs collapsed every operand-missing case into "at end of string", losing operator location for orphan-binary expressions. Added `tok_start: usize` field to `MathEval` (updated in `zzlex` after whitespace skip), then in `mathparse`'s binary-op arm, when `stack.is_empty()` AND op is binary, error with `at \`<input[tok_start..]>'` — captures both `let "*"` (just `*`) and `let "*5"` (operator + remaining input). Pure-unary cases (`let "+"`, `let "-"`) still fall through to the existing "at end of string" path. Tests: `test_let_orphan_mul_at_op`, `test_let_orphan_div_at_op`, `test_let_orphan_mul_with_right_includes_remaining`, `test_let_trailing_mul_still_end_of_string`.

### `[ a b c ]` (3 non-flag args, no operator) silently returned 1 instead of erroring "condition expected: b"

- zsh: `[ a b c ]` -> `1: condition expected: b` exit 2 — points at args[1] which should have been an op. zshrs's 3-arg path only checked layouts where args[1] starts with `-`; pure-non-flag layouts fell through to the catch-all `1`. Added a 3-arg arm that fires when none of args[0..3] is `-`-prefixed, with `=`/`!=`/`==` excluded (those go through the regular comparison arms). Test: `test_test_3args_no_op_errors`.

### `print -u 2 hi` always wrote to stdout instead of routing to fd 2

- zsh: `print -u N` writes to fd N. zshrs validated the fd was open (added in iter 84) but the actual write still always went to stdout via `print!()`. Routed via match-on-fd: 1 → `print!`, 2 → `eprint!`, others → `libc::write`. Now `print -u 2 hi 2>/dev/null` correctly suppresses the output. Test: `test_print_u_routes_to_fd`.

### `type -S` and `type -k` errored "bad option" instead of accepting silently

- zsh's `-S` and `-k` are silent-accept on `type` (no observable effect in `-c` mode). zshrs's unknown-flag fallback (added when fixing `-Z` rejection) rejected them. Added an `S | k => {}` arm before the catch-all. Test: `test_type_S_k_accepted`.

### `[ -- a ]` silently returned 1 instead of erroring "unknown condition: --"

- zsh: `[ -- a ]` -> `[:1: unknown condition: --` exit 2 (zsh treats `--` as a bogus flag name in `[`-test, distinct from POSIX shell-end-of-options usage). zshrs's 2-arg unknown-flag arm only fired for all-alphabetic letters, missing the `--` case. Extended the check to include `--` as a valid trigger. Test: `test_test_dashdash_unknown_condition`.

### `fc 1` (single numeric positional, edit form) reported "event not found: 1" instead of "would recurse endlessly"

- Earlier iter 85 fix only covered 2-numeric-positional case. Extended to 1-numeric-positional too: any all-numeric edit-mode positional (count ≤ 2) routes to the recurse-endlessly path in `-c` mode. Test: `test_fc_single_numeric_recurse`.

### `[ a \) ]` (surplus close paren) reported "argument expected" instead of "too many arguments"

- zsh distinguishes paren-depth direction: surplus `(` -> `argument expected` (waiting for operand); surplus `)` -> `too many arguments` (the `)` is the extra arg). zshrs collapsed both into "argument expected". Split the depth-check arm: `d > 0` errors argument-expected; `d < 0` errors too-many. Test: `test_test_unmatched_close_paren_too_many`.

## Closed (eightieth-pass)

### `fc` (no args) reported "no such event: 1" instead of recursion-aborted

- Bare `fc` (no `-l`, no positional) is the EDIT mode — re-execute the previous command. With empty history the previous command IS `fc` itself, so zsh refuses with `current history line would recurse endlessly, aborted`. zshrs collapsed this case into the same "no such event: N" path the `-l` form uses. Added an early branch: when not list-mode AND no positional args, emit zsh's recursion-aborted message. Test: `test_fc_no_args_recursion_message`.

### `${(q)x}` for empty `x` returned empty instead of `''`

- zsh's `(q)` flag on an empty value emits `''` (a single-quoted empty pair) so the value survives word-splitting in the consumer. zshrs's level-1 quote loop did nothing when input was empty, returning bare empty — which an unquoted consumer would drop silently. Added an `s.is_empty()` early return to the level-1 branch. Test: `test_q_flag_empty_returns_quoted_empty`.

### `command -x ls` printed "bad option: -x" instead of "command not found: -x"

- zsh treats unknown `command` flags as command names (so `command -x ls` looks for an executable literally named `-x`, finds nothing, and emits `command not found: -x` with exit 127). zshrs's flag parser rejected with `command: bad option: -x` exit 1. Changed the unknown-flag arm to emit zsh's command-not-found diagnostic and return 127. Test: `test_command_unknown_flag_treated_as_command_name`.

### `command --help` silently passed (was eating `--` mid-flag)

- zsh: `command --help` (long-option-style) is treated as a command NAME (zsh's `command` builtin has no long-option support) — emits `command not found: --help` exit 127. zshrs's flag parser iterated `--help` char by char, hit `-` first which matched the `--` (end-of-options) arm, advanced the args index past `--help`, then returned with no positional args and exit 0. Two-part fix: (1) handle bare `--` BEFORE entering the per-char loop (proper end-of-options detection); (2) detect `--xxx` as a whole-arg long-option-style and emit the command-not-found diagnostic. Test: `test_command_long_option_treated_as_command_name`.

### `[ -i /tmp ]` (unknown unary cond) silently returned 1

- zsh: `[ -X path ]` for an unknown letter X errors `unknown condition: -X` exit 2. zshrs's `builtin_test` had no catch-all for unknown two-arg unary forms — they fell through the AND/OR split and silently returned 1, which a consumer would read as "false" rather than "syntax error". Added a 2-arg alphabetic-flag default arm before the AND/OR split that emits zsh's diagnostic and exits 2. Test: `test_test_unknown_unary_condition_errors`.

### `[ a -eq a ]` (non-numeric operands) silently returned true

- zsh: `[ a -eq a ]` errors `integer expression expected: a` exit 2 because the operands aren't integers. zshrs's `-eq`/`-ne` arms used `unwrap_or(0)`, silently coercing `a` to 0; `a -eq 0` then evaluated true (status 0). Added explicit `parse::<i64>()` checks in the `-eq` and `-ne` arms that emit the diagnostic and return 2 on parse failure. Test: `test_test_eq_non_numeric_errors`.

### `[ 5 -lt abc ]` etc. silently returned 1 instead of "integer expression expected"

- Same `unwrap_or(0)` issue extended to `-lt` / `-le` / `-gt` / `-ge`. Non-numeric operands silently coerced to 0 (so `[ 5 -lt abc ]` evaluated `5 < 0` → false → exit 1). All four arms now do explicit `parse::<i64>()` checks and emit zsh's `integer expression expected: <arg>` exit 2 on failure. Test: `test_test_lt_gt_le_ge_non_numeric_errors`.

### `type --help` silently passed instead of "bad option: -h"

- zsh: `type --help` errors `bad option: -h` exit 1 (the unknown-flag path; zsh treats the second `-` of `--help` as a no-op and reports the first non-`-` letter). zshrs's flag loop had a silent default arm that dropped unknown letters. Added an `eprintln + return 1` and a `'-' => { /* skip */ }` arm so the bad-option diagnostic reports the first letter after the leading dashes. Test: `test_type_unknown_flag_errors`.

### `unalias -X x` printed bash-style "unalias: bad option" instead of zsh format

- zsh: `unalias:1: bad option: -X` (typed `:1:` source-position prefix). zshrs printed `unalias: bad option: -X` (bash style with extra space, no `:1:`). Aligned the format. Test: `test_unalias_bad_option_format`.

### `fc -l blah` (non-numeric event) reported "no such event: 0"

- zsh: non-numeric event spec → `event not found: <text>` (distinct from numeric out-of-range `no such event: N`). zshrs's `parse::<i64>().unwrap_or(-16)` coerced `blah` to -16, then resolved to 0 and printed the numeric form. Added an explicit "single positional + non-numeric" arm BEFORE the numeric resolution that emits the text-event message. Test: `test_fc_non_numeric_event_spec`.

### `history -w` / `-X` (bash-style flags) gave wrong error from fc

- zsh's `history` is a `fc -l` synonym. It REJECTS bash-style flags like `-w` (write), `-X` (unknown) with `bad option: -X`, but ACCEPTS fc-passable flags like `-r` (reverse), `-D` (duration), date-format flags. zshrs's `builtin_history` had explicit arms for `-c`/`-a`/`-n`/numeric counts but treated unknown `-X` flags as search queries, then fell through to the empty-history path with the wrong message. Split the unknown-flag handling: bash-style (`-w`/`-X`) reject explicitly with `history:1: bad option: -X`; everything else falls through to the fc-list code path. Test: `test_history_unknown_flag_errors`.

### `type` (no args) returned 0 silently instead of exit 1

- zsh: bare `type` (no args) exits 1 — type requires at least one name to look up. zshrs returned 0 silently. One-line fix in `builtin_type`'s empty-args guard. Test: `test_type_no_args_exits_1`.

### `[${(@)a}]` for empty `a` dropped the surrounding brackets

- The `(@)NAME` flag form is the splice equivalent of `[@]` — each element becomes its own arg; surrounding literals should stick to first/last (so `[${(@)a}]` for an empty `a` still prints `[]`). zshrs's `is_splice_expansion` only matched `[@]`/`[*]`/slice forms, so `(@)` fell into DISTRIBUTE which drops the brackets when the array is empty. Added a `(...)` flag-block check that returns true when the flag set contains `@`. Test: `test_paren_at_flag_empty_array_preserves_brackets`.

### `${(qq)a}` for empty `a` returned empty instead of `''`

- zsh's `(qq)` flag on an empty array emits `''` (a single quoted empty pair) — the array is treated as `[""]` for quoting so the result still occupies a slot. zshrs returned actually empty (would be silently dropped by an unquoted consumer). Added an empty-array branch in the `q`-flag's state transition that emits `[quote_one("")]` when input array is empty. Test: `test_qq_flag_empty_array_emits_quoted_pair`.

### `${a[-5,-1]}` with len=3 returned full array instead of empty

- zsh: when the negative start index is below the array's lower bound (e.g. `-5` on a 3-element array), the slice empties — zsh treats the start as "past the array's start" and returns nothing. zshrs's `slice_indexed_array` clamped both negatives to valid range and returned the full array. Added an explicit `start < -len` check that short-circuits to empty. Test: `test_array_slice_neg_start_below_neg_len_empty`.

### `typeset -i x` left x empty instead of defaulting to 0

- zsh: `typeset -i NAME` (no value) initializes the integer to `0`; `typeset -F NAME` initializes the float to `0.0000000000` (default precision 10). zshrs's no-`=`-value branch always inserted an empty string, so `typeset -p x` printed `x=''` instead of `x=0`. Added a default-value computation: integer → `"0"`, float → formatted with the requested precision (or default 10), other → empty. Test: `test_typeset_integer_float_default_zero`.

### `print -P "%L"` was off by 1 (used parent's pre-increment SHLVL)

- zsh's `%L` outputs the in-shell SHLVL (incremented at startup over the parent's value). zshrs's `build_prompt_context` read `env::var("SHLVL")` which still held the parent's pre-increment value (the bump goes into `self.variables` only). So `print -P "%L"` showed `parent_shlvl` instead of `parent_shlvl + 1`. Now reads `self.variables["SHLVL"]` first, falling back to env. Test: `test_print_P_L_uses_in_shell_shlvl`.

### `${(V)x}` for control-char string left chars raw instead of `^X`

- zsh: `(V)` flag makes non-printable characters visible (control chars → `^X`, `\n` → `\n`, `\t` → `\t`). zshrs had a `ZshParamFlag::Visible` handler in the multi-flag dispatcher, but the inline state machine for single-flag `${(V)x}` had no `V` arm — control chars passed through raw. Added a `V` arm to the inline state machine that mirrors the visible-char encoding. Test: `test_v_flag_visible_control_chars`.

### `fc -W FILE` in `-c` mode dumped the entire on-disk persistent history

- zsh: `fc -W FILE` in non-interactive `-c` mode writes ONLY session-added entries (typically empty unless `print -s` ran). zshrs called `engine.recent(10000)` and wrote the full persistent log, leaking prior runs' commands into the user's named file. Now restricts to `session_history_ids` when atty is absent (matches zsh's `-c` mode), and falls back to the full recent list only on a real tty. Test: `test_fc_W_writes_session_entries_only_in_minus_c`.

### `fc -R FILE` (missing file) printed an error instead of silently ignoring

- zsh: `fc -R /no/such` returns 0 with no output — read failures are silently ignored so script consumers don't trip on missing logs. zshrs emitted `fc: cannot read /no/such` and returned 1. Removed the eprintln + return; missing-file is now a no-op. Test: `test_fc_R_silent_on_missing_file`.

### `fc -lr` ignored `-r` for session-only listings

- zsh: `fc -lr` walks the same range backwards (most recent first) while keeping original event numbers — `3 c | 2 b | 1 a` for a 3-entry session. zshrs's session-only path iterated `session_history_ids` forward unconditionally; the `-r` flag was a no-op for this code path (it only worked on the engine.recent fallback). Now collects session entries into a Vec and reverses iteration when `reverse` is set. Test: `test_fc_lr_session_reverse`.

### `ulimit -X` (unknown flag) silently fell back to `-f` and printed "unlimited"

- zsh: `ulimit -X` errors `bad option: -X` exit 1. zshrs's silent default arm let unknown flags slip through, then proceeded with the default resource (FSIZE) and emitted "unlimited" — masking the typo. Added an explicit error in the unknown-flag arm. Test: `test_ulimit_unknown_flag_errors`.

### `fc -ld` / `-lD` skipped time/duration columns in session-only mode

- zsh's `fc -ld` adds an `HH:MM` time column; `-lD` adds an `M:SS` duration column. zshrs's session-only listing path emitted only `N  command` regardless. Updated the session-only loop to read each entry's `timestamp` and `duration_ms` fields and format them when `show_time` / `show_duration` is set. Test: `test_fc_ld_lD_show_time_duration`.

### `alias =val` (empty NAME) silently created an unkillable alias

- zsh: `alias =val` / `alias =` errors `bad assignment` exit 1 — the alias name is required. zshrs silently created an alias keyed under empty string which then couldn't be removed via `unalias`. Added an `eq_pos == 0` guard in `builtin_alias` that emits the diagnostic and returns 1. Test: `test_alias_empty_name_errors`.

### `functions +t NAME` errored "no such function: +t"

- zsh: `functions +t NAME` / `+T` clears the trace attr silently (the off-switch counterpart to `-t`/`-T`). zshrs's flag matcher had no `+`-prefix arm — `+t` fell through to the names list, then the per-name lookup emitted `no such function: +t`. Added explicit `+t` / `+T` arms (and a combined `+xyz` arm for `+lt` etc.) that silently consume the flag. Test: `test_functions_plus_t_disable_trace_silent`.

### `alias g="x=y"; alias g` listed value bare instead of single-quoted

- zsh's `alias` listing single-quotes the value when it contains shell metas — including `=`, because the bare form `alias g=x=y` would re-parse as `alias g=x` + positional arg `=y`. zshrs's `format_alias_kv` (and its inline copy in the per-name lookup) excluded `=` from the quote-trigger set. Added `=` to both. Test: `test_alias_listing_quotes_value_with_equals`.

### Bare `fc` with session entries fell through to list mode instead of recurse-aborted

- Bare `fc` (no -l, no positional) is the EDIT mode — re-execute the prior command. In -c mode the prior command is `fc` itself, which is infinite recursion; zsh refuses with `current history line would recurse endlessly, aborted`. zshrs's earlier guard required empty session_history_ids, so adding any `print -s` entry let bare `fc` fall through to the list-mode pass-through. Hoisted the recurse-aborted check to fire BEFORE the session-only branch. Test: `test_fc_no_args_with_session_still_recurses`.

### `command` (no args) silently returned 0 instead of "redirection with no command"

- zsh: bare `command` (no args, no command name) errors `redirection with no command` exit 1 — `command` requires a command name. zshrs's empty-positional branch returned 0 silently, masking the missing CMD argument. Added the diagnostic and exit 1. Test: `test_command_no_args_redirection_error`.

### `wait %1` after bg job already reaped errored "no such job"

- zsh: `cmd & wait %1` works even after the bg process has completed and been reaped — missing job spec is silent success. zshrs's `wait` builtin emitted `wait: %1: no such job` and set status 127, breaking the common `cmd & wait` idiom. Now silently consumes missing job specs. Test: `test_wait_missing_job_silent`.

## Closed (seventy-ninth-pass)

### `printf "%04x" 42` printed `  2a` instead of `002a`

- The `%x`/`%X`/`%o` cases in `builtin_printf` had `left_align` and default-(right-pad-with-space) branches but no `zero_pad` branch — so `%04x` ignored the `0` flag and used spaces. Added a `zero_pad` arm to each that emits the prefix (`0x`/`0X`/`0` for alt-form), then `0`-fill for the width gap, then the digits. Test: `test_printf_hex_zero_pad`.

### `for ((i=1; i<=$#a; i++))` never iterated

- The arith COMMAND already routes through MathEval when the expr contains `$`, but the for-loop arith sections (init/cond/step) routed only on `,`. ArithCompiler's lexer can't parse `$`, so the cond became "0" (false) and the loop body never ran. Two-part fix: (1) extend `route_through_eval` in `compile_for_arith` to fire on `$` as well as `,`; (2) lift the routing decision to a single `needs_eval_global` so init/cond/step all use the SAME backend (otherwise init writes `i` into a slot via ArithCompiler but cond reads `i` from MathEval's variable map and sees 0). Test: `test_for_arith_with_dollar_param_in_cond`.

### `set -y` (and any unknown flag letter) errored "invalid option"

- zsh accepts unknown single-letter `set` flags silently — `set -y` is a no-op, `set -xy` enables xtrace and silently accepts -y. zshrs's `builtin_set` errored on the first unknown letter, breaking scripts that probe combinations. Default arm now silently ignores unknown letters (matching zsh's lenient flag-letter behavior). Test: `test_set_unknown_flag_silent`.

### `print -S "msg"` printed to stdout instead of history-only

- zsh's `print -S` adds to history INSTEAD of stdout (split-shell-words variant of `-s`). zshrs left `S` in the TODO list of unhandled flags so the line reached stdout. Now `S` sets `add_to_history=true` like `s`. Test: `test_print_S_adds_to_history_silently`.

### `${a[N]/pat/repl}` returned the element unchanged

- The bracket-modifier path in the array subscript expansion handled `:-` / `:+` / `:?` / `:=` / `:N:M` / history-modifier suffixes, but skipped the `/`, `//`, `/#`, `/%` pattern-replace forms. So `a=(file.txt other.txt); ${a[1]/.txt/.bak}` returned `file.txt` instead of `file.bak`. Added a `/`-prefix arm that decodes the op (0/1/2/3 for `/`/`//`/`/#`/`/%`) and dispatches through a new `zsh_pattern_replace` free function (extracted from the `BUILTIN_PARAM_REPLACE` `one()` closure so element-level callers can use it without going through the name-keyed builtin). Test: `test_array_element_pattern_replace`.

### `$histchars` was empty (zsh defaults to `!^#`)

- `$histchars` is the canonical 3-char string controlling history expansion (bang, hat, hash). zsh ships with `!^#`. zshrs left it unset so script reads of `$histchars` returned empty. Initialized in `ShellExecutor::new` next to the other special-name defaults. Test: `test_histchars_default`.

### `foo() {}` (empty function body) failed "command not found: {}"

- The lexer's `{` handler required whitespace, newline, or EOF after `{` to recognise it as Inbrace; `{}` consumed as a single literal token and the function-body parser failed. Two-part fix: (1) added `}` to the post-`{` accept list so `{}` lexes as Inbrace even when in cmd position; (2) for the OUT-of-cmd-position case (e.g. directly after `()` where `Outpar` cleared `incmdpos`), peek for `}` and force Inbrace recognition so `foo() {}` parses as a function with empty body. Other `{...}` shapes (brace expansion `{a,b,c}`, `${var}`) still work because they're consumed by separate token paths. Test: `test_empty_function_body`.

### `fc -l` indented event numbers one space too far

- zsh's `fc -l` formats event numbers right-aligned in a 5-char field (`    1  cmd`). zshrs used `{:>6}` (6-char field), so the output column was shifted one space right of zsh's. Switched every `fc -l` print site (session-history loop, recent-iter loop, with-time/duration variants) to `{:>5}` so the alignment matches. Test: `test_fc_l_event_number_width`.

### `set -a` did not enable `allexport`

- The multi-letter set-flag parser (`-xy` / `-xa` / `+ax` etc.) had arms for `e`/`x`/`u`/`v`/`n`/`f`/`m`/`C`/`b` but no `a`. So `set -a` silently passed through the silent-unknown default and `allexport` stayed off. Added `a` (enable) and `+a` (disable) arms in both halves of the multi-letter parser. Test: `test_set_a_enables_allexport`.

### `echo ~0` aborted "no such user or named directory: 0"

- zsh's `~N` (digits only) is shorthand for `~+N` — Nth entry on the directory stack, 0 = $PWD. zshrs's `expand_tilde_named` checked for `~+N` and `~-N` explicitly but not bare digits, so `~0` fell through to the `getpwnam` path which (correctly for non-numeric usernames) aborted in `-c` mode. Added a digits-only branch above the user-lookup arm that resolves to PWD or `dir_stack[N-1]`. Test: `test_tilde_digit_is_dirstack_index`.

### `echo \*` aborted with NOMATCH instead of printing the literal

- `looks_like_glob` walked the pattern looking for `*`/`?`/`[` and returned true on any occurrence — including `\*` where the `*` is escaped. So `echo \*` triggered the glob path, expanded against `cwd` (zero matches in most directories), and aborted in NOMATCH mode. Now the check walks character-by-character and skips `\X` escape pairs so backslash-escaped metachars don't count as glob triggers. The output still preserves the literal backslash (deeper unquoting fix needed for the full `\* → *` translation), but the script no longer aborts. Test: `test_escaped_glob_metachar_does_not_trigger_nomatch`.

### `let` (no args) returned 1 silently — zsh emits a diagnostic

- zsh's bare `let` errors `zsh:let:1: not enough arguments` and exits 1. zshrs returned 1 with no message, so script consumers couldn't distinguish "let with no args" from "let with arg that evaluated to 0". Added the diagnostic; status unchanged (still 1). Test: `test_let_no_args_errors`.

### `history` in `-c` mode dumped the on-disk persistent log instead of aborting

- In non-interactive `-c` mode with no in-session history adds, zsh's `history` (a `fc -l` synonym) errors `no such event: 1` rather than reading the persistent file. zshrs read the disk log and printed entries from prior sessions. Added the same atty + session-history-empty guard already used by `fc -l`; with session entries, only those are listed (numbered from 1). Test: `test_history_in_minus_c_mode_errors`.

### `read -q` from a pipe (non-tty) read silently and returned 0

- zsh's `read -q` is a single y/n character read from the terminal. Off a tty (`echo y | read -q ans`), zsh errors `not interactive and can't open terminal` and returns 1. zshrs read from stdin and returned 0 — scripts couldn't detect the missing terminal. Added an atty guard at the start of the `read` body before any stdin lock. Test: `test_read_q_requires_terminal`.

### `print -z "ls"` printed to stdout instead of pushing to ZLE buffer

- zsh's `print -z` pushes the args onto the line-editor's pre-buffer (so the next prompt starts with that text). Non-interactive mode has no editor, so the args are silently discarded with exit 0. zshrs's `push_to_stack` flag was a `let _ = ` no-op, so the args fell through to the output-args path and printed. Now `push_to_stack` returns 0 immediately. Test: `test_print_z_does_not_emit_to_stdout`.

### `kill -l` was missing SIGINFO on macOS

- macOS's `kill -l` lists `INFO` between `WINCH` and `USR1` (signal 29). zshrs's `signal_map` didn't have an entry for it, so the listing was missing one column and `kill -l` output didn't match zsh. Added a macOS-cfg-gated `("INFO", libc::SIGINFO, Signal::SIGINFO)` row. Test: `test_kill_l_includes_info_on_macos`.

### `fc -l` (no args) reported "no such event: 0" instead of "1"

- zsh: `fc -l` (no args) defaults to the last 16 events. With empty history, the lower bound resolves to event #1 (which doesn't exist), so the error is `no such event: 1`. zshrs collapsed all non-positive args to `0`. Now distinguishes the no-arg default (resolves to 1) from explicit 0/negative args (resolve to 0). Test: `test_fc_l_default_no_args_event_one`.

### `kill -l XYZ` printed wrong error format

- zsh: unknown-signal error in `kill -l` is `zsh:kill:1: unknown signal: SIGXYZ` — both a typed `zsh:kill:1:` prefix AND the SIG prefix on the signal name (the SIG prefix is always added even when the user's input lacked it). zshrs printed `kill: unknown signal: XYZ` (missing both prefixes). Updated the eprintln!. Test: `test_kill_l_unknown_signal_format`.

### `typeset` silently overwrote read-only variables

- zsh: `readonly y=1; typeset y=2` errors `read-only variable: y` and aborts the shell with exit 1 in -c mode. zshrs's `builtin_typeset_named` skipped the read-only check and overwrote the value. Added a check at the top of the assignment branch: if the name is in `readonly_vars` or has `var_attrs.readonly`, emit the diagnostic and `process::exit(1)` (matching `BUILTIN_SET_VAR`'s abort behavior). Test: `test_typeset_readonly_aborts`.

### `fc -h` overflowed the stack via infinite re-execution

- zsh: any unknown `fc` flag errors `bad option: -X` and bails. zshrs's flag-letter loop had a silent default arm — unknown flags fell through to the no-args path (re-execute last command), and since `fc -h` itself entered history that path infinitely recursed and overflowed. Added an `eprintln + return 1` for unknown flags so `fc -h`, `fc -w`, etc. fail cleanly. Test: `test_fc_unknown_flag_errors`.

### `functions -T NAME` printed the function body instead of enabling trace

- zsh: `functions -T` enables tracing on the named functions silently. zshrs didn't recognize `-T` (only `-l`/`-t`/`-m`), so it fell into the default-listing path and printed the body. Added `-T` (and combined-flag `T`) arms that return 0 immediately (the trace attribute itself isn't tracked yet, but the no-output behavior matches script consumers). Test: `test_functions_T_enable_trace_silent`.

### `unalias` (no args) printed bash-style usage instead of zsh format

- zsh: bare `unalias` errors `zsh:unalias:1: not enough arguments`. zshrs's empty-args path emitted a bash-style `unalias: usage: unalias [-agsm]` message — script consumers pattern-matching on `unalias:1:` missed the diagnostic. Replaced both no-args paths with `zshrs:unalias:1: not enough arguments`. Test: `test_unalias_no_args_emits_zsh_format`.

### `[[ -N file ]]` (modified-since-access test) errored "unknown condition"

- zsh: `-N file` returns true iff the file's access time is NOT newer than its modification time (used by mailbox-watching code). zshrs's cond compiler had no `-N` arm so it fell through to the unknown-condition error path. Added `BUILTIN_FILE_MODIFIED_SINCE_ACCESS` (id 341) using `MetadataExt`'s `mtime()`/`atime()` with the `atime <= mtime` semantic so equal-stamped (newly created) files count as modified, and an `emit_file_test` arm to dispatch. Also fixed an inadvertent ID collision: my first attempt picked id 331 which clobbered `BUILTIN_APPEND_SCALAR_OR_PUSH`, breaking `a+=val` array push. Test: `test_cond_N_file_modified_since_access`.

### `((1/0))` arith COMMAND aborted instead of continuing with non-zero status

- zsh: `((1/0))` arith command sets status to 2 and continues; only the substitution form `$((1/0))` aborts the whole command. zshrs's earlier "abort on division-by-zero" fix in `evaluate_arithmetic` unilaterally `process::exit(1)`-ed regardless of caller, so `((1/0)); echo` skipped the echo. Reverted the unilateral exit — now just emits the diagnostic and returns "0"; the call-site SetStatus op gives the surrounding command a non-zero status (1 from compile-time StrEq-to-"0" check; zsh uses 2 but scripts treat both as failure via `(()) && …` gating). Test: `test_arith_division_by_zero_continues`.

### `fc -l N M` (range query) used the single-arg "no such event" message

- zsh: `fc -l N M` (range query) emits `no events in that range` when the history is empty; the per-event `no such event: N` is reserved for single-positional / no-positional forms. zshrs collapsed both to the per-event form. Added a `positional.len() >= 2` arm that uses the range-specific message. Test: `test_fc_l_range_two_args_no_events_in_range`.

### `umask abcd` printed generic "invalid mask" instead of zsh's per-char diagnostic

- zsh validates symbolic umask values character-by-character: after class chars (`u`/`g`/`o`/`a`) it expects an operator (`+`/`-`/`=`). On invalid input it reports the specific bad operator: `umask abcd` → `bad symbolic mode operator: b`. zshrs printed a generic `umask: invalid mask: abcd`. Added a fallback parse: walk the leading class chars, then check if the next char is an operator; emit zsh's per-char message when it isn't. Test: `test_umask_bad_symbolic_operator_specific_error`.

### `type -w NAME` printed descriptive form instead of `NAME: KIND`

- zsh's `type -w NAME` is the "word" shorthand: prints `NAME: KIND` where KIND is one of `builtin`, `command`, `function`, `alias`, `reserved`, `none`. zshrs ignored `-w` and fell to the default descriptive output (`NAME is a shell builtin`, etc.). Added a `show_word` flag set by `-w`, plus per-kind branches that emit the `NAME: KIND` form. Test: `test_type_w_emits_name_colon_kind`.

### Non-executable file invocation exited 127 silently instead of 126 with diagnostic

- zsh: invoking a non-executable file (`chmod 644 file; ./file`) emits `permission denied: ./file` on stderr and exits 126 (POSIX convention for "command found but not executable"). zshrs's `execute_external` only special-cased `NotFound` (→ 127); all other IO errors fell into the generic `Err` arm which the caller converted to 127 with no diagnostic. Added a `PermissionDenied` arm that emits zsh's diagnostic and returns 126. Test: `test_exec_non_executable_file_status_126`.

### `[[ ]]` (empty condition) silently passed instead of parse error

- zsh: `[[ ]]` is a parse error — a condition is required between the brackets. zshrs's `parse_cond` called `parse_cond_expr` which returned `None`, then silently consumed the `]]` and produced no command (exit 0). Added an early-return: if the immediate next token after `[[` is `]]`, emit a parse error. Test: `test_empty_cond_bracket_parse_error`.

### `umask 022 044` (multiple values) silently used the last value

- zsh: multiple positional values to `umask` errors `too many arguments` and exits 1. zshrs's flag loop overwrote `value` with each positional, silently using only the last. Added a `value_count` counter and an early error when > 1. Test: `test_umask_too_many_args`.

### `functions -t NAME` printed unconditionally instead of trace-attr-gated

- zsh: `functions -t NAME` lists only functions whose trace attribute IS set; vanilla functions with no trace marking produce no output. zshrs printed `functions -t NAME` unconditionally, leaking output for the common no-trace case. Per-function trace tracking is a follow-up; for now `-t` falls into a continue/silent path that matches zsh's no-trace output. Test: `test_functions_t_no_trace_set_silent`.

## Closed (seventy-eighth-pass)

### `print -P "%S"` emitted reverse-video instead of italic

- zsh's prompt-expansion `%S` (start standout) emits `\e[3m` (italic), and `%s` (end standout) emits `\e[23m` (italic-end). zshrs's prompt expander used `\e[7m`/`\e[27m` (reverse video pair). Switched to italic codes to match zsh's actual output. Test: `test_print_P_standout_emits_italic_codes`.

### `for ((i=0,j=10; i<3; i++,j--))` only initialized one variable

- ArithCompiler emits at most one op-write per call; the comma-trailing statements were dropped. So `i=0,j=10` set `i` but left `j` empty, producing `0:` instead of `0:10`. Routed comma-containing init/step expressions through `BUILTIN_ARITH_EVAL` (MathEval, which evaluates the whole comma-list and writes back through `extract_string_variables`). Cond expressions with comma also re-routed. Test: `test_for_arith_comma_init_and_step`.

### `print -P "%B%S%U"` re-emitted bold/standout each time

- zsh: `%B%S%U` outputs `\e[1m\e[3m\e[4m` (each independent SGR). zshrs's `apply_attrs` re-emitted ALL currently-active attrs every call, producing `\e[1m\e[1m\e[3m\e[1m\e[3m\e[4m` etc. Each individual attribute handler (`B`/`U`/`S`) now emits ONLY its specific SGR code; `apply_attrs` is reserved for color-set paths that need the full state. Test: `test_print_P_attr_chain_independent`.

### `$_` leaked function body text inside function calls

- After `foo() { echo $_ }; foo`, zsh's `$_` inside the function body should be `foo` (function name when no args). zshrs was reading the FUNCTION BODY SOURCE because `BUILTIN_REGISTER_COMPILED_FN` (called when defining the function) had updated `pending_underscore` with the body text via the standard `pop_args` hook. Fix: explicitly set `$_` and `pending_underscore` BEFORE the function body runs (in `call_function`'s pre-VM setup) — using the function's call-form last arg, or the function name when no args. Test: `test_dollar_underscore_inside_function_body`.

### `print -P "%y"` (tty short name) outputted empty when not on a tty

- zsh: `%y` outputs `()` when not connected to a tty (matches `%l`). zshrs returned empty because the `%y` handler stripped the `/dev/` prefix from an empty string. Added the empty-tty check to emit `()` matching zsh. Test: `test_print_P_y_no_tty_outputs_parens`.

### `print -P "%F{red}"` re-emitted all active attrs

- zsh: `%F`, `%K`, `%k` each emit ONLY their specific SGR code (`\e[31m`, `\e[44m`, `\e[49m`). zshrs's color handlers called `apply_attrs` which re-emitted all currently-active attrs (`\e[1m\e[1m\e[31m...` if bold was already set). Same fix as text-attrs in batch 2: each color handler now emits only its specific code. Test: `test_print_P_color_no_extra_bold`.

### `which local` reported just `local` instead of `local: shell reserved word`

- `which` is a csh-style synonym for `whence -c`. zsh's `whence -c` outputs `name: shell reserved word` for reserved-word names. zshrs's whence-c branch had no reserved-word case — it fell through and printed just the name. Added a `csh_style` arm in the reserved-word handler. Test: `test_which_reserved_word_csh_style`.

### `${a[N]:r}` / `:e` / `:t` / `:h` / `:l` / `:u` returned full element

- zsh: `a=(file.txt); ${a[1]:r}` → `file` (history-style modifier on resolved element). zshrs's bracket handler routed `:` modifiers through the colon-default branch (`:-`/`:=`/`:?`/`:+`) and didn't recognize history modifiers. Added a per-element `apply_history_modifiers` call when `after_bracket` starts with `:` and the body is a known modifier. Test: `test_array_element_history_modifier`.

### `builtin nosuch` had non-zsh error format

- zsh: `builtin nosuch:1: no such builtin: NAME`. zshrs printed `builtin: NAME: not a shell builtin`. Aligned to zsh's format. Test: `test_builtin_missing_zsh_format`.

### `%F{1}` (numeric color 0-7) used 256-color escape

- zsh: `%F{1}` (red) outputs `\e[31m` (basic ANSI). zshrs's `Color::Numbered(n)` always used the 256-color form `\e[38;5;Nm`. For indices 0-7, the basic ANSI codes (30-37 fg, 40-47 bg) are the canonical encoding. Added a fast-path for `n <= 7` that emits `\e[3Nm` / `\e[4Nm`. Indexes 8-255 still use the long form. Test: `test_print_P_color_basic_8_uses_ansi_codes`.

### `print -P "%1d"` printed `tmp` and `%2d` printed `/private/tmp` (canonicalized cwd)

- zsh's prompt-expansion `%d`/`%~` use the *logical* pwd from `$PWD`, not the canonicalized cwd from `getcwd()`. On macOS, `cd /tmp` leaves `$PWD=/tmp` while `getcwd()` returns `/private/tmp`. zshrs's `build_prompt_context` used `env::current_dir()` only, so `%2d` printed `/private/tmp` and `%1d` (last 1 component) printed `tmp` (no leading `/`). Switched to `env::var("PWD")` first, fall back to `current_dir()`. Test: `test_prompt_d_uses_logical_pwd_not_canonical`.

### `setopt globdots` did not enable hidden-file matching

- zsh's canonical option name is `globdots`; `dotglob` is the bash alias. zshrs's options table stores them under separate keys, but the glob expansion only consulted `dotglob`. So `setopt globdots; print *` skipped dotfiles even though the user explicitly asked for them. Now read both keys (either spelling enables it). Test: `test_glob_globdots_setopt_alias_for_dotglob`.

### `<N-M>` numeric range glob produced parse error or literal pattern

- zsh's numeric range glob `<N-M>` matches any digit sequence whose decimal value is in `[N, M]`. zshrs threw `parse error: expected word after redirection` because the lexer consumed `<` as a redirection operator mid-word. Three-part fix: (1) lexer's mid-word `<` handler peeks for `<digits?-digits?>` shape and absorbs it into the current word; (2) compile-time word classifier (`compile_word_str`) recognises the `<...>` shape via `has_numeric_range_glob` and triggers glob compilation; (3) runtime adds `expand_glob_with_numeric_range` that walks the directory and filters by a regex with `(\d+)` captures, validating each capture against the `[lo, hi]` range. Open-ended forms (`<3->`, `<-5>`, `<->`) work via `Option<i64>` bounds. Tests: `test_glob_numeric_range_finite`, `test_glob_numeric_range_open_high`, `test_glob_numeric_range_open_both`.

### `$((PPID))`, `$((UID))`, `$((EUID))`, `$((GID))`, `$((EGID))` resolved to `0`

- zsh resolves bareword identifiers in arithmetic context against live process-id specials. zshrs's `evaluate_arithmetic` only injected `RANDOM` / `SECONDS` / `EPOCHSECONDS` / `EPOCHREALTIME` / `LINENO` into MathEval's extras map, so `PPID` and the user/group ids fell through to the static-zero default. Added the 5 process-id specials to the inject list. Test: `test_arith_ppid_uid_special_names`.

### `${a[5,10]}` (out-of-range slice) returned the trailing element

- zsh: out-of-range starts collapse to empty (`a=(a b c); print ${a[5,10]}` → empty). zshrs's `slice_indexed_array` clamped the start index down to len, returning the last element. Added explicit out-of-range checks (start > len; both negatives crossing) that short-circuit to `Vec::new()`. Test: `test_array_slice_out_of_range_is_empty`.

### Empty array slice in DQ context dropped surrounding literals

- `print "[${a[5,10]}]"` with empty slice should print `[]` (surrounding `[` `]` literals stick to first/last element via splice semantics). zshrs's compiler routed slice forms through `BUILTIN_CONCAT_DISTRIBUTE` because `is_splice_expansion` only matched `[@]` / `[*]` — the cartesian-distribute path drops empty arrays AND surrounding literals together. Extended `is_splice_expansion` to recognise `[N,M]` slice subscripts so they pick `BUILTIN_CONCAT_SPLICE` (which preserves the LHS scalar when the RHS array is empty). Same test: `test_array_slice_out_of_range_is_empty`.

### Recursive aliases caused stack overflow

- zsh's lexer disables an alias inside its own body (so `alias ls='ls -la'` works without recursion; the lexer expands `ls` to `ls -la` once, then the inner `ls` stays literal). zshrs expands aliases at run time via `execute_script(&combined)`, which re-parses → re-dispatches → recurses forever. `alias g="g hi"; g` overflowed the stack. Added an `expanding_aliases: HashSet<String>` guard on `ShellExecutor`: insert the name before the recursive `execute_script`, remove on return, and skip alias lookup entirely for any name already in the set. The recursive `g` now misses the alias table and falls through to "command not found", matching zsh. Test: `test_alias_recursion_guard_self_disables`.

### `[ \( -n a \) -a \( -z "" \) ]` (paren grouping) didn't work

- POSIX `[ ... ]` (test) supports `\(` `\)` for grouping around `-a`/`-o` connectives. zshrs's default arm split on the LAST top-level `-a`/`-o` without tracking paren depth, so a paren-grouped expression got split on a connective that should have been inside a sub-group. Added a depth-tracking pass to `builtin_test`: only consider `-a`/`-o` at depth 0, AND if the entire expression is wrapped in matching outer parens, strip them and recurse on the inner. Test: `test_test_builtin_paren_grouping`.

### `fc -l 0` and `fc -l -N` reported wrong event number

- In `-c` (non-interactive) mode with empty history, zsh's `fc -l N` errors with `no such event: <resolved-N>`. Resolved is `max(0, N)` — both 0 and any negative offset collapse to `0`; positive arguments echo verbatim. zshrs hardcoded `"1"` for non-positive arguments, so `fc -l 0` reported `1` instead of `0`. Now uses `if first <= 0 { 0 } else { first }` for the message. Test: `test_fc_l_no_event_uses_resolved_index`.

### `read -p` was treated as prompt (should be coprocess input)

- zsh's `read -p` reads input from the coprocess; the prompt feature is `read 'NAME?prompt'`. zshrs misread `-p` as a prompt flag, ate the next arg as the prompt text, and printed it. Now emits `zshrs:read:1: -p: no coprocess` and exits 1, matching zsh in -c mode. Capital `-P` is left as a prompt-flag alias for back-compat with anything that relied on the old (wrong) shape. Test: `test_read_p_flag_means_coprocess_not_prompt`.

### `typeset -i N x; x=255` did not format in base N

- `typeset -i N x` declares an integer that should display in base N (`N#DIGITS`). zshrs only applied the base format when the typeset call also assigned in the same statement (`typeset -i 16 x=255` → `16#FF` worked); a later bare `x=255` skipped the format because `BUILTIN_SET_VAR` only consulted `VarKind::Integer`, not `var_attrs.int_base`. Pulled the full attrs out, applied `format_int_in_base` on store. Test: `test_typeset_i_base_format_at_assignment`.

### `[[ -v a[1] ]]` errored "no matches found: a[1]"

- The condition `-v` (parameter-set test) takes a parameter NAME with optional subscript. zshrs's cond compiler called `compile_word_str(arg)` which triggered glob expansion — `a[1]` was treated as a `[1]` char-class glob and aborted with NOMATCH. Three-part fix: (1) `ZshCond::Unary` arm for `-v` emits the operand as a `LoadConst` literal, skipping glob; (2) `ZshCond::Binary` arm (which the parser uses when `-v` is followed by one operand and an empty third operand) gets the same `-v` literal-emission branch; (3) `BUILTIN_VAR_EXISTS` runtime now splits `name[key]` and looks up either the indexed array element (with negative-index resolution and bounds check) or the assoc-array key, returning `Bool`. Test: `test_cond_v_with_array_subscript`.

### `${x:0:${#x}-2}` (substring with arithmetic length) returned full string

- zshrs's `parse_param_modifier` rejected ANY shape with nested `${...}` (line 3133: `if inner.contains("${") { return None; }`), falling through to the bridge expander which doesn't handle the substring shape. Relaxed the rejection: nested `${…}` is now allowed in substring offset/length operands as long as it doesn't appear before the first `:` (the var-name segment) and the modifier op isn't a prefix-letter form (`:-`, `:#`, `:/`, `:?`, etc.). The length expression then routes through `BUILTIN_PARAM_SUBSTRING_EXPR` which calls `eval_arith_expr` after `expand_string`. Test: `test_substring_with_arithmetic_length`.

### `(( ${+h[a]} ))` arith command with parameter expansion always saw 0

- The arith COMMAND `(( ... ))` (vs the arith SUBSTITUTION `$(( ... ))`) compiled through `ArithCompiler`, whose lexer can't parse `$` and treated the expansion as an unknown token. The expansion never ran, so the operand silently became 0 and the command exited 1 even when the value was 1. Added `$` to `needs_eval` triggers in `compile_command` for `(( ... ))` — any expr touching parameter expansion now routes through `BUILTIN_ARITH_EVAL` → `evaluate_arithmetic` which calls `expand_string` first. Test: `test_arith_command_with_parameter_expansion`.

### `$0` in `-c` mode returned basename instead of argv[0] verbatim

- zsh exposes argv[0] verbatim through `$0` in `-c` mode — `/bin/zsh -c 'echo $0'` prints `/bin/zsh`, plain `zsh -c '...'` prints `zsh`. zshrs basename-stripped argv[0] in the `-c` dispatch (`bins/zshrs.rs`), losing the path information. Now passes argv[0] through unchanged. Test renamed from `_returns_basename` to `_uses_argv0_verbatim` and updated to accept either basename or absolute path. Test: `test_dollar_zero_in_minus_c_uses_argv0_verbatim`.

### `[[ -v 1 ]]` (positional param test) returned false even when set

- zsh: `[[ -v N ]]` for a digit name N tests whether the Nth positional parameter is set (i.e. `$#` ≥ N). zshrs's `BUILTIN_VAR_EXISTS` only consulted `variables`/`arrays`/`assoc_arrays`/env, never the positional list — so `set -- one; [[ -v 1 ]]` returned false. Added a digit-name fast-path: parse name as `usize`, treat 0 specially (look up the `0` variable), otherwise compare against `positional_params.len()`. Test: `test_cond_v_with_positional_param`.

### `HISTSIZE=0` left HISTSIZE at 0 (zsh clamps to 1)

- zsh enforces a minimum of 1 on `HISTSIZE`. The internal `params.rs` setter already clamped via `.max(1)`, but the assignment path (`BUILTIN_SET_VAR`) writes directly into `variables` and skipped the clamp. So a script doing `HISTSIZE=0; echo $HISTSIZE` saw `0` instead of `1`. Added a name-specific clamp at the SET_VAR layer: `if name == "HISTSIZE"` parse, max with 1, store the clamped string. Test: `test_histsize_min_clamp_to_one`.

### `echo $((1/0))` printed `0` after the error message

- zsh aborts the command on arithmetic division-by-zero — `echo` never runs. zshrs's `evaluate_arithmetic` printed the error then returned `"0"` from the `Err` arm, so `echo $((1/0))` printed `division by zero` to stderr AND `0` to stdout (status 0). The shell continued normally. Now matches zsh: division-by-zero in `evaluate_arithmetic` calls `process::exit(1)` after printing the error, aborting the surrounding command in `-c` mode. Other arith errors still return `"0"` (matches zsh's NumericContext defaults). Test: `test_arith_division_by_zero_aborts`.

### `wait NOT_A_PID` emitted "wait: NOT_A_PID: invalid pid" instead of zsh's job-not-found format

- zsh treats an unparseable `wait` argument as a (failed) job-spec lookup: `zsh:wait:1: job not found: NOT_A_PID` exit 127. zshrs hand-rolled `wait: NOT_A_PID: invalid pid` from the parse-error arm, which neither matches zsh's `<shellname>:<builtin>:<line>:` prefix nor uses the canonical "job not found" wording — script consumers grep'ing stderr for the zsh format silently saw nothing. Changed the parse-error arm to emit `zshrs:wait:1: job not found: <arg>` (same exit 127). Test: `test_wait_invalid_pid_uses_zsh_format`.

## Still open (seventy-fifth-pass — remaining)

- **`nocorrect CMD args`** — parser drops the rest of the line after `nocorrect` appears. Lexer needs to recognize `nocorrect` (and `noglob` as well, eventually for purity) as a precommand modifier and skip past it. Deferred.
- **`set -n` syntax-only mode** — `set -n; cmd` should parse but not execute. zshrs ignores -n. Deferred (needs runtime no-op gate).
- ~~**`${(j:sep:)$(cmd subst)}`**~~ — closed in eighty-eighth-pass batch 25. (j) is now a no-op on scalar cmd-subst output (zsh-correct).
- **Arith error full command-abort** — zsh: `echo $((10/0))` prints only the error and skips `echo`; zshrs prints the error then runs `echo 0`. Partial fix landed (error message now visible); full abort needs expansion-time error plumbing.
- **`set` noargs print all variables** — `set` with no args should dump every shell parameter in name=value form. zshrs prints ~10 lines; zsh prints ~480. Massive output diff; deferred (needs full param-table walk + assoc/array formatting).
- ~~**`${a:^b}` zip-arrays**~~ — closed in eighty-eighth-pass batch 24. SUB_ZIP_SHORT and SUB_ZIP_LONG implemented.
- ~~**`${(v)assoc}` insertion order**~~ — closed in eighty-eighth-pass batch 1. Switched assoc storage to IndexMap.
- **`${(s:l:)hello}` empty-element handling** — zsh drops empty elements when splitting (`hello` split by `l` → `he`, `o`); zshrs keeps empties (`he`, ``, `o`). Niche.
- **`${#:-empty}` length-of-default** — zsh returns 5 (length of "empty"); zshrs returns 0. Esoteric edge case in `${#name:-default}` parsing.
- **`*` glob ordering caseglob** — zsh sorts `bench bins Cargo.lock …` (case-insensitive); zshrs sorts `Cargo.lock … bench bins` (case-sensitive). Glob expansion needs to honor the `caseglob` option (default-on in zsh).
- **`declare -A h; h[foo bar]=baz`** — zsh: "bad pattern: h[foo"; zshrs: "command not found: h[foo". Both error; format/source differs.
- **`declare -i` / `declare -a` / `declare -A` no-args listing** — zsh includes shell-internal params (`!`, `$`, `EUID`, `fpath`, etc.) in their respective type listings. zshrs's special params aren't typed, so the filter would return empty. Needs typed-special-param infrastructure.
- **Math funcs without `zmodload zsh/mathfunc`** — zshrs auto-provides `sqrt`, `sin`, `cos`, `floor`, `ceil`, `min`, `max`, etc.; zsh requires `zmodload zsh/mathfunc` first and errors with "unknown function" otherwise. zshrs is more permissive (likely a feature for daily use); leaving as documented divergence.

- **Backtick nesting** — parser-deferred.
- **`xtrace` exact zsh format** — POSIX `+ cmd` shape; zsh's elaborate PS4 not matched.

The following items have been investigated and confirmed as false positives or fundamentally compatible:

- **`read -d DELIM` / `read -A array` / `read -r raw`** — verified working when not run inside a pipeline subshell. The original probe diff was a `cmd | read v` pipeline-isolation artifact (the `read` runs in a subshell so `$v` doesn't survive — same behavior in zsh).
- **`print -m PATTERN args...`** — match-arg flag still missing (cosmetic; rarely used).
- **`${(M)arr:#pat}` DQ context** — zsh's behavior here is subtle (the `(M)` flag stays active in DQ to flip filter direction on the joined scalar). zshrs's array-context filter logic differs only when the WHOLE expression is DQ-wrapped AND uses `(M)` AND has a `:#pat` filter. Niche edge case; deferred.
- **`cd -`** — output style differs by one leading "print pwd" line that zsh's interactive cd suppresses but `-c` doesn't. Cosmetic.
- **`select` PS3 customization** — uses `?# ` default; users with custom PS3 see their own value. Cosmetic in `-c` mode.

The "Stub modules (loaded but limited)" section below remains as documented deferrals (`zsh/cap`, `zsh/clone`, `zsh/curses`, `zsh/zftp`, `zsh/db_gdbm`) — these are niche features whose `zmodload` call currently no-ops, with the corresponding builtins not registered. They are not active gaps in zshrs's compatibility floor; they're tracked separately because they have no real-world load on the daily-driver path. `zsh/mapfile` was previously in this list; it is now closed (read form implemented above).

## Stub modules (loaded but limited)

- `zsh/cap`, `zsh/clone`, `zsh/curses`, `zsh/zftp` — module loads via `zmodload` succeeds but the corresponding builtins (`cap`, `clone`, `zcurses`, `zftp`) aren't registered. Niche features; deferred.
- `zsh/db_gdbm` — `ztie` correctly reports "GDBM support not compiled in" (no native gdbm dep). Acceptable stub behavior.
- `zsh/files chown/chmod/chgrp` — works (proper error for nonexistent file).
- `zsh/mapfile` — closed; `${mapfile[/path]}` magic assoc reads file bytes, composes with `(f)` flag for line-split. See "Closed (this session)" above.
- `zsh/private` — closed; routed to `builtin_local`.
- `zsh/newuser`, `zsh/nearcolor` — niche, deferred.