# 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