fallow-cli 3.28.0

CLI for fallow, codebase intelligence for TypeScript and JavaScript
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
# Fallow GitLab CI Template
#
# Find unused code, code duplication, circular dependencies, and complexity
# hotspots in TypeScript/JavaScript projects.
#
# Usage: add to your .gitlab-ci.yml:
#
#   include:
#     - remote: 'https://raw.githubusercontent.com/fallow-rs/fallow/vX.Y.Z/ci/gitlab-ci.yml'
#
#   fallow:
#     extends: .fallow
#     variables:
#       FALLOW_COMMAND: "dead-code"
#       FALLOW_FAIL_ON_ISSUES: "true"
#
# Or include locally if you vendor the file:
#
#   # fallow ci-template gitlab --vendor
#   include:
#     - local: 'ci/gitlab-ci.yml'
#
# All variables are optional and have sensible defaults.
#
# Features:
#   - Inline MR annotations via GitLab Code Quality reports (CodeClimate format)
#   - Rich MR summary comments with collapsible sections (set FALLOW_COMMENT: "true")
#   - Inline MR review discussions with suggestion blocks (set FALLOW_REVIEW: "true")
#   - Optional inline "What to do" guidance (set FALLOW_REVIEW_GUIDANCE: "true")
#   - Comment merging: groups unused exports per file, deduplicates clones
#   - Automatic cleanup of previous fallow comments on re-runs
#   - Auto --changed-since in MR context (scopes to changed files)
#   - Incremental caching of parse results
#   - All fallow commands: dead-code, dupes, health, audit, security, fix
#   - Configurable failure thresholds
#
# Examples:
#
#   # Dead code analysis only, fail on issues
#   fallow:
#     extends: .fallow
#     variables:
#       FALLOW_COMMAND: "dead-code"
#
#   # Duplication check, warn but don't fail
#   fallow-dupes:
#     extends: .fallow
#     variables:
#       FALLOW_COMMAND: "dupes"
#       FALLOW_FAIL_ON_ISSUES: "false"
#
#   # Full analysis with rich MR comments and inline review
#   fallow:
#     extends: .fallow
#     variables:
#       FALLOW_COMMENT: "true"
#       FALLOW_REVIEW: "true"
#       FALLOW_REVIEW_GUIDANCE: "true"
#
#   # Incremental: only report issues in changed files
#   fallow:
#     extends: .fallow
#     variables:
#       FALLOW_CHANGED_SINCE: "origin/main"

# ---------------------------------------------------------------------------
# Configuration variables
# ---------------------------------------------------------------------------

variables:
  # Git checkout. Fallow needs a working tree, and changed-file analysis needs
  # enough history to diff against the MR base SHA. These override shared
  # templates that set GIT_STRATEGY=none or a shallow clone.
  GIT_STRATEGY: "fetch"
  GIT_DEPTH: "0"

  # Core
  FALLOW_VERSION: ""             # Empty reads package.json fallow dependency, then falls back to latest
  FALLOW_SKIP_INSTALL: ""        # "true" skips `npm install -g fallow` and uses the fallow already on PATH (e.g. a pnpm-catalog pin installed by a prior `pnpm install`). Fails fast if no fallow is found.
  FALLOW_COMMAND: ""            # dead-code, dupes, health, audit, security, fix, or empty (runs all)
  FALLOW_ROOT: "."
  FALLOW_CONFIG: ""             # Path to .fallowrc.json, .fallowrc.jsonc, fallow.toml, or .fallow.toml
  FALLOW_PRODUCTION: ""                # "true"/"false" enables production for every analysis. Empty defers to config.
  FALLOW_PRODUCTION_DEAD_CODE: ""      # Combined/audit mode: "true"/"false" overrides FALLOW_PRODUCTION for dead-code. Empty defers to it.
  FALLOW_PRODUCTION_HEALTH: ""         # Combined/audit mode: "true"/"false" overrides FALLOW_PRODUCTION for health. Empty defers to it.
  FALLOW_PRODUCTION_DUPES: ""          # Combined/audit mode: "true"/"false" overrides FALLOW_PRODUCTION for duplication. Empty defers to it.
  FALLOW_FAIL_ON_ISSUES: "true"
  # health command only: fail when a complexity finding reaches this severity
  # ('moderate'|'high'|'critical'). FALLOW_FAIL_ON_ISSUES is a separate count
  # gate and still counts every finding.
  FALLOW_MIN_SEVERITY: ""
  # health command only: fail when the health score drops below this threshold
  # (0-100). Implies --score, so the template also passes --complexity unless a
  # health section variable is set, keeping the Code Quality report populated.
  FALLOW_MIN_SCORE: ""
  # Fail the pipeline when the run analyzed no source file at all, so every
  # count it reports is zero because nothing was measured. Warns and passes by
  # default, because a scope that legitimately holds no source should not break.
  FALLOW_FAIL_ON_EMPTY_ANALYSIS: "false"
  FALLOW_INCLUDE_ENTRY_EXPORTS: "false"  # Report unused exports in entry files instead of auto-marking them as used; mirrors --include-entry-exports
  FALLOW_TYPE_AWARE: ""                  # true/false overrides repository typeAware.enabled; empty defers to config
  FALLOW_TYPE_AWARE_PROJECTS: ""         # Comma-separated tsconfig paths; empty uses automatic project discovery
  FALLOW_TYPE_AWARE_REQUIRE: ""           # best-effort or complete; empty defers to repository typeAware.require
  FALLOW_ARGS: ""               # Extra CLI arguments (space-separated)
  FALLOW_COMMENT: "false"       # Post results as MR summary comment
  FALLOW_REVIEW: "false"        # Post inline MR discussions with rich comments and suggestions
  FALLOW_REVIEW_GUIDANCE: "false" # Add collapsed "What to do" guidance to inline review discussions
  FALLOW_REVIEW_ID: ""       # Stable scope for parallel review jobs targeting the same MR
  FALLOW_CODEQUALITY: "true"    # Generate GitLab Code Quality report (inline MR annotations)
  FALLOW_MAX_COMMENTS: "50"     # Maximum number of inline review comments + items in the sticky details table
  FALLOW_COMMENT_ID: ""         # Sticky-comment marker id; auto-suffixed with the workspace name when scoped to one workspace and unset
  FALLOW_PR_COMMENT_LAYOUT: "default" # Sticky MR comment layout: default, compact, gate-only, or details
  FALLOW_SUMMARY_SCOPE: "all"   # Sticky MR summary scope: 'all' keeps project-level dependency/catalog/override findings outside the diff; 'diff' applies the diff filter to them too. Inline review discussions are unaffected. If the diff cannot be read, fallow reports all findings.
  FALLOW_DIFF_FILTER: "added"   # Diff-aware filter: 'added' | 'diff_context' | 'file' | 'nofilter'
  FALLOW_DIFF_FILE: ""          # Path to a unified-diff file. When unset and CI_MERGE_REQUEST_DIFF_BASE_SHA is set, the comment / review scripts derive it via `git diff` (see the script blocks below). When set OR derived, fallow narrows findings to lines inside an added hunk. Sticky summary comments keep project-level findings by default unless FALLOW_SUMMARY_SCOPE is 'diff'; inline review discussions stay diff-anchored. When both FALLOW_DIFF_FILE and FALLOW_CHANGED_SINCE are set, --diff-file wins for line-level filtering and --changed-since still scopes file discovery; fallow logs a one-line stderr note.
  FALLOW_API_RETRIES: "3"       # Maximum HTTP retry attempts for the binary's reconcile-review and the curl/gh wrappers
  FALLOW_API_RETRY_DELAY: "2"   # Floor delay in seconds between rate-limited retries; server-supplied Retry-After overrides
  FALLOW_GITLAB_BASE_SHA: ""    # Override for the MR base SHA in the review-gitlab position object; falls back to CI_MERGE_REQUEST_DIFF_BASE_SHA
  FALLOW_GITLAB_START_SHA: ""   # Override for the MR start SHA; falls back to base
  FALLOW_GITLAB_HEAD_SHA: ""    # Override for the MR head SHA; falls back to CI_COMMIT_SHA

  # MR integration auth.
  # GITLAB_TOKEN (PAT/project access token with api scope) is required for
  # summary comments and inline MR discussions. GitLab's documented
  # CI_JOB_TOKEN permissions allow reading MR notes, but not creating,
  # updating, or deleting them.

  # Diff-based filtering
  FALLOW_CHANGED_SINCE: ""      # Git ref for incremental analysis (auto-set in MR context)
  FALLOW_BASELINE: ""           # Path to a baseline file; only findings absent from it are reported
  FALLOW_SAVE_BASELINE: ""      # Save this run's findings as a baseline file for future comparisons
  FALLOW_FAIL_ON_STALE_BASELINE: "false"  # Fail the pipeline when FALLOW_BASELINE has entries that match nothing this run. The staleness warning needs no variable: any run that loads a baseline reports it, and a run scoped to changed files re-reads the baseline over the whole project first, because a scoped run cannot judge a whole-project baseline. This variable only decides whether that verdict fails the pipeline. Independent of FALLOW_FAIL_ON_ISSUES.

  # Workspace / monorepo
  FALLOW_WORKSPACE: ""
  FALLOW_CHANGED_WORKSPACES: ""  # Git-derived monorepo scoping: set to a git ref (e.g. "origin/main") to scope analysis to workspaces containing any changed file. Requires full git history. Mutually exclusive with FALLOW_WORKSPACE.

  # Dead-code specific
  FALLOW_ISSUE_TYPES: ""        # Comma-separated: unused-files,unused-exports,...
  FALLOW_FAIL_ON_REGRESSION: "false"
  FALLOW_TOLERANCE: "0"
  FALLOW_REGRESSION_BASELINE: ""
  FALLOW_SAVE_REGRESSION_BASELINE: ""

  # Dupes specific
  FALLOW_DUPES_MODE: "mild"     # strict, mild, weak, semantic
  FALLOW_MIN_TOKENS: ""
  FALLOW_MIN_LINES: ""
  FALLOW_THRESHOLD: ""          # Fail if duplication exceeds this %
  FALLOW_SKIP_LOCAL: "false"
  FALLOW_CROSS_LANGUAGE: "false"
  FALLOW_IGNORE_IMPORTS: "false"

  # Health specific
  FALLOW_MAX_CYCLOMATIC: ""
  FALLOW_MAX_COGNITIVE: ""
  FALLOW_MAX_CRAP: ""             # Maximum CRAP score (default 30.0); pair with coverage data for accurate per-function scoring
  FALLOW_COVERAGE: ""             # Istanbul coverage-final.json for accurate CRAP scoring (health/audit/default combined)
  FALLOW_PRODUCTION_COVERAGE: ""  # Path to paid runtime coverage input (V8 dir/file or Istanbul coverage-final.json)
  FALLOW_COVERAGE_ROOT: ""        # Rebase Istanbul file paths before matching coverage or runtime coverage input
  FALLOW_MIN_INVOCATIONS_HOT: ""  # Hot-path threshold for runtime coverage findings (default 100)
  FALLOW_MIN_OBSERVATION_VOLUME: "" # Minimum observation volume required for high-confidence runtime coverage verdicts
  FALLOW_LOW_TRAFFIC_THRESHOLD: "" # Fraction of total trace volume below which an invoked function is classified as low_traffic
  FALLOW_TOP: ""
  FALLOW_SORT: ""               # cyclomatic (default), cognitive, lines, or severity
  FALLOW_SCORE: "false"          # health score (0-100 with letter grade), enables delta header in MR comments
  FALLOW_FILE_SCORES: "false"
  FALLOW_HOTSPOTS: "false"
  FALLOW_TARGETS: "false"
  FALLOW_COMPLEXITY: "false"
  FALLOW_SINCE: ""
  FALLOW_MIN_COMMITS: ""
  FALLOW_SAVE_SNAPSHOT: ""       # save snapshot to .fallow/snapshots/ for trend tracking; cache this path across pipelines
  FALLOW_TREND: "false"          # compare against most recent snapshot; requires FALLOW_SAVE_SNAPSHOT on a prior run

  # Audit specific
  FALLOW_AUDIT_GATE: ""                 # new-only or all
  FALLOW_AUDIT_DEAD_CODE_BASELINE: ""   # Baseline from fallow dead-code --save-baseline
  FALLOW_AUDIT_HEALTH_BASELINE: ""      # Baseline from fallow health --save-baseline
  FALLOW_AUDIT_DUPES_BASELINE: ""       # Baseline from fallow dupes --save-baseline

  # Security specific
  FALLOW_SECURITY_GATE: ""       # new or newly-reachable

  # Fix specific
  FALLOW_DRY_RUN: "true"

  # Performance
  FALLOW_NO_CACHE: "false"
  FALLOW_THREADS: ""

  # Bare invocation selectors
  FALLOW_ONLY: ""               # Comma-separated: check,dupes,health
  FALLOW_SKIP: ""

  # Advanced: pin remote MR-integration scripts to a specific tag or commit.
  # Leave empty to prefer vendored local ci/ + action/ scripts when present.
  FALLOW_SCRIPTS_REF: ""

