# 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, audit, 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"
FALLOW_MIN_SEVERITY: "" # Only fail at or above this complexity severity ('moderate'|'high'|'critical'); empty applies all severities
FALLOW_INCLUDE_ENTRY_EXPORTS: "false" # Report unused exports in entry files instead of auto-marking them as used; mirrors --include-entry-exports
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 + 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_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 EVERY finding to lines inside an added hunk; project-level findings (unused deps, catalog, override) bypass the filter. 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: ""
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_COVERAGE: "" # Istanbul coverage-final.json for accurate CRAP scoring (health/audit)
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
# 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 bash scripts for MR integration
- |
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..."
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 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|audit|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
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
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 every finding (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 analysis below; for the comment /
# review jobs (separate jobs in the pipeline) we rely on the
# `dotenv` artifact written by the shared helper that lives in
# ci/scripts/. Today comment.sh / review.sh re-derive the diff
# themselves when FALLOW_DIFF_FILE is unset, so this block only
# affects the analysis job and is purely an upgrade.
if [ -n "$FALLOW_CHANGED_SINCE" ] && [ -z "$FALLOW_DIFF_FILE" ]; then
if git diff --unified=0 "${FALLOW_CHANGED_SINCE}..HEAD" > fallow-mr.diff 2>/dev/null && [ -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 ──────────────────────────────────────────
# 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_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_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)
;;
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 ─────────────────────────────────────────────────
{
printf 'FALLOW_ANALYSIS_ARGS=('
printf '%q ' "${ARGS[@]}" "${EXTRA_ARGS[@]}"
printf ')\n'
} > fallow-analysis-args.sh
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 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
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) ;;
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
# A findings exit code is still a valid CodeClimate report. Fall back
# only when the report file is empty, malformed, or not a JSON array.
fallow "${CQ_ARGS[@]}" "${EXTRA_ARGS[@]}" > gl-code-quality-report.json 2>/dev/null || true
if [ ! -s gl-code-quality-report.json ] || ! jq -e 'type == "array"' gl-code-quality-report.json > /dev/null 2>&1; then
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 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 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" ]; 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
echo "ERROR: Fallow audit failed (gate: ${AUDIT_GATE:-new-only}, ${ISSUES} finding(s) at error severity in changed files)"
exit 1
fi
elif [ "$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
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