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