# ---------------------------------------------------------------------------
# Template job: extend this in your pipeline
# ---------------------------------------------------------------------------

.fallow:
  image: node:22-alpine
  stage: test
  cache:
    # Scoped per job so a matrix over FALLOW_ROOT does not overwrite itself.
    # GitLab rejects "/" inside a cache key, so the root cannot be interpolated
    # directly; every matrix arm already gets a distinct job name slug.
    key: "fallow-${CI_COMMIT_REF_SLUG}-${CI_JOB_NAME_SLUG}"
    paths:
      - ${FALLOW_ROOT}/.fallow/
    policy: pull-push
  before_script:
    # Install dependencies: detect Alpine (apk) vs Debian/Ubuntu (apt-get)
    - |
      if command -v apk > /dev/null 2>&1; then
        apk add --no-cache bash jq git curl > /dev/null 2>&1
      elif command -v apt-get > /dev/null 2>&1; then
        apt-get update -qq && apt-get install -y -qq bash jq git curl > /dev/null 2>&1
      else
        echo "ERROR: No supported package manager found (apk or apt-get required)"
        exit 2
      fi
    # Validate and install fallow
    - |
      bash -eo pipefail <<'FALLOW_INSTALL_EOF'
      trim() {
        local value="$1"
        value="${value#"${value%%[![:space:]]*}"}"
        value="${value%"${value##*[![:space:]]}"}"
        printf '%s' "$value"
      }

      is_safe_version_spec() {
        local spec
        spec="$(trim "$1")"
        if [ "$spec" = "latest" ]; then
          return 0
        fi
        local start_re='^[0-9xX*~^<>=]'
        local safe_re='^[0-9A-Za-z.*~^<>=| -]+$'
        # Accept semver versions and ranges, while rejecting protocols, paths,
        # package aliases, git URLs, or injected npm arguments.
        [[ "$spec" =~ $start_re ]] &&
          [[ "$spec" =~ $safe_re ]] &&
          [[ ! "$spec" =~ : ]] &&
          [[ ! "$spec" =~ / ]] &&
          [[ ! "$spec" =~ [[:space:]]-[A-Za-z] ]]
      }

      is_exact_version() {
        [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?$ ]]
      }

      project_fallow_spec() {
        local package_json="$1/package.json"
        if [ ! -f "$package_json" ]; then
          return 0
        fi

        node - "$package_json" <<'NODE'
      const fs = require("node:fs");
      const packageJson = process.argv[2];
      const pkg = JSON.parse(fs.readFileSync(packageJson, "utf8"));
      for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
        const spec = pkg[section]?.fallow;
        if (typeof spec === "string" && spec.trim()) {
          console.log(spec.trim());
          process.exit(0);
        }
      }
      NODE
      }

      # FALLOW_SKIP_INSTALL lets a pipeline reuse a fallow binary that is already
      # on PATH (e.g. a pnpm-catalog pin installed by a prior `pnpm install`)
      # instead of `npm install -g fallow` at pipeline time. Default empty
      # preserves the install behavior below.
      if [ "$(trim "${FALLOW_SKIP_INSTALL:-}")" = "true" ]; then
        if ! command -v fallow > /dev/null 2>&1; then
          echo "ERROR: FALLOW_SKIP_INSTALL=true but no 'fallow' binary is on PATH. Install fallow before this job (e.g. 'pnpm install' so a pinned fallow lands on PATH), or unset FALLOW_SKIP_INSTALL to let the template run 'npm install -g fallow'."
          exit 2
        fi
        installed_version="$(fallow --version || echo 'unknown version')"
        echo "Skipping install; using pre-installed ${installed_version} ($(command -v fallow))"
        # Let the MR-integration script-prep block pin remote scripts to the
        # matching release tag when the binary reports an exact semver (parity
        # with the install path, which writes the resolved spec here).
        installed_semver="$(printf '%s\n' "$installed_version" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?' | head -n 1 || true)"
        if [ -n "$installed_semver" ]; then
          printf '%s\n' "$installed_semver" > /tmp/fallow-version-spec
        fi
        exit 0
      fi

      requested_version="$(trim "${FALLOW_VERSION:-}")"
      root="${FALLOW_ROOT:-.}"
      project_spec="$(project_fallow_spec "$root" || true)"
      project_spec="$(trim "$project_spec")"
      install_spec=""

      if [ -n "$requested_version" ]; then
        install_spec="$requested_version"
        echo "Using fallow version from FALLOW_VERSION: ${install_spec}"
      elif [ -n "$project_spec" ]; then
        if is_safe_version_spec "$project_spec"; then
          install_spec="$project_spec"
          echo "Using fallow version from ${root}/package.json: ${install_spec}"
        else
          echo "WARNING: Ignoring unsupported fallow package.json spec '${project_spec}'. Use a semver version or range, or set FALLOW_VERSION explicitly."
          install_spec="latest"
        fi
      else
        install_spec="latest"
      fi

      if ! is_safe_version_spec "$install_spec"; then
        echo "ERROR: Invalid version specifier: ${install_spec}. Use 'latest' or a semver version/range like '2.52.2' or '^2.52.0'."
        exit 2
      fi

      printf '%s\n' "$install_spec" > /tmp/fallow-version-spec

      if [ "$install_spec" = "latest" ]; then
        install_arg="fallow"
      else
        install_arg="fallow@${install_spec}"
      fi

      # FALLOW_INSTALL_DRY_RUN is an internal hook used by ci/tests/run.sh to
      # exercise this block without invoking npm. Not a documented user knob.
      if [ "${FALLOW_INSTALL_DRY_RUN:-}" = "true" ]; then
        echo "DRY RUN: npm install -g --ignore-scripts ${install_arg}"
        exit 0
      fi

      npm install -g --ignore-scripts "$install_arg" || { echo "ERROR: Failed to install ${install_arg}"; exit 2; }

      installed_version="$(fallow --version || echo 'unknown version')"
      echo "Installed fallow ${installed_version}"

      if [ -z "$requested_version" ] && [ -n "$project_spec" ] && is_exact_version "$project_spec"; then
        installed_semver="$(printf '%s\n' "$installed_version" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?' | head -n 1 || true)"
        if [ -n "$installed_semver" ] && [ "$installed_semver" != "$project_spec" ]; then
          echo "WARNING: Installed fallow ${installed_semver}, but ${root}/package.json pins ${project_spec}. Set FALLOW_VERSION or align package.json to keep local and CI results comparable."
        fi
      fi
      FALLOW_INSTALL_EOF
    # Prepare bash scripts for MR integration
    - |
      bash -eo pipefail <<'FALLOW_SCRIPT_PREP_EOF'
      FALLOW_SCRIPTS_DIR="/tmp/fallow-scripts"
      mkdir -p "$FALLOW_SCRIPTS_DIR"

      if [ "$FALLOW_COMMENT" = "true" ] || [ "$FALLOW_REVIEW" = "true" ]; then
        DOWNLOAD_FAILURES=0
        if [ -d "ci/scripts" ]; then
          echo "Using vendored MR integration scripts from the repository checkout..."

          # The stderr of `cp` is discarded because the `else` branch names the
          # file that failed, and the counter below decides the job outcome.
          for f in comment.sh review.sh gitlab_common.sh; do
            if cp "ci/scripts/${f}" "${FALLOW_SCRIPTS_DIR}/${f}" 2>/dev/null; then
              chmod +x "${FALLOW_SCRIPTS_DIR}/${f}"
            else
              echo "  WARNING: Failed to copy ci/scripts/${f}"
              DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
            fi
          done
        else
          FALLOW_RESOLVED_VERSION="$(cat /tmp/fallow-version-spec || printf '%s' "${FALLOW_VERSION:-}")"
          if [ -z "$FALLOW_SCRIPTS_REF" ] && echo "$FALLOW_RESOLVED_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?$'; then
            FALLOW_SCRIPTS_REF="v${FALLOW_RESOLVED_VERSION}"
          fi

          if [ -z "$FALLOW_SCRIPTS_REF" ]; then
            echo "ERROR: FALLOW_COMMENT/FALLOW_REVIEW require vendored ci/ + action/ scripts or a pinned FALLOW_SCRIPTS_REF when fallow is installed from latest or a semver range."
            exit 2
          fi

          if ! echo "$FALLOW_SCRIPTS_REF" | grep -qE '^[a-zA-Z0-9._/-]+$'; then
            echo "ERROR: Invalid FALLOW_SCRIPTS_REF: ${FALLOW_SCRIPTS_REF}"; exit 2
          fi

          FALLOW_SCRIPTS_BASE="https://raw.githubusercontent.com/fallow-rs/fallow/${FALLOW_SCRIPTS_REF}"
          echo "Downloading MR integration scripts pinned to ${FALLOW_SCRIPTS_REF}..."

          # The stderr of `curl` is discarded because the `else` branch names the
          # file that failed, and the counter below decides the job outcome.
          for f in comment.sh review.sh gitlab_common.sh; do
            if curl -sf --retry 3 --retry-connrefused --retry-delay 2 "${FALLOW_SCRIPTS_BASE}/ci/scripts/${f}" -o "${FALLOW_SCRIPTS_DIR}/${f}" 2>/dev/null; then
              chmod +x "${FALLOW_SCRIPTS_DIR}/${f}"
            else
              echo "  WARNING: Failed to download ci/scripts/${f}"
              DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
            fi
          done
        fi

        if [ "$DOWNLOAD_FAILURES" -gt 0 ]; then
          echo "WARNING: ${DOWNLOAD_FAILURES} script(s) failed to download, MR comments/review may be limited"
        else
          echo "Scripts downloaded"
        fi
      fi
      FALLOW_SCRIPT_PREP_EOF
    # Write the analysis script (heredoc avoids quoting issues)
    - |
      bash -eo pipefail <<'FALLOW_RUN_WRITER_EOF'
      cat > /tmp/fallow-run.sh << 'FALLOW_SCRIPT_EOF'
      #!/bin/bash
      set -euo pipefail

      # ── Validate inputs ──────────────────────────────────────────────
      case "$FALLOW_COMMAND" in
        ""|dead-code|check|dupes|health|audit|security|fix) ;;
        *) echo "ERROR: Invalid command: ${FALLOW_COMMAND}"; exit 2 ;;
      esac
      if [ "$FALLOW_COMMAND" = "audit" ] && { [ -n "$FALLOW_BASELINE" ] || [ -n "$FALLOW_SAVE_BASELINE" ]; }; then
        echo "ERROR: The audit command does not support FALLOW_BASELINE/FALLOW_SAVE_BASELINE. Use FALLOW_AUDIT_DEAD_CODE_BASELINE, FALLOW_AUDIT_HEALTH_BASELINE, or FALLOW_AUDIT_DUPES_BASELINE instead."
        exit 2
      fi
      # audit cannot judge a whole-project baseline, and FALLOW_BASELINE is
      # already rejected for it above, so the pair is unreachable through the
      # variables. It is still reachable through FALLOW_ARGS, where it buys a
      # green pipeline plus a note --quiet removes.
      if [ "$FALLOW_COMMAND" = "audit" ] \
        && printf '%s' "$FALLOW_ARGS" | grep -q -- '--fail-on-stale-baseline'; then
        echo "ERROR: --fail-on-stale-baseline in FALLOW_ARGS cannot apply to command: audit, which analyzes only the files that changed against its base and cannot judge a whole-project baseline. Run the gate on dead-code, dupes or health."
        exit 2
      fi
      # The stale-baseline gate reads the analysis envelope, so it needs a
      # baseline to judge and a command that reports one.
      if [ "$FALLOW_FAIL_ON_STALE_BASELINE" = "true" ]; then
        if [ -z "$FALLOW_BASELINE" ]; then
          echo "ERROR: FALLOW_FAIL_ON_STALE_BASELINE has no baseline to judge. Set FALLOW_BASELINE, or turn the gate off."
          exit 2
        fi
        case "$FALLOW_COMMAND" in
          fix|security)
            echo "ERROR: The ${FALLOW_COMMAND} command reports no baseline staleness, so FALLOW_FAIL_ON_STALE_BASELINE cannot apply. Run the gate on dead-code, dupes or health."
            exit 2
            ;;
        esac
      fi
      # FALLOW_SAVE_BASELINE runs before the comparison, so a baseline re-saved
      # to the path it is loaded from can never be stale and no gate on it can
      # ever fire.
      if [ -n "$FALLOW_BASELINE" ] && [ "$FALLOW_BASELINE" = "$FALLOW_SAVE_BASELINE" ]; then
        echo "WARNING: FALLOW_BASELINE and FALLOW_SAVE_BASELINE name the same file (${FALLOW_BASELINE}). The run saves before it compares, so the baseline is rewritten from this run and can never report stale entries. Save to a different path, or drop FALLOW_SAVE_BASELINE from the job that reads the baseline."
      fi
      if [ -n "$FALLOW_AUDIT_GATE" ] && [ "$FALLOW_AUDIT_GATE" != "new-only" ] && [ "$FALLOW_AUDIT_GATE" != "all" ]; then
        echo "ERROR: FALLOW_AUDIT_GATE must be 'new-only' or 'all', got: ${FALLOW_AUDIT_GATE}"; exit 2
      fi
      if [ -n "$FALLOW_SECURITY_GATE" ] && [ "$FALLOW_SECURITY_GATE" != "new" ] && [ "$FALLOW_SECURITY_GATE" != "newly-reachable" ]; then
        echo "ERROR: FALLOW_SECURITY_GATE must be 'new' or 'newly-reachable', got: ${FALLOW_SECURITY_GATE}"; exit 2
      fi

      for name_val in "min-tokens:$FALLOW_MIN_TOKENS" "min-lines:$FALLOW_MIN_LINES" \
                      "max-cyclomatic:$FALLOW_MAX_CYCLOMATIC" "max-cognitive:$FALLOW_MAX_COGNITIVE" \
                      "top:$FALLOW_TOP" "min-commits:$FALLOW_MIN_COMMITS" "threads:$FALLOW_THREADS" \
                      "min-invocations-hot:$FALLOW_MIN_INVOCATIONS_HOT" "min-observation-volume:$FALLOW_MIN_OBSERVATION_VOLUME"; do
        name="${name_val%%:*}"; val="${name_val#*:}"
        if [ -n "$val" ] && ! echo "$val" | grep -qE '^[0-9]+$'; then
          echo "ERROR: ${name} must be a positive integer, got: ${val}"; exit 2
        fi
      done
      if [ -n "$FALLOW_THRESHOLD" ] && ! echo "$FALLOW_THRESHOLD" | grep -qE '^[0-9]+\.?[0-9]*$'; then
        echo "ERROR: threshold must be a number, got: ${FALLOW_THRESHOLD}"; exit 2
      fi
      # max-crap accepts floating-point values; CRAP scores are non-integer.
      if [ -n "$FALLOW_MAX_CRAP" ] && ! echo "$FALLOW_MAX_CRAP" | grep -qE '^[0-9]+\.?[0-9]*$'; then
        echo "ERROR: max-crap must be a non-negative number, got: ${FALLOW_MAX_CRAP}"; exit 2
      fi
      if [ -n "$FALLOW_LOW_TRAFFIC_THRESHOLD" ] && ! echo "$FALLOW_LOW_TRAFFIC_THRESHOLD" | grep -qE '^[0-9]+\.?[0-9]*$'; then
        echo "ERROR: low-traffic-threshold must be a non-negative number, got: ${FALLOW_LOW_TRAFFIC_THRESHOLD}"; exit 2
      fi

      # ── Auto changed-since in MR context ───────────────────────────
      if [ -n "${CI_MERGE_REQUEST_IID:-}" ] && [ -z "$FALLOW_CHANGED_SINCE" ] && [ "$FALLOW_COMMAND" != "fix" ]; then
        if [ -n "${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}" ]; then
          FALLOW_CHANGED_SINCE="$CI_MERGE_REQUEST_DIFF_BASE_SHA"
          echo "Auto-scoping to changed files (--changed-since ${FALLOW_CHANGED_SINCE:0:12})"
        fi
      fi

      # ── Pre-compute unified diff for line-level finding scoping ────
      # When the user did not supply $FALLOW_DIFF_FILE, write a
      # fallow-mr.diff alongside the analysis output so fallow can
      # narrow source-anchored findings (dead-code, complexity, duplication,
      # boundary violations, runtime-coverage hot paths) to lines
      # inside the diff. Project-level findings (unused deps, catalog,
      # override) bypass the filter and pass through unchanged.
      # GitLab CI runs each script line in the same shell, so an exported
      # variable is visible to the saved-envelope comment/review renderers.
      # They derive the diff themselves only when FALLOW_DIFF_FILE is unset.
      if [ -n "$FALLOW_CHANGED_SINCE" ] && [ -z "$FALLOW_DIFF_FILE" ]; then
        if git diff --unified=0 "${FALLOW_CHANGED_SINCE}..HEAD" > fallow-mr.diff && [ -s fallow-mr.diff ]; then
          export FALLOW_DIFF_FILE="$PWD/fallow-mr.diff"
        else
          rm -f fallow-mr.diff
          echo "fallow: warning [shallow-clone]: could not produce unified diff for line-level finding scoping. Set GIT_DEPTH: \"0\" in the pipeline to enable line-precision."
        fi
      fi

      # ── Build CLI arguments ──────────────────────────────────────────
      # Analyze once as JSON. Secondary CI formats are rendered from that
      # artifact so semantic and syntactic findings cannot drift between runs.
      ARGS=()
      # Issue-type filter flags this template added, recorded so the
      # stale-baseline gate's unscoped re-run can remove exactly those.
      ISSUE_TYPE_FLAGS=()
      [ -n "$FALLOW_COMMAND" ] && ARGS+=("$FALLOW_COMMAND")
      ARGS+=(--root "$FALLOW_ROOT" --quiet --format json)

      [ -n "$FALLOW_CONFIG" ]           && ARGS+=(--config "$FALLOW_CONFIG")
      [ "$FALLOW_PRODUCTION" = "true" ] && ARGS+=(--production)
      if [ -z "$FALLOW_COMMAND" ]; then
        [ "$FALLOW_PRODUCTION_DEAD_CODE" = "true" ] && ARGS+=(--production-dead-code)
        [ "$FALLOW_PRODUCTION_HEALTH" = "true" ] && ARGS+=(--production-health)
        [ "$FALLOW_PRODUCTION_DUPES" = "true" ] && ARGS+=(--production-dupes)
      fi
      [ -n "$FALLOW_CHANGED_SINCE" ]    && ARGS+=(--changed-since "$FALLOW_CHANGED_SINCE")
      [ -n "$FALLOW_BASELINE" ]         && ARGS+=(--baseline "$FALLOW_BASELINE")
      [ -n "$FALLOW_SAVE_BASELINE" ]    && ARGS+=(--save-baseline "$FALLOW_SAVE_BASELINE")
      [ -n "$FALLOW_WORKSPACE" ]        && ARGS+=(--workspace "$FALLOW_WORKSPACE")
      [ -n "$FALLOW_CHANGED_WORKSPACES" ] && ARGS+=(--changed-workspaces "$FALLOW_CHANGED_WORKSPACES")
      [ "$FALLOW_NO_CACHE" = "true" ]   && ARGS+=(--no-cache)
      [ -n "$FALLOW_THREADS" ]          && ARGS+=(--threads "$FALLOW_THREADS")

      TYPE_AWARE_CLI_ENABLED=false
      if [ "$FALLOW_TYPE_AWARE" = "false" ] && \
         { [ -n "$FALLOW_TYPE_AWARE_PROJECTS" ] || [ -n "$FALLOW_TYPE_AWARE_REQUIRE" ]; }; then
        echo "ERROR: FALLOW_TYPE_AWARE=false conflicts with type-aware projects or completeness policy."
        exit 2
      fi
      if [ "$FALLOW_TYPE_AWARE" = "true" ]; then
        ARGS+=(--type-aware)
        TYPE_AWARE_CLI_ENABLED=true
      fi
      if [ -n "$FALLOW_TYPE_AWARE_PROJECTS" ]; then
        if [ "$TYPE_AWARE_CLI_ENABLED" != "true" ]; then
          ARGS+=(--type-aware)
          TYPE_AWARE_CLI_ENABLED=true
        fi
        IFS=',' read -ra TYPE_AWARE_PROJECTS <<< "$FALLOW_TYPE_AWARE_PROJECTS"
        for project in "${TYPE_AWARE_PROJECTS[@]}"; do
          project="$(echo "$project" | xargs)"
          [ -n "$project" ] && ARGS+=(--type-aware-project "$project")
        done
      fi
      if [ -n "$FALLOW_TYPE_AWARE_REQUIRE" ]; then
        if [ "$TYPE_AWARE_CLI_ENABLED" != "true" ]; then
          ARGS+=(--type-aware)
          TYPE_AWARE_CLI_ENABLED=true
        fi
        ARGS+=(--type-aware-require "$FALLOW_TYPE_AWARE_REQUIRE")
      fi

      # Empty GitLab variables are still exported. Remove them so the CLI can
      # distinguish "unset, defer to repository config" from an invalid empty
      # environment override. Explicit false remains exported to disable a
      # repository-level typeAware.enabled setting.
      [ -z "$FALLOW_TYPE_AWARE" ] && unset FALLOW_TYPE_AWARE
      unset FALLOW_TYPE_AWARE_PROJECTS FALLOW_TYPE_AWARE_REQUIRE

      if [ -z "$FALLOW_COMMAND" ]; then
        [ -n "$FALLOW_ONLY" ] && ARGS+=(--only "$FALLOW_ONLY")
        [ -n "$FALLOW_SKIP" ] && ARGS+=(--skip "$FALLOW_SKIP")
      fi

      case "$FALLOW_COMMAND" in
        dead-code|check)
          if [ -n "$FALLOW_ISSUE_TYPES" ]; then
            IFS=',' read -ra TYPES <<< "$FALLOW_ISSUE_TYPES"
            for t in "${TYPES[@]}"; do
              t="$(echo "$t" | xargs)"
              ARGS+=("--${t}")
              # An issue-type filter narrows the run and stands the
              # stale-baseline gate down; remember it so the gate's own
              # unscoped re-run can drop exactly what the template added.
              ISSUE_TYPE_FLAGS+=("--${t}")
            done
          fi
          [ "$FALLOW_INCLUDE_ENTRY_EXPORTS" = "true" ] && ARGS+=(--include-entry-exports)
          [ "$FALLOW_FAIL_ON_REGRESSION" = "true" ] && ARGS+=(--fail-on-regression)
          [ -n "$FALLOW_TOLERANCE" ] && [ "$FALLOW_TOLERANCE" != "0" ] && ARGS+=(--tolerance "$FALLOW_TOLERANCE")
          [ -n "$FALLOW_REGRESSION_BASELINE" ] && ARGS+=(--regression-baseline "$FALLOW_REGRESSION_BASELINE")
          [ -n "$FALLOW_SAVE_REGRESSION_BASELINE" ] && ARGS+=(--save-regression-baseline "$FALLOW_SAVE_REGRESSION_BASELINE")
          ;;
        dupes)
          ARGS+=(--mode "$FALLOW_DUPES_MODE")
          [ -n "$FALLOW_MIN_TOKENS" ]          && ARGS+=(--min-tokens "$FALLOW_MIN_TOKENS")
          [ -n "$FALLOW_MIN_LINES" ]           && ARGS+=(--min-lines "$FALLOW_MIN_LINES")
          [ -n "$FALLOW_THRESHOLD" ]           && ARGS+=(--threshold "$FALLOW_THRESHOLD")
          [ "$FALLOW_SKIP_LOCAL" = "true" ]    && ARGS+=(--skip-local)
          [ "$FALLOW_CROSS_LANGUAGE" = "true" ] && ARGS+=(--cross-language)
          [ "$FALLOW_IGNORE_IMPORTS" = "true" ] && ARGS+=(--ignore-imports)
          [ -n "$FALLOW_TOP" ]                 && ARGS+=(--top "$FALLOW_TOP")
          ;;
        health)
          [ -n "$FALLOW_MAX_CYCLOMATIC" ]     && ARGS+=(--max-cyclomatic "$FALLOW_MAX_CYCLOMATIC")
          [ -n "$FALLOW_MAX_COGNITIVE" ]      && ARGS+=(--max-cognitive "$FALLOW_MAX_COGNITIVE")
          [ -n "$FALLOW_MAX_CRAP" ]           && ARGS+=(--max-crap "$FALLOW_MAX_CRAP")
          [ -n "$FALLOW_COVERAGE" ]           && ARGS+=(--coverage "$FALLOW_COVERAGE")
          [ -n "$FALLOW_PRODUCTION_COVERAGE" ] && ARGS+=(--runtime-coverage "$FALLOW_PRODUCTION_COVERAGE")
          [ -n "$FALLOW_COVERAGE_ROOT" ]      && ARGS+=(--coverage-root "$FALLOW_COVERAGE_ROOT")
          [ -n "$FALLOW_MIN_INVOCATIONS_HOT" ] && ARGS+=(--min-invocations-hot "$FALLOW_MIN_INVOCATIONS_HOT")
          [ -n "$FALLOW_MIN_OBSERVATION_VOLUME" ] && ARGS+=(--min-observation-volume "$FALLOW_MIN_OBSERVATION_VOLUME")
          [ -n "$FALLOW_LOW_TRAFFIC_THRESHOLD" ] && ARGS+=(--low-traffic-threshold "$FALLOW_LOW_TRAFFIC_THRESHOLD")
          [ -n "$FALLOW_TOP" ]                && ARGS+=(--top "$FALLOW_TOP")
          [ -n "$FALLOW_SORT" ]               && ARGS+=(--sort "$FALLOW_SORT")
          [ "$FALLOW_SCORE" = "true" ]        && ARGS+=(--score)
          [ "$FALLOW_FILE_SCORES" = "true" ]  && ARGS+=(--file-scores)
          [ "$FALLOW_HOTSPOTS" = "true" ]     && ARGS+=(--hotspots)
          [ "$FALLOW_TARGETS" = "true" ]      && ARGS+=(--targets)
          [ "$FALLOW_COMPLEXITY" = "true" ]   && ARGS+=(--complexity)
          [ -n "$FALLOW_SINCE" ]              && ARGS+=(--since "$FALLOW_SINCE")
          [ -n "$FALLOW_MIN_COMMITS" ]        && ARGS+=(--min-commits "$FALLOW_MIN_COMMITS")
          [ -n "$FALLOW_MIN_SEVERITY" ]       && ARGS+=(--min-severity "$FALLOW_MIN_SEVERITY")
          if [ -n "$FALLOW_MIN_SCORE" ]; then
            ARGS+=(--min-score "$FALLOW_MIN_SCORE")
            # --min-score implies --score, a section selector: without this the
            # envelope carries the score and nothing else, and the Code Quality
            # report and the merge-request note both render empty.
            if [ "$FALLOW_COMPLEXITY" != "true" ] && [ "$FALLOW_FILE_SCORES" != "true" ]; then
              ARGS+=(--complexity)
            fi
          fi
          if [ -n "$FALLOW_SAVE_SNAPSHOT" ]; then
            if [ "$FALLOW_SAVE_SNAPSHOT" = "true" ]; then
              ARGS+=(--save-snapshot)
            else
              ARGS+=(--save-snapshot "$FALLOW_SAVE_SNAPSHOT")
            fi
          fi
          [ "$FALLOW_TREND" = "true" ]        && ARGS+=(--trend)
          ;;
        audit)
          [ "$FALLOW_PRODUCTION_DEAD_CODE" = "true" ] && ARGS+=(--production-dead-code)
          [ "$FALLOW_PRODUCTION_HEALTH" = "true" ] && ARGS+=(--production-health)
          [ "$FALLOW_PRODUCTION_DUPES" = "true" ] && ARGS+=(--production-dupes)
          [ -n "$FALLOW_AUDIT_DEAD_CODE_BASELINE" ] && ARGS+=(--dead-code-baseline "$FALLOW_AUDIT_DEAD_CODE_BASELINE")
          [ -n "$FALLOW_AUDIT_HEALTH_BASELINE" ] && ARGS+=(--health-baseline "$FALLOW_AUDIT_HEALTH_BASELINE")
          [ -n "$FALLOW_AUDIT_DUPES_BASELINE" ] && ARGS+=(--dupes-baseline "$FALLOW_AUDIT_DUPES_BASELINE")
          [ -n "$FALLOW_MAX_CRAP" ]           && ARGS+=(--max-crap "$FALLOW_MAX_CRAP")
          [ -n "$FALLOW_COVERAGE" ]           && ARGS+=(--coverage "$FALLOW_COVERAGE")
          [ -n "$FALLOW_COVERAGE_ROOT" ]      && ARGS+=(--coverage-root "$FALLOW_COVERAGE_ROOT")
          [ -n "$FALLOW_AUDIT_GATE" ]         && ARGS+=(--gate "$FALLOW_AUDIT_GATE")
          [ "$FALLOW_INCLUDE_ENTRY_EXPORTS" = "true" ] && ARGS+=(--include-entry-exports)
          ;;
        security)
          [ -n "$FALLOW_SECURITY_GATE" ] && ARGS+=(--gate "$FALLOW_SECURITY_GATE")
          ;;
        fix)
          [ "$FALLOW_DRY_RUN" = "true" ] && ARGS+=(--dry-run) || ARGS+=(--yes)
          ;;
        "")
          ARGS+=(--dupes-mode "$FALLOW_DUPES_MODE")
          [ -n "$FALLOW_THRESHOLD" ]         && ARGS+=(--dupes-threshold "$FALLOW_THRESHOLD")
          [ "$FALLOW_SCORE" = "true" ]        && ARGS+=(--score)
          [ "$FALLOW_TREND" = "true" ]        && ARGS+=(--trend)
          [ -n "$FALLOW_COVERAGE" ]           && ARGS+=(--coverage "$FALLOW_COVERAGE")
          [ -n "$FALLOW_COVERAGE_ROOT" ]      && ARGS+=(--coverage-root "$FALLOW_COVERAGE_ROOT")
          if [ -n "$FALLOW_SAVE_SNAPSHOT" ]; then
            if [ "$FALLOW_SAVE_SNAPSHOT" = "true" ]; then
              ARGS+=(--save-snapshot)
            else
              ARGS+=(--save-snapshot "$FALLOW_SAVE_SNAPSHOT")
            fi
          fi
          ;;
      esac

      EXTRA_ARGS=()
      RUN_ARGS=("${ARGS[@]}")
      if [ -n "$FALLOW_ARGS" ]; then
        read -ra EXTRA_ARGS <<< "$FALLOW_ARGS"
      fi

      # Path-prefix options belong to saved-envelope presentation, not the
      # JSON analysis invocation. Preserve an explicit empty prefix too.
      export FALLOW_RENDER_PATH_PREFIX_SET=0
      export FALLOW_RENDER_PATH_PREFIX=""
      FILTERED_EXTRA_ARGS=()
      for ((i = 0; i < ${#EXTRA_ARGS[@]}; i++)); do
        arg="${EXTRA_ARGS[$i]}"
        case "$arg" in
          --report-path-prefix|--annotations-path-prefix)
            if [ $((i + 1)) -ge "${#EXTRA_ARGS[@]}" ]; then
              echo "ERROR: ${arg} requires a prefix value"
              exit 2
            fi
            i=$((i + 1))
            export FALLOW_RENDER_PATH_PREFIX="${EXTRA_ARGS[$i]}"
            export FALLOW_RENDER_PATH_PREFIX_SET=1
            ;;
          --report-path-prefix=*|--annotations-path-prefix=*)
            export FALLOW_RENDER_PATH_PREFIX="${arg#*=}"
            export FALLOW_RENDER_PATH_PREFIX_SET=1
            ;;
          *) FILTERED_EXTRA_ARGS+=("$arg") ;;
        esac
      done
      EXTRA_ARGS=()
      if [ "${#FILTERED_EXTRA_ARGS[@]}" -gt 0 ]; then
        EXTRA_ARGS=("${FILTERED_EXTRA_ARGS[@]}")
        RUN_ARGS+=("${EXTRA_ARGS[@]}")
      fi

      # ── Run analysis ─────────────────────────────────────────────────
      {
        printf '%s\0' "${RUN_ARGS[@]}"
      } > fallow-analysis-args.bin

      echo "Running: fallow ${RUN_ARGS[*]}"
      set +e
      fallow "${RUN_ARGS[@]}" > fallow-results.json 2> fallow-stderr.log
      FALLOW_EXIT_CODE=$?
      set -e
      if [ "$FALLOW_EXIT_CODE" -ne 0 ] && \
         { [ ! -s fallow-results.json ] || ! jq -e '.' fallow-results.json > /dev/null 2>&1; }; then
        echo "ERROR: Fallow failed to run"
        [ -s fallow-stderr.log ] && cat fallow-stderr.log
        [ -s fallow-results.json ] && cat fallow-results.json
        exit 2
      fi
      if jq -e '.error == true' fallow-results.json > /dev/null 2>&1; then
        MESSAGE=$(jq -r '.message // "Fallow failed"' fallow-results.json)
        EXIT_CODE=$(jq -r '.exit_code // 2' fallow-results.json)
        echo "ERROR: ${MESSAGE}"
        exit "$EXIT_CODE"
      fi

      # ── Baseline staleness and the opt-in stale-baseline gate ───────
      # The CLI reports both on stderr only, which --quiet removes, so neither
      # reached a template user (issue #2673). Read them from the envelope:
      # gate_trips is the same boolean --fail-on-stale-baseline exits on, so
      # the rule stays in Rust instead of being restated in jq here.
      #
      # Reading the advisory is independent of the gate. A merge-request
      # pipeline is scoped and a scoped run cannot judge a whole-project
      # baseline, so the unscoped re-read below happens for any run that loaded
      # one; FALLOW_FAIL_ON_STALE_BASELINE only decides whether the verdict
      # fails the pipeline. Every branch that cannot read an answer warns and
      # continues, and none of them looks at $FALLOW_EXIT_CODE: the case this
      # gate exists for exits 0.
      STALE_BASELINE_GATE_FAILED=false
      BASELINE_STALENESS_JQ='.baseline_staleness // .summary.baseline_staleness // .check.baseline_staleness // empty'

      # `section` selects which staleness object to read: empty for the
      # single-analysis commands, whose object the // chain finds, or one section
      # prefix for audit, which carries up to three and whose first-match chain
      # would report one of them under every label.
      read_staleness_field() {
        local file=$1 field=$2 section=${3:-}
        local selector="${BASELINE_STALENESS_JQ}"
        if [ -n "$section" ]; then
          selector="${section}.baseline_staleness // empty"
        fi
        # `// empty` cannot be used here: jq treats `false` as absent, which
        # would silently blank `change_scoped: false` and `gate_trips: false`.
        jq -r --arg field "$field" \
          "(${selector}) | if has(\$field) then .[\$field] else empty end" \
          "$file" || true
      }

      # scope_reasons needs its own reader: it is an array, and the scalar
      # reader above returns the raw jq rendering of one, not a log line.
      read_staleness_scope_reasons() {
        local file=$1
        jq -r "(${BASELINE_STALENESS_JQ}) | (.scope_reasons // []) | join(\", \")" \
          "$file" || true
      }

      read_all_staleness_fields() {
        local file=$1
        BASELINE_ENTRIES=$(read_staleness_field "$file" baseline_entries)
        BASELINE_STALE_ENTRIES=$(read_staleness_field "$file" stale_entries)
        BASELINE_ADVISORY=$(read_staleness_field "$file" warning)
        BASELINE_GATE_TRIPS=$(read_staleness_field "$file" gate_trips)
        BASELINE_CHANGE_SCOPED=$(read_staleness_field "$file" change_scoped)
        BASELINE_UNRECOGNISED=$(read_staleness_field "$file" unrecognised_format)
        BASELINE_SCOPE_REASONS=$(read_staleness_scope_reasons "$file")
      }

      read_all_staleness_fields fallow-results.json

      # Production mode and workspace scoping are the user's own choice about
      # what to analyze, so they are never removed and a run narrowed by them
      # stands down instead.
      #
      # Driven by the run's own scope_reasons when the binary reports them, so
      # scoping smuggled through FALLOW_ARGS is visible here instead of sending
      # the template into a re-read that comes back narrowed anyway. A binary
      # that predates the member falls back to the variable-based guess.
      BASELINE_REMOVABLE_SCOPE_REASONS="diff changed-since changed-files scope file issue-type-filter"

      template_can_rerun_unscoped() {
        if [ -n "${BASELINE_SCOPE_REASONS:-}" ]; then
          local reason
          for reason in $(printf '%s' "$BASELINE_SCOPE_REASONS" | tr ',' ' '); do
            case " ${BASELINE_REMOVABLE_SCOPE_REASONS} " in
              *" ${reason} "*) ;;
              *) return 1 ;;
            esac
          done
          return 0
        fi
        if [ "$FALLOW_PRODUCTION" = "true" ]; then return 1; fi
        if [ "$FALLOW_PRODUCTION_DEAD_CODE" = "true" ]; then return 1; fi
        if [ "$FALLOW_PRODUCTION_HEALTH" = "true" ]; then return 1; fi
        if [ "$FALLOW_PRODUCTION_DUPES" = "true" ]; then return 1; fi
        if [ -n "$FALLOW_WORKSPACE" ]; then return 1; fi
        if [ -n "$FALLOW_CHANGED_WORKSPACES" ]; then return 1; fi
        return 0
      }

      # An element-wise copy of the analysis argv with every narrowing flag and
      # every workspace-writing flag removed. Never rebuilt from $FALLOW_ARGS:
      # re-splitting user input would reintroduce word splitting.
      build_stale_gate_args() {
        GATE_ARGS=()
        local skip_next=false skip_next_if_value=false arg dropped flag
        for arg in "${RUN_ARGS[@]}"; do
          if [ "$skip_next" = "true" ]; then
            skip_next=false
            continue
          fi
          # --save-snapshot takes an optional value, so its argument is only
          # the next element when that element is not itself a flag.
          if [ "$skip_next_if_value" = "true" ]; then
            skip_next_if_value=false
            case "$arg" in
              --*) ;;
              *) continue ;;
            esac
          fi
          case "$arg" in
            --changed-since|--scope|--file)
              skip_next=true
              continue
              ;;
            --changed-since=*|--scope=*|--file=*)
              continue
              ;;
            --save-baseline|--save-regression-baseline)
              skip_next=true
              continue
              ;;
            --save-baseline=*|--save-regression-baseline=*|--save-snapshot=*)
              continue
              ;;
            --save-snapshot)
              skip_next_if_value=true
              continue
              ;;
            --fail-on-regression|--yes)
              continue
              ;;
          esac
          dropped=false
          for flag in ${ISSUE_TYPE_FLAGS[@]+"${ISSUE_TYPE_FLAGS[@]}"}; do
            if [ "$arg" = "$flag" ]; then
              dropped=true
              break
            fi
          done
          if [ "$dropped" = "true" ]; then
            continue
          fi
          GATE_ARGS+=("$arg")
        done
      }

      # Re-read the baseline over the whole project. Report-discarding: the
      # envelope feeds nothing but the staleness read and is removed after, so
      # no consumer can mistake it for the run the MR note was built from.
      run_stale_gate_analysis() {
        build_stale_gate_args
        echo "Re-reading the baseline over the whole project, which a scoped run cannot judge"
        local started ended
        started=$SECONDS
        set +e
        env -u FALLOW_DIFF_FILE fallow "${GATE_ARGS[@]}" \
          > fallow-stale-baseline-gate-raw.json 2> fallow-stale-baseline-gate-stderr.log
        set -e
        ended=$SECONDS
        echo "Unscoped baseline re-read finished in $((ended - started))s"
        if [ -s fallow-stale-baseline-gate-stderr.log ]; then
          echo "--- baseline re-read stderr ---"
          cat fallow-stale-baseline-gate-stderr.log
          echo "---"
        fi
        if [ ! -s fallow-stale-baseline-gate-raw.json ] \
            || ! jq -e '.' fallow-stale-baseline-gate-raw.json > /dev/null 2>&1; then
          return 1
        fi
        jq -s 'last' fallow-stale-baseline-gate-raw.json > fallow-stale-baseline-gate.json || return 1
        if jq -e '.error == true' fallow-stale-baseline-gate.json > /dev/null 2>&1; then
          return 1
        fi
        return 0
      }

      # Name the gate only when it was asked for, so a pipeline that wanted no
      # gate does not read as if one failed.
      # A run that asked for the gate and did not get one has a problem worth a
      # warning. A run that asked for nothing does not: production mode plus a
      # baseline is an ordinary configuration, and warning on every merge
      # request about a judgement nobody requested is noise the project cannot
      # turn off. The CLI itself is silent there, so the level matches it.
      # The channels that narrowed the run, as a parenthetical for a log line.
      # Empty when the binary does not report them.
      baseline_scope_clause() {
        if [ -n "${BASELINE_SCOPE_REASONS:-}" ]; then
          printf ' (%s)' "$BASELINE_SCOPE_REASONS"
        fi
      }

      # Why a narrowed run cannot be re-read unscoped. Falls back to the two
      # variables the guess is built from, for a binary that reports no
      # scope_reasons.
      baseline_unremovable_scope_clause() {
        if [ -n "${BASELINE_SCOPE_REASONS:-}" ]; then
          printf ' (%s)' "$BASELINE_SCOPE_REASONS"
        else
          printf ' (production mode or workspace scoping)'
        fi
      }

      stale_baseline_stand_down() {
        if [ "$FALLOW_FAIL_ON_STALE_BASELINE" = "true" ]; then
          echo "WARNING: baseline staleness could not be judged on this run because ${1}. FALLOW_FAIL_ON_STALE_BASELINE stood down. ${2}"
        else
          echo "NOTE: baseline staleness could not be judged on this run because ${1}. ${2}"
        fi
      }

      if [ -n "$FALLOW_BASELINE" ] && [ -z "$BASELINE_ENTRIES" ]; then
        if [ "$FALLOW_FAIL_ON_STALE_BASELINE" = "true" ]; then
          stale_baseline_stand_down "it reported no baseline staleness" "A fallow that predates this feature cannot report it: pin a current version, or run the gate on dead-code, dupes or health."
        fi
      elif [ "$BASELINE_CHANGE_SCOPED" = "true" ]; then
        if template_can_rerun_unscoped; then
          if run_stale_gate_analysis; then
            read_all_staleness_fields fallow-stale-baseline-gate.json
            if [ "$BASELINE_CHANGE_SCOPED" = "true" ]; then
              stale_baseline_stand_down "the unscoped re-read was still narrowed to part of the project$(baseline_scope_clause)" "Remove the positional path from FALLOW_ARGS to judge the baseline."
            fi
          else
            stale_baseline_stand_down "the unscoped baseline re-read produced no readable result" "The primary analysis is unaffected; its stderr is printed above."
          fi
          rm -f fallow-stale-baseline-gate-raw.json fallow-stale-baseline-gate.json fallow-stale-baseline-gate-stderr.log
        else
          stale_baseline_stand_down "it analyzed only part of the project$(baseline_unremovable_scope_clause)" "Run an unscoped pipeline to judge the baseline."
        fi
      fi

      # fallow audit loads up to three baselines and judges none of them:
      # every audit narrows to the files that changed against its base. It says
      # so once on stderr, which --quiet removes, so an audit user never learned
      # that the baseline they pass is inert (issue #2677).
      #
      # Read each section separately rather than lengthening the
      # single-analysis // chain: that chain is first-match, so an audit with
      # three baselines would report one and hide the other two.
      audit_baseline_notices() {
        local file=$1 row label command input section entries unrecognised path
        # label:jq-prefix:command:variable. The label names the envelope
        # section a reader goes looking in; the command is what they have to
        # run, and the two differ: duplication is served by fallow dupes and
        # complexity by fallow health.
        for row in \
          'dead-code:.dead_code:dead-code:FALLOW_AUDIT_DEAD_CODE_BASELINE' \
          'duplication:.duplication:dupes:FALLOW_AUDIT_DUPES_BASELINE' \
          'complexity:.complexity.summary:health:FALLOW_AUDIT_HEALTH_BASELINE'
        do
          label=${row%%:*}
          section=$(printf '%s' "$row" | cut -d: -f2)
          command=$(printf '%s' "$row" | cut -d: -f3)
          input=${row##*:}
          # Through the shared reader, so this loop reads a member the same
          # way the single-analysis path does. The reader guards with has, which
          # keeps a literal false distinct from an absent member. The inline
          # // empty it replaces collapsed the two. No consumer here saw a
          # difference, because each one compares the value against true.
          entries=$(read_staleness_field "$file" baseline_entries "$section")
          if [ -z "$entries" ]; then
            continue
          fi
          unrecognised=$(read_staleness_field "$file" unrecognised_format "$section")
          path=$(eval "printf '%s' \"\${${input}:-}\"")
          if [ "$unrecognised" = "true" ]; then
            if [ -n "$path" ]; then
              echo "WARNING: the ${label} baseline at ${path} has no entries this command recognises. It may be a baseline saved by another command, or an empty file. Either way it suppresses nothing."
            else
              echo "WARNING: the ${label} baseline has no entries this command recognises. It may be a baseline saved by another command, or an empty file. Either way it suppresses nothing."
            fi
            continue
          fi
          if [ -n "$path" ]; then
            echo "NOTICE: the ${label} baseline (${path}) has ${entries} entries and was not judged on this run: fallow audit analyzes only the files that changed against its base. Run 'fallow ${command} --baseline ${path}' over the whole project to check it."
          else
            echo "NOTICE: the ${label} baseline has ${entries} entries and was not judged on this run: fallow audit analyzes only the files that changed against its base. Run 'fallow ${command}' with that baseline over the whole project to check it."
          fi
        done
      }

      if [ "$FALLOW_COMMAND" = "audit" ]; then
        audit_baseline_notices fallow-results.json
      fi

      # A baseline written by another command suppresses nothing, so the counts
      # below are all zero and read exactly like a baseline saved on a project
      # that had nothing to record. A project that pointed FALLOW_BASELINE at
      # the wrong file would otherwise gate on it forever.
      #
      # Ahead of the advisory rather than beside it: the binary now trips the
      # gate on such a file, so the *) arm below would add "0 of 0 baseline
      # entries matched nothing this run" next to the line that says what is
      # actually wrong.
      #
      # Keyed on the binary's own verdict rather than on a zero entry count,
      # which a baseline saved on a green main with nothing to record carries
      # too: warning on every pipeline about a correctly saved baseline is noise
      # the project cannot turn off. Not gated on FALLOW_BASELINE either, so a
      # baseline passed through FALLOW_ARGS earns the same line; the path is
      # named only when the template knows it.
      #
      # The advisory and the gate answer different questions and legitimately
      # disagree, so speak on either. A rotted baseline on a project with
      # nothing left to report is warning "none" with gate_trips true, which is
      # exactly the case issue #2673 was filed about.
      if [ "${BASELINE_UNRECOGNISED:-}" = "true" ]; then
        if [ -n "$FALLOW_BASELINE" ]; then
          echo "WARNING: the baseline at ${FALLOW_BASELINE} has no entries this command recognises. It may be a baseline saved by another command, or an empty file. Either way it suppresses nothing."
        else
          echo "WARNING: the loaded baseline has no entries this command recognises. It may be a baseline saved by another command, or an empty file. Either way it suppresses nothing."
        fi
      elif [ -n "$BASELINE_ENTRIES" ]; then
        case "$BASELINE_ADVISORY" in
          partial)
            echo "WARNING: baseline is partially stale: ${BASELINE_STALE_ENTRIES} of ${BASELINE_ENTRIES} entries matched nothing this run, so it protects less than what was saved. Re-save it with FALLOW_SAVE_BASELINE."
            ;;
          zero-overlap)
            echo "WARNING: baseline has ${BASELINE_ENTRIES} entries but matched nothing this run. Paths may have changed, or the baseline was saved elsewhere. Re-save it with FALLOW_SAVE_BASELINE."
            ;;
          *)
            if [ "$BASELINE_GATE_TRIPS" = "true" ]; then
              echo "WARNING: ${BASELINE_STALE_ENTRIES} of ${BASELINE_ENTRIES} baseline entries matched nothing this run. The project may be clean, or the baseline may no longer describe it. Re-save it with FALLOW_SAVE_BASELINE."
            fi
            ;;
        esac
      fi

      if [ "$FALLOW_FAIL_ON_STALE_BASELINE" = "true" ] && [ "$BASELINE_GATE_TRIPS" = "true" ]; then
        STALE_BASELINE_GATE_FAILED=true
      fi

      TYPE_AWARE_COMPLETENESS_FAILED=false
      if [ "$FALLOW_EXIT_CODE" -ne 0 ] && jq -e '
        (
          ._meta.type_aware
          // ._meta.check.type_aware
          // .check._meta.type_aware
          // .dead_code._meta.type_aware
          // empty
        ) as $type_aware
        | ($type_aware.required_completeness == "complete")
          and (
            ($type_aware.identity.completeness != "complete")
            or ([ $type_aware.queries[]? | select(.status != "complete") ] | length > 0)
          )
      ' fallow-results.json > /dev/null 2>&1; then
        TYPE_AWARE_COMPLETENESS_FAILED=true
      fi

      if [ -s fallow-stderr.log ]; then
        echo "--- fallow stderr ---"
        cat fallow-stderr.log
        echo "---"
      fi

      # ── Extract verdict / gate (audit only) and issue count ─────────
      # Audit's verdict (pass/warn/fail) is the load-bearing severity-aware
      # signal: warn means "warn-tier only, do not fail". Fail check gates
      # on verdict for audit; raw counts only gate non-audit commands.
      VERDICT=""
      AUDIT_GATE=""
      if [ "$FALLOW_COMMAND" = "audit" ]; then
        VERDICT=$(jq -r '.verdict // ""' fallow-results.json)
        AUDIT_GATE=$(jq -r '.attribution.gate // ""' fallow-results.json)
      fi

      case "$FALLOW_COMMAND" in
        dead-code|check) ISSUES=$(jq -r '.total_issues // 0' fallow-results.json) ;;
        dupes)           ISSUES=$(jq -r '.stats.clone_groups // 0' fallow-results.json) ;;
        health)          ISSUES=$(jq -r '((.summary.functions_above_threshold // 0) + ((.runtime_coverage.findings // []) | map(select(.verdict == "safe_to_delete" or .verdict == "review_required" or .verdict == "low_traffic")) | length))' fallow-results.json) ;;
        audit)           ISSUES=$(jq -r 'if (.attribution.gate // "new-only") == "all" then ((.summary.dead_code_issues // 0) + (.summary.complexity_findings // 0) + (.summary.duplication_clone_groups // 0)) else ((.attribution.dead_code_introduced // 0) + (.attribution.complexity_introduced // 0) + (.attribution.duplication_introduced // 0)) end' fallow-results.json) ;;
        security)        ISSUES=$(jq -r 'if .gate then (.gate.new_count // 0) else (.summary.security_findings // ((.security_findings // []) | length)) end' fallow-results.json) ;;
        fix)             ISSUES=$(jq -r '(.fixes | length)' fallow-results.json) ;;
        "")              ISSUES=$(jq -r '((.check.total_issues // 0) + (((.dupes.clone_groups // []) | length) + (.dupes.clone_groups_omitted // 0)) + (.health.summary.functions_above_threshold // 0) + ((.health.runtime_coverage.findings // []) | map(select(.verdict == "safe_to_delete" or .verdict == "review_required" or .verdict == "low_traffic")) | length))' fallow-results.json) ;;
      esac

      if ! echo "$ISSUES" | grep -qE '^[0-9]+$'; then
        echo "ERROR: Unexpected issue count: ${ISSUES}"; exit 2
      fi
      echo "Found ${ISSUES} issues"

      # ── GitLab Code Quality report (CodeClimate format) ──────────────
      # Re-renders the saved JSON envelope for inline MR annotations.
      if [ "$FALLOW_CODEQUALITY" = "true" ] && [ "$FALLOW_COMMAND" != "fix" ] && [ "$FALLOW_COMMAND" != "security" ]; then
        echo "Generating Code Quality report..."
        REPORT_ARGS=(report --from fallow-results.json --root "$FALLOW_ROOT" --format codeclimate --quiet)
        [ -n "$FALLOW_CONFIG" ] && REPORT_ARGS+=(--config "$FALLOW_CONFIG")
        [ -n "$FALLOW_WORKSPACE" ] && REPORT_ARGS+=(--workspace "$FALLOW_WORKSPACE")
        [ "$FALLOW_RENDER_PATH_PREFIX_SET" = "1" ] \
          && REPORT_ARGS+=(--report-path-prefix "${FALLOW_RENDER_PATH_PREFIX:-}")
        set +e
        fallow "${REPORT_ARGS[@]}" > gl-code-quality-report.json 2> fallow-codequality-stderr.log
        CODEQUALITY_EXIT=$?
        set -e
        if [ ! -s gl-code-quality-report.json ] || ! jq -e 'type == "array"' gl-code-quality-report.json > /dev/null 2>&1; then
          echo "ERROR: GitLab Code Quality rendering failed."
          [ -s fallow-codequality-stderr.log ] && cat fallow-codequality-stderr.log
          [ "$CODEQUALITY_EXIT" -ne 0 ] || CODEQUALITY_EXIT=2
          exit "$CODEQUALITY_EXIT"
        fi
        CQ_COUNT=$(jq '. | length' gl-code-quality-report.json || echo 0)
        echo "Code Quality report: ${CQ_COUNT} findings"
      else
        echo "[]" > gl-code-quality-report.json
      fi

      # ── MR summary comment ──────────────────────────────────────────
      if [ "$FALLOW_COMMENT" = "true" ] && [ "$FALLOW_COMMAND" != "security" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ]; then
        if [ -x "/tmp/fallow-scripts/comment.sh" ]; then
          echo "Posting MR summary comment..."
          export CHANGED_SINCE="$FALLOW_CHANGED_SINCE"
          export INPUT_ROOT="${FALLOW_ROOT:-.}"
          bash /tmp/fallow-scripts/comment.sh || echo "WARNING: MR comment failed"
        else
          echo "WARNING: comment.sh not available, skipping MR comment"
        fi
      fi

      # ── Inline MR review discussions ─────────────────────────────────
      if [ "$FALLOW_REVIEW" = "true" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ] && [ "$FALLOW_COMMAND" != "fix" ] && [ "$FALLOW_COMMAND" != "security" ]; then
        if [ -x "/tmp/fallow-scripts/review.sh" ]; then
          echo "Posting inline MR review..."
          export MAX_COMMENTS="$FALLOW_MAX_COMMENTS"
          export CHANGED_SINCE="$FALLOW_CHANGED_SINCE"
          bash /tmp/fallow-scripts/review.sh || echo "WARNING: MR review failed"
        else
          echo "WARNING: review.sh not available, skipping MR review"
        fi
      fi

      # ── Gate verdicts ────────────────────────────────────────────────
      #
      # Every gate the run armed publishes status and enforced in
      # gate_outcomes at the envelope root, computed by the same rule that
      # decides the exit code. The template reads that instead of
      # FALLOW_EXIT_CODE, which it deliberately discards whenever stdout
      # parses as JSON: the case these gates exist for exits 0.
      #
      # A gate fails the pipeline only when the variable that owns it asked
      # for it, its status is fail, and the CLI marked it enforced. That is
      # what keeps FALLOW_FAIL_ON_ISSUES: "false" authoritative for a flag
      # that arrived through FALLOW_ARGS.
      GATE_FAILURES=()
      GATE_FAILED_NAMES=""
      GATE_WARNED_NAMES=""
      GATE_SKIPPED_NAMES=""
      GATE_PASSED_NAMES=""
      SECURITY_GATE_FAILED=false

      gate_owning_value() {
        case "$1" in
          regression)            printf '%s' "$FALLOW_FAIL_ON_REGRESSION" ;;
          duplication-threshold) printf '%s' "$FALLOW_THRESHOLD" ;;
          health-min-severity)   printf '%s' "$FALLOW_MIN_SEVERITY" ;;
          health-min-score)      printf '%s' "$FALLOW_MIN_SCORE" ;;
          security)              printf '%s' "$FALLOW_SECURITY_GATE" ;;
          stale-baseline)        printf '%s' "$FALLOW_FAIL_ON_STALE_BASELINE" ;;
          type-aware-require)    printf '%s' "$FALLOW_TYPE_AWARE_REQUIRE" ;;
          *)                     printf '%s' "" ;;
        esac
      }

      gate_is_owned() {
        local value
        value=$(gate_owning_value "$1")
        case "$value" in
          ""|false|0) return 1 ;;
          *) return 0 ;;
        esac
      }

      # has() rather than // empty, because jq treats a false value as absent
      # under the alternative operator. Never a bare jq -e in an assignment:
      # this script runs under set -euo pipefail.
      read_gate_member() {
        jq -r --arg gate "$1" --arg member "$2" '
          (.gate_outcomes // {}) as $gates
          | if ($gates | has($gate)) and ($gates[$gate] | has($member))
            then ($gates[$gate][$member] | tostring)
            else "" end
        ' fallow-results.json || true
      }

      # The envelope carries these as JSON numbers, so a whole value arrives
      # as "3.0". Trim it for prose; the wire keeps the number.
      trim_gate_number() {
        case "$1" in
          *.0) printf '%s' "${1%.0}" ;;
          *) printf '%s' "$1" ;;
        esac
      }

      gate_detail() {
        case "$1" in
          regression)
            local delta
            delta=$(jq -r '(.regression.delta // .check.regression.delta // "") | tostring' fallow-results.json || true)
            [ -n "$delta" ] && printf 'issue count rose by %s (tolerance %s)' "$delta" "$FALLOW_TOLERANCE"
            ;;
          duplication-threshold)
            local observed threshold
            observed=$(read_gate_member duplication-threshold observed)
            threshold=$(read_gate_member duplication-threshold threshold)
            [ -n "$observed" ] && printf 'duplication %s%% exceeds the %s%% threshold' "$(trim_gate_number "$observed")" "$(trim_gate_number "$threshold")"
            ;;
          health-min-score)
            local observed threshold
            observed=$(read_gate_member health-min-score observed)
            threshold=$(read_gate_member health-min-score threshold)
            [ -n "$observed" ] && printf 'health score %s is below the minimum %s' "$(trim_gate_number "$observed")" "$(trim_gate_number "$threshold")"
            ;;
          health-min-severity)
            local observed floor
            observed=$(read_gate_member health-min-severity observed)
            floor=$(read_gate_member health-min-severity threshold_label)
            [ -n "$observed" ] && printf '%s finding(s) at or above %s' "$(trim_gate_number "$observed")" "$floor"
            ;;
          security)
            local new_count
            new_count=$(jq -r '(.gate.new_count // "") | tostring' fallow-results.json || true)
            [ -n "$new_count" ] && printf '%s new security candidate(s) on changed lines (gate: %s)' "$new_count" "$FALLOW_SECURITY_GATE"
            ;;
        esac
        # An empty detail must not make this function return non-zero: the case
        # branches end in `&&` lists, and the caller assigns the result under
        # errexit.
        return 0
      }

      record_gate_failure() {
        local gate=$1 detail line
        if [ "$gate" = "stale-baseline" ]; then
          # The gate also trips on a file this command cannot read as its own,
          # whose counts are all zero: re-saving is not the remedy there, and
          # "0 of 0 entries matched nothing" names nothing to act on.
          if [ "${BASELINE_UNRECOGNISED:-}" = "true" ]; then
            GATE_FAILURES+=("Fallow baseline gate failed: the baseline ${FALLOW_BASELINE:-passed to this pipeline} has no entries this command recognises, so it suppresses nothing. Point FALLOW_BASELINE at this command's own baseline, or set FALLOW_FAIL_ON_STALE_BASELINE to false.")
            return
          fi
          GATE_FAILURES+=("Fallow baseline gate failed: ${BASELINE_STALE_ENTRIES} of ${BASELINE_ENTRIES} entries in ${FALLOW_BASELINE} matched nothing this run. Re-save the baseline, or set FALLOW_FAIL_ON_STALE_BASELINE to false.")
          return
        fi
        if [ "$gate" = "type-aware-require" ]; then
          GATE_FAILURES+=("Type-aware completeness gate failed because semantic analysis was unavailable or partial.")
          return
        fi
        detail=$(gate_detail "$gate")
        line="Fallow ${gate} gate failed"
        if [ -n "$detail" ]; then
          line="${line}: ${detail}"
        fi
        GATE_FAILURES+=("${line}.")
        if [ "$gate" = "security" ]; then
          SECURITY_GATE_FAILED=true
        fi
      }

      classify_gate() {
        local gate=$1 status=$2 enforced=$3 detail
        case "$status" in
          fail)
            GATE_FAILED_NAMES="${GATE_FAILED_NAMES:+${GATE_FAILED_NAMES},}${gate}"
            if gate_is_owned "$gate" && [ "$enforced" = "true" ]; then
              record_gate_failure "$gate"
            elif [ "$gate" = "error-severity-findings" ] || [ "$gate" = "audit-verdict" ]; then
              # Both are governed by FALLOW_FAIL_ON_ISSUES rather than by a
              # variable of their own. The severity rule is the CLI's, which the
              # count gate deliberately does not follow; the audit verdict is
              # already applied by the count gate below, and an audit pipeline
              # with FALLOW_FAIL_ON_ISSUES false is a deliberate reporting
              # configuration. Both reach dotenv and never the log.
              :
            elif gate_is_owned "$gate"; then
              # The variable asked for the gate and the CLI still reports it
              # unenforced, which is the CLI saying this run could not have
              # exited on it: combined mode collapses every gate but the
              # baseline and regression ones. This is the default job, so say
              # which it was rather than blaming the variable.
              detail=$(gate_detail "$gate")
              echo "WARNING: Fallow ${gate} gate reports a failure${detail:+: ${detail}}. It does not fail this pipeline: the combined run does not enforce that gate. Set FALLOW_COMMAND to the dedicated command to gate on it."
            else
              detail=$(gate_detail "$gate")
              echo "WARNING: Fallow ${gate} gate reports a failure${detail:+: ${detail}}. It does not fail this pipeline, because its variable is not set."
            fi
            ;;
          warn)
            GATE_WARNED_NAMES="${GATE_WARNED_NAMES:+${GATE_WARNED_NAMES},}${gate}"
            echo "WARNING: Fallow ${gate} gate reports a warning."
            ;;
          skipped)
            GATE_SKIPPED_NAMES="${GATE_SKIPPED_NAMES:+${GATE_SKIPPED_NAMES},}${gate}"
            if gate_is_owned "$gate"; then
              echo "WARNING: Fallow ${gate} gate stood down, so the run it was asked to judge was not judged."
            else
              echo "NOTE: Fallow ${gate} gate stood down."
            fi
            ;;
          pass)
            GATE_PASSED_NAMES="${GATE_PASSED_NAMES:+${GATE_PASSED_NAMES},}${gate}"
            ;;
          *)
            # The status set is open. A value this template does not recognise
            # is reported rather than silently counted as a pass.
            GATE_WARNED_NAMES="${GATE_WARNED_NAMES:+${GATE_WARNED_NAMES},}${gate}"
            echo "WARNING: Fallow ${gate} gate reported an unrecognised status '${status}'. Upgrade the template, or read gate_outcomes directly."
            ;;
        esac
      }

      HAS_GATE_OUTCOMES=false
      if jq -e 'has("gate_outcomes")' fallow-results.json > /dev/null 2>&1; then
        HAS_GATE_OUTCOMES=true
      fi

      if [ "$HAS_GATE_OUTCOMES" = "true" ]; then
        while IFS= read -r gate_key; do
          [ -z "$gate_key" ] && continue
          case "$gate_key" in
            *[!a-z0-9-]*) continue ;;
          esac
          # The stale-baseline gate is owned by the #2674 machinery below, which
          # judges the unscoped re-read rather than this envelope. A
          # merge-request pipeline is change-scoped, so this envelope reports
          # `skipped` and classifying it here would print a stand-down beside
          # that block's own error.
          if [ "$gate_key" = "stale-baseline" ] && [ -n "$FALLOW_BASELINE" ]; then
            continue
          fi
          classify_gate "$gate_key" "$(read_gate_member "$gate_key" status)" "$(read_gate_member "$gate_key" enforced)"
        done < <(jq -r '(.gate_outcomes // {}) | keys[]?' fallow-results.json || true)
      else
        # A pinned binary older than the gate index. Read the feature-local
        # field each gate already published, and fail OPEN for the three that
        # never had one, warning only when their variable was actually set.
        if gate_is_owned regression; then
          if jq -e '(.regression.exceeded // .check.regression.exceeded) == true' fallow-results.json > /dev/null 2>&1; then
            classify_gate regression fail true
          fi
        fi
        if gate_is_owned security; then
          if jq -e '.gate.verdict == "fail"' fallow-results.json > /dev/null 2>&1; then
            classify_gate security fail true
          fi
        fi
        FALLBACK_UNAVAILABLE=""
        for fallback_gate in duplication-threshold health-min-score health-min-severity; do
          if gate_is_owned "$fallback_gate"; then
            FALLBACK_UNAVAILABLE="${FALLBACK_UNAVAILABLE:+${FALLBACK_UNAVAILABLE}, }${fallback_gate}"
          fi
        done
        if [ -n "$FALLBACK_UNAVAILABLE" ]; then
          echo "WARNING: Fallow did not publish gate verdicts, so ${FALLBACK_UNAVAILABLE} could not be checked. Upgrade FALLOW_VERSION to 3.27.0 or later."
        fi
      fi

      if [ "$STALE_BASELINE_GATE_FAILED" = "true" ]; then
        case ",${GATE_FAILED_NAMES}," in
          *,stale-baseline,*) ;;
          *) GATE_FAILED_NAMES="${GATE_FAILED_NAMES:+${GATE_FAILED_NAMES},}stale-baseline"; record_gate_failure stale-baseline ;;
        esac
      fi
      if [ "$TYPE_AWARE_COMPLETENESS_FAILED" = "true" ]; then
        case ",${GATE_FAILED_NAMES}," in
          *,type-aware-require,*) ;;
          *) GATE_FAILED_NAMES="${GATE_FAILED_NAMES:+${GATE_FAILED_NAMES},}type-aware-require"; record_gate_failure type-aware-require ;;
        esac
      fi

      # ── Degraded analysis ────────────────────────────────────────────
      ANALYSIS_DEGRADED=false
      DEGRADED_SUMMARY=$(jq -r '
        [ (.workspace_diagnostics // .dead_code.workspace_diagnostics // [])[] | select(.degrades_analysis == true) ]
        | group_by(.kind)
        | map("\(.[0].kind) (\(length))")
        | join(", ")
      ' fallow-results.json || true)
      if [ -n "$DEGRADED_SUMMARY" ]; then
        ANALYSIS_DEGRADED=true
        echo "WARNING: Fallow ran with degraded inputs: ${DEGRADED_SUMMARY}. Some findings or scores were computed over less than the whole project, or from an input that did not load."
      fi
      # ── Requests the run could not apply ─────────────────────────────
      #
      # The CLI also writes this to stderr, and this job runs it with
      # --quiet --format json, so the envelope is the only channel that
      # reaches the pipeline. One aggregated line, for the same reason the
      # degraded-analysis block aggregates.
      # Selected on `affects == "scope"`, never on a name list: the object also
      # carries requests that produce a file beside the report, whose failure
      # says nothing about the report's scope, and a name added later carries
      # its own class.
      REQUESTS_UNAPPLIED=$(jq -r '
        [ (.request_outcomes // {}) | to_entries[]
          | select(.value.status != "applied" and .value.affects == "scope")
          | if .value.reason then "\(.key) (\(.value.reason))" else .key end ]
        | join(", ")
      ' fallow-results.json || true)
      if [ -n "$REQUESTS_UNAPPLIED" ]; then
        echo "WARNING: Fallow could not apply: ${REQUESTS_UNAPPLIED}. The findings below cover more of the project than was requested, so do not read this run as scoped to the change."
      fi
      # The opposite shape: a narrowing request that DID apply, over a scope it
      # measured as empty. Every finding filters out, so the clean report below
      # covered nothing. A binary that publishes no `scope_size` says nothing
      # here.
      REQUESTS_EMPTY_SCOPE=$(jq -r '
        [ (.request_outcomes // {}) | to_entries[]
          | select(.value.status == "applied" and .value.affects == "scope" and .value.scope_size == 0)
          | .key ]
        | join(", ")
      ' fallow-results.json || true)
      if [ -n "$REQUESTS_EMPTY_SCOPE" ]; then
        echo "WARNING: Fallow applied ${REQUESTS_EMPTY_SCOPE} over an empty scope, so no finding could survive it and the report below is clean because nothing was analyzable. Check the diff or ref this run was given before reading it as a clean result."
      fi

      if jq -e '[ (.workspace_diagnostics // .dead_code.workspace_diagnostics // [])[] | select(.kind == "no-source-files-analyzed") ] | length > 0' fallow-results.json > /dev/null 2>&1; then
        EMPTY_ANALYSIS_MESSAGE="Fallow analyzed no source file at all, so every count this run reports is zero because nothing was measured, not because the project is clean. Check FALLOW_ROOT, ignorePatterns, and any path or workspace filter."
        if [ "$FALLOW_FAIL_ON_EMPTY_ANALYSIS" = "true" ]; then
          GATE_FAILURES+=("$EMPTY_ANALYSIS_MESSAGE")
        else
          echo "WARNING: ${EMPTY_ANALYSIS_MESSAGE} Set FALLOW_FAIL_ON_EMPTY_ANALYSIS to true to fail the pipeline on this."
        fi
      fi

      # ── Fail check ───────────────────────────────────────────────────
      #
      # The count gate joins the same accumulator, so a run with both a tripped
      # gate and findings reports both rather than exiting on the first.
      if [ "$FALLOW_FAIL_ON_ISSUES" = "true" ]; then
        if [ "$FALLOW_COMMAND" = "audit" ]; then
          # Audit gates on rule severity. Verdict encodes the gate decision:
          # pass -> no issues, warn -> warn-tier only (do not fail),
          # fail -> error-tier (fail). Counting introduced findings instead
          # would re-introduce the bug issue #302 was filed to fix.
          if [ "$VERDICT" = "fail" ]; then
            GATE_FAILURES+=("Fallow audit failed (gate: ${AUDIT_GATE:-new-only}, ${ISSUES} finding(s) at error severity in changed files)")
          fi
        elif [ "$ISSUES" -gt 0 ] && [ "$(read_gate_member health-findings status)" != "skipped" ]; then
          # FALLOW_MIN_SCORE turns the CLI's findings rule off, and the envelope
          # says so with `health-findings: skipped`. Counting them here would
          # fail a run the CLI deliberately passed.
          case "$FALLOW_COMMAND" in
            dead-code|check) GATE_FAILURES+=("Fallow found ${ISSUES} unused code issues") ;;
            dupes)           GATE_FAILURES+=("Fallow found ${ISSUES} clone groups") ;;
            health)          GATE_FAILURES+=("Fallow found ${ISSUES} health findings") ;;
            security)        GATE_FAILURES+=("Fallow found ${ISSUES} security candidates") ;;
            fix)             GATE_FAILURES+=("Fallow found ${ISSUES} fixable issues") ;;
            "")              GATE_FAILURES+=("Fallow found ${ISSUES} issues") ;;
          esac
        fi
      fi

      # dotenv so a downstream job can branch on the verdict without re-reading
      # the envelope.
      {
        echo "FALLOW_GATES_FAILED=${GATE_FAILED_NAMES}"
        echo "FALLOW_GATES_WARNED=${GATE_WARNED_NAMES}"
        echo "FALLOW_GATES_SKIPPED=${GATE_SKIPPED_NAMES}"
        echo "FALLOW_GATES_PASSED=${GATE_PASSED_NAMES}"
        echo "FALLOW_ANALYSIS_DEGRADED=${ANALYSIS_DEGRADED}"
        echo "FALLOW_REQUESTS_UNAPPLIED=${REQUESTS_UNAPPLIED}"
      } > fallow-gates.env

      if [ ${#GATE_FAILURES[@]} -gt 0 ]; then
        for failure in "${GATE_FAILURES[@]}"; do
          echo "ERROR: ${failure}"
        done
        # 8 is the documented security-gate exit and outranks the generic 1.
        if [ "$SECURITY_GATE_FAILED" = "true" ]; then
          exit 8
        fi
        exit 1
      fi
      FALLOW_SCRIPT_EOF
      chmod +x /tmp/fallow-run.sh
      FALLOW_RUN_WRITER_EOF
  script:
    - bash /tmp/fallow-run.sh

  artifacts:
    when: always
    paths:
      - fallow-results.json
      - fallow-analysis-args.bin
      - fallow-mr-comment.md
      - fallow-mr-comment-envelope.json
      - fallow-mr-comment-plan.json
      - fallow-mr-decision.json
      - fallow-mr-details.json
      - fallow-review.json
      - fallow-review-post.json
      # Sidecar markers written by comment.sh / review.sh on dedup-lookup
      # failure. Downstream jobs gate on these to detect degraded posting
      # state (greppable: `none` vs `pagination_failure` for skip-reason,
      # `false` vs `true` for dedup-lookup-failed). See issue #470.
      - fallow-skip-reason.txt
      - fallow-dedup-lookup-failed.txt
      - fallow-gates.env
    reports:
      codequality:
        - gl-code-quality-report.json
      # The gate verdict as dotenv, so a downstream job can branch on
      # FALLOW_GATES_FAILED without re-reading the envelope. Same names as the
      # action's step outputs.
      dotenv: fallow-gates.env
    expire_in: 30 days

  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH