fallow-cli 2.61.0

CLI for fallow, Rust-native 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
# 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")
#   - 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, 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"
#
#   # 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_COMMAND: ""            # dead-code, dupes, health, fix, or empty (runs all)
  FALLOW_ROOT: "."
  FALLOW_CONFIG: ""             # Path to .fallowrc.json, .fallowrc.jsonc, or fallow.toml
  FALLOW_PRODUCTION: ""                # "true"/"false" enables production for every analysis. Empty defers to config.
  FALLOW_PRODUCTION_DEAD_CODE: ""      # Combined mode only: "true"/"false" overrides FALLOW_PRODUCTION for dead-code. Empty defers to it.
  FALLOW_PRODUCTION_HEALTH: ""         # Combined mode only: "true"/"false" overrides FALLOW_PRODUCTION for health. Empty defers to it.
  FALLOW_PRODUCTION_DUPES: ""          # Combined mode only: "true"/"false" overrides FALLOW_PRODUCTION for duplication. Empty defers to it.
  FALLOW_FAIL_ON_ISSUES: "true"
  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_CODEQUALITY: "true"    # Generate GitLab Code Quality report (inline MR annotations)
  FALLOW_MAX_COMMENTS: "50"     # Maximum number of inline review comments

  # 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: ""
  FALLOW_SAVE_BASELINE: ""

  # 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_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 health 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

  # 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:
    key: "fallow-${CI_COMMIT_REF_SLUG}"
    paths:
      - .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
    - |
      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
      }

      requested_version="$(trim "${FALLOW_VERSION:-}")"
      root="${FALLOW_ROOT:-.}"
      project_spec="$(project_fallow_spec "$root" 2>/dev/null || 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 ${install_arg}"
        exit 0
      fi

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

      installed_version="$(fallow --version 2>/dev/null || 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
    # Prepare jq scripts and bash scripts for MR integration
    - |
      export FALLOW_JQ_DIR="/tmp/fallow-jq"
      export FALLOW_SHARED_JQ_DIR="/tmp/fallow-shared-jq"
      FALLOW_SCRIPTS_DIR="/tmp/fallow-scripts"
      mkdir -p "$FALLOW_JQ_DIR" "$FALLOW_SHARED_JQ_DIR" "$FALLOW_SCRIPTS_DIR"

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

          for f in summary-check.jq summary-health.jq summary-combined.jq review-comments-dupes.jq; do
            cp "ci/jq/${f}" "${FALLOW_JQ_DIR}/${f}" 2>/dev/null || {
              echo "  WARNING: Failed to copy ci/jq/${f}"
              DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
            }
          done

          for f in summary-dupes.jq summary-fix.jq review-comments-check.jq review-comments-health.jq review-body.jq merge-comments.jq filter-changed.jq; do
            cp "action/jq/${f}" "${FALLOW_SHARED_JQ_DIR}/${f}" 2>/dev/null || {
              echo "  WARNING: Failed to copy action/jq/${f}"
              DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
            }
          done

          for f in comment.sh review.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 2>/dev/null || 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}..."

          for f in summary-check.jq summary-health.jq summary-combined.jq review-comments-dupes.jq; do
            curl -sf "${FALLOW_SCRIPTS_BASE}/ci/jq/${f}" -o "${FALLOW_JQ_DIR}/${f}" 2>/dev/null || {
              echo "  WARNING: Failed to download ci/jq/${f}"
              DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
            }
          done

          for f in summary-dupes.jq summary-fix.jq review-comments-check.jq review-comments-health.jq review-body.jq merge-comments.jq filter-changed.jq; do
            curl -sf "${FALLOW_SCRIPTS_BASE}/action/jq/${f}" -o "${FALLOW_SHARED_JQ_DIR}/${f}" 2>/dev/null || {
              echo "  WARNING: Failed to download action/jq/${f}"
              DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
            }
          done

          for f in comment.sh review.sh; do
            if curl -sf "${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
    # Write the analysis script (heredoc avoids quoting issues)
    - |
      cat > /tmp/fallow-run.sh << 'FALLOW_SCRIPT_EOF'
      #!/bin/bash
      set -euo pipefail

      # ── Validate inputs ──────────────────────────────────────────────
      case "$FALLOW_COMMAND" in
        ""|dead-code|check|dupes|health|fix) ;;
        *) echo "ERROR: Invalid command: ${FALLOW_COMMAND}"; exit 2 ;;
      esac

      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

      # ── Build CLI arguments ──────────────────────────────────────────
      # Primary run uses --format json; CodeClimate report is generated via
      # a second run with --format codeclimate when FALLOW_CODEQUALITY=true.
      ARGS=()
      [ -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")

      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}")
            done
          fi
          [ "$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_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")
          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)
          ;;
        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)
          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=()
      if [ -n "$FALLOW_ARGS" ]; then
        read -ra EXTRA_ARGS <<< "$FALLOW_ARGS"
      fi

      # ── Run analysis ─────────────────────────────────────────────────
      echo "Running: fallow ${ARGS[*]} ${EXTRA_ARGS[*]}"
      if ! fallow "${ARGS[@]}" "${EXTRA_ARGS[@]}" > fallow-results.json 2> fallow-stderr.log; then
        if [ ! -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
      fi

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

      # ── Extract issue count ──────────────────────────────────────────
      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) ;;
        fix)             ISSUES=$(jq -r '(.fixes | length)' fallow-results.json) ;;
        "")              ISSUES=$(jq -r '((.check.total_issues // 0) + (.dupes.stats.clone_groups // 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) ──────────────
      # Uses fallow's native --format codeclimate for inline MR annotations.
      if [ "$FALLOW_CODEQUALITY" = "true" ] && [ "$FALLOW_COMMAND" != "fix" ]; then
        echo "Generating Code Quality report..."
        # Re-run with --format codeclimate instead of json
        CQ_ARGS=()
        for arg in "${ARGS[@]}"; do
          if [ "$arg" = "json" ] && [ "${prev_arg:-}" = "--format" ]; then
            CQ_ARGS+=("codeclimate")
          else
            CQ_ARGS+=("$arg")
          fi
          prev_arg="$arg"
        done
        if ! fallow "${CQ_ARGS[@]}" "${EXTRA_ARGS[@]}" > gl-code-quality-report.json 2>/dev/null; then
          # Fallback: empty report on failure
          echo "[]" > gl-code-quality-report.json
        fi
        CQ_COUNT=$(jq '. | length' gl-code-quality-report.json 2>/dev/null || echo 0)
        echo "Code Quality report: ${CQ_COUNT} findings"
      else
        echo "[]" > gl-code-quality-report.json
      fi

      # ── MR summary comment ──────────────────────────────────────────
      if [ "$FALLOW_COMMENT" = "true" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ]; then
        if [ -x "/tmp/fallow-scripts/comment.sh" ]; then
          echo "Posting MR summary comment..."
          export FALLOW_JQ_DIR="${FALLOW_JQ_DIR:-/tmp/fallow-jq}"
          export FALLOW_SHARED_JQ_DIR="${FALLOW_SHARED_JQ_DIR:-/tmp/fallow-shared-jq}"
          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" ]; then
        if [ -x "/tmp/fallow-scripts/review.sh" ]; then
          echo "Posting inline MR review..."
          export FALLOW_JQ_DIR="${FALLOW_JQ_DIR:-/tmp/fallow-jq}"
          export FALLOW_SHARED_JQ_DIR="${FALLOW_SHARED_JQ_DIR:-/tmp/fallow-shared-jq}"
          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

      # ── Fail check ───────────────────────────────────────────────────
      if [ "$FALLOW_FAIL_ON_ISSUES" = "true" ] && [ "$ISSUES" -gt 0 ]; then
        case "$FALLOW_COMMAND" in
          dead-code|check) echo "ERROR: Fallow found ${ISSUES} unused code issues" ;;
          dupes)           echo "ERROR: Fallow found ${ISSUES} clone groups" ;;
          health)          echo "ERROR: Fallow found ${ISSUES} health findings" ;;
          fix)             echo "ERROR: Fallow found ${ISSUES} fixable issues" ;;
          "")              echo "ERROR: Fallow found ${ISSUES} issues" ;;
        esac
        exit 1
      fi
      FALLOW_SCRIPT_EOF
      chmod +x /tmp/fallow-run.sh
  script:
    - bash /tmp/fallow-run.sh

  artifacts:
    when: always
    paths:
      - fallow-results.json
    reports:
      codequality:
        - gl-code-quality-report.json
    expire_in: 30 days

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