From ec8474f0553fb469b10e3c44fb5e5899d80f5a2f Mon Sep 17 00:00:00 2001 From: rohitgollapalli Date: Thu, 11 Jun 2026 14:23:39 -0400 Subject: [PATCH 1/2] chore: add tracked fallow-stop.sh Stop hook (prerequisite for #15) Commit the fallow audit Stop hook directly into the repo so any contributor can wire it with no external dependency on the owner's private ~/tools/fallow-hook. The hook runs fallow audit --changed-since over changed TS/JS files and writes a JSON report under .fallow/. - Informational only: exits 0 on every path (no fallow binary, nothing changed, audit error, broken symlink on Windows). CI (#15) is the gate. - Honors FALLOW_DISABLE=1 and FALLOW_BASE_REF; base ref falls back main -> master -> origin/HEAD. One-time install hint when fallow absent. - .fallow/ added to .gitignore (runtime output); the script stays tracked. - .gitattributes pins *.sh to LF so a fresh clone on Windows (autocrlf=true) gets a runnable shebang instead of CRLF corruption. Closes #36 Co-Authored-By: Claude Opus 4.8 --- .claude/hooks/fallow-stop.sh | 164 +++++++++++++++++++++++++++++++++++ .gitattributes | 3 + .gitignore | 4 + 3 files changed, 171 insertions(+) create mode 100755 .claude/hooks/fallow-stop.sh create mode 100644 .gitattributes diff --git a/.claude/hooks/fallow-stop.sh b/.claude/hooks/fallow-stop.sh new file mode 100755 index 00000000..b9df9ef1 --- /dev/null +++ b/.claude/hooks/fallow-stop.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 The resumelint Authors +# +# Stop hook: run `fallow audit` over the TS/JS files changed vs the base +# branch and drop a JSON report under .fallow/. Informational only — this +# hook NEVER gates a session: it exits 0 on every path (fallow not +# installed, nothing changed, audit failed, even a broken symlink on +# Windows). The enforcement point is the CI gate (issue #15), not here. +# +# Wiring (per-contributor, local — not shared): add to .claude/settings.local.json +# { "hooks": { "Stop": [ { "matcher": "", "hooks": [ +# { "type": "command", +# "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/fallow-stop.sh" } ] } ] } } +# +# Knobs: +# FALLOW_DISABLE=1 no-op immediately +# FALLOW_BASE_REF ref to diff against (else: main -> master -> origin/HEAD) +# +# Windows: needs git-bash to run. Because it exits 0 everywhere, simply not +# executing on a machine without git-bash is safe. + +# No `set -e`: this hook must reach `exit 0` no matter what fails. +set -uo pipefail + +# --- knob: hard disable ----------------------------------------------------- +[[ "${FALLOW_DISABLE:-0}" == "1" ]] && exit 0 + +# --- locate repo root (.claude/hooks -> two levels up) ---------------------- +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" || exit 0 +REPO_ROOT="$(cd "${HOOK_DIR}/../.." && pwd -P)" || exit 0 +cd "$REPO_ROOT" || exit 0 + +# Not a git checkout? Nothing to diff. +git rev-parse --git-dir >/dev/null 2>&1 || exit 0 + +OUT_DIR=".fallow" + +# --- resolve base ref ------------------------------------------------------- +resolves() { git rev-parse --verify --quiet "${1}^{commit}" >/dev/null 2>&1; } + +base_ref="" +if [[ -n "${FALLOW_BASE_REF:-}" ]] && resolves "${FALLOW_BASE_REF}"; then + base_ref="$FALLOW_BASE_REF" +else + for cand in main master origin/HEAD; do + if resolves "$cand"; then base_ref="$cand"; break; fi + done +fi +[[ -n "$base_ref" ]] || exit 0 # no base to compare against -> no-op + +# Pretty name for the summary line (origin/HEAD -> its symbolic target). +base_label="$base_ref" +if [[ "$base_ref" == "origin/HEAD" ]]; then + base_label="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo origin/HEAD)" +fi + +# --- any changed TS/JS files vs base? -------------------------------------- +# Committed diff (merge-base..HEAD) plus uncommitted working-tree edits. +# --diff-filter=d drops deletions (nothing to audit in a removed file). +changed="$( + { + git diff --name-only --diff-filter=d "${base_ref}...HEAD" 2>/dev/null + git diff --name-only --diff-filter=d HEAD 2>/dev/null + } | grep -E '\.(ts|tsx|js|jsx|mjs|cjs)$' | sort -u +)" +[[ -n "$changed" ]] || exit 0 # nothing relevant changed -> silent no-op + +# --- locate the fallow binary ---------------------------------------------- +if command -v fallow >/dev/null 2>&1; then + FALLOW=(fallow) +elif [[ -x "node_modules/.bin/fallow" ]]; then + FALLOW=("node_modules/.bin/fallow") +else + # Print the install hint at most once (marker lives in the gitignored dir). + hint_marker="${OUT_DIR}/.install-hint-shown" + if [[ ! -f "$hint_marker" ]]; then + mkdir -p "$OUT_DIR" 2>/dev/null || true + : > "$hint_marker" 2>/dev/null || true + echo "🌾 fallow not installed — skipping audit. Install with: npm i -D fallow" >&2 + fi + exit 0 +fi + +# --- run the audit ---------------------------------------------------------- +mkdir -p "$OUT_DIR" 2>/dev/null || true +ts="$(date -u +%Y%m%dT%H%M%SZ)" +report="${OUT_DIR}/audit-${ts}.json" + +"${FALLOW[@]}" audit --changed-since "$base_ref" --format json > "$report" 2>/dev/null || true + +# No usable report? Bail quietly — still a success. +[[ -s "$report" ]] || exit 0 + +# Maintain .fallow/latest.json -> newest report. Relative target keeps the +# link valid if .fallow/ is moved; failure (e.g. no Windows symlink priv) is fine. +ln -sf "audit-${ts}.json" "${OUT_DIR}/latest.json" 2>/dev/null || true + +# --- one-line summary to stderr -------------------------------------------- +# fallow's audit JSON schema isn't pinned here, so the parser is deliberately +# tolerant: it matches counts by category keyword across a few likely shapes +# (top-level/summary numeric fields, or a findings[] list tagged by category) +# and falls back to 0. A schema it doesn't recognize yields zeros, never an error. +summary="$( + FALLOW_REPORT="$report" python3 - <<'PY' 2>/dev/null +import json, os + +ALIASES = { + "dead": ("dead", "unused"), + "dupes": ("dup", "clone"), + "complexity": ("complex", "health", "maintainab", "crap"), + "circular": ("circular", "cycle"), +} +counts = {k: 0 for k in ALIASES} + +def matches(name, needles): + n = str(name).lower() + return any(x in n for x in needles) + +try: + with open(os.environ["FALLOW_REPORT"], encoding="utf-8") as fh: + data = json.load(fh) +except Exception: + data = None + +def scan_dict_for_counts(d): + # Numeric fields whose key names a category, e.g. {"deadCode": 3}. + for key, val in d.items(): + if isinstance(val, (int, float)) and not isinstance(val, bool): + for cat, needles in ALIASES.items(): + if matches(key, needles): + counts[cat] = max(counts[cat], int(val)) + +def scan_list_for_findings(items): + # A list of findings each tagged with a category/kind/rule field. + for it in items: + if not isinstance(it, dict): + continue + tag = " ".join( + str(it.get(f, "")) for f in ("category", "kind", "rule", "type", "ruleId", "check") + ) + for cat, needles in ALIASES.items(): + if matches(tag, needles): + counts[cat] += 1 + +if isinstance(data, dict): + scan_dict_for_counts(data) + for sub in ("summary", "counts", "totals", "stats"): + if isinstance(data.get(sub), dict): + scan_dict_for_counts(data[sub]) + for sub in ("findings", "results", "issues", "items", "runs"): + if isinstance(data.get(sub), list): + scan_list_for_findings(data[sub]) +elif isinstance(data, list): + scan_list_for_findings(data) + +print("dead:{dead} | dupes:{dupes} | complexity:{complexity} | circular:{circular}".format(**counts)) +PY +)" + +[[ -n "$summary" ]] || summary="dead:? | dupes:? | complexity:? | circular:?" +echo "🌾 fallow audit (vs ${base_label}): ${summary}" >&2 + +exit 0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c9427f16 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts run under git-bash on Windows; CRLF (\r) breaks the shebang +# and bash test syntax. Force LF so a fresh clone gets a runnable script. +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 6e51f133..16d5bca5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ dist/ # Python bytecode cache from hook scripts under scripts/hooks/. __pycache__/ +# fallow audit output (runtime reports + latest.json symlink). The hook +# script .claude/hooks/fallow-stop.sh that writes this dir IS tracked. +.fallow/ + # Local editor / workspace files (per-machine, never shared). *.code-workspace .idea/ From 023b0e3aa8a25e755b4e607b97e03207931ea6cc Mon Sep 17 00:00:00 2001 From: rohitgollapalli Date: Fri, 12 Jun 2026 13:57:20 -0400 Subject: [PATCH 2/2] chore: make fallow-stop audit the gate-selected file set (#36 review) Pass the exact changed-file list the gate computed to `fallow audit` instead of relying on fallow re-deriving its own set via --changed-since, which can diverge (different diff base, deletions, non-TS/JS noise). Falls back to --changed-since if this fallow build does not accept a positional file list, so behavior never regresses below the prior path. Co-Authored-By: Claude Opus 4.8 --- .claude/hooks/fallow-stop.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/fallow-stop.sh b/.claude/hooks/fallow-stop.sh index b9df9ef1..ff1e3723 100755 --- a/.claude/hooks/fallow-stop.sh +++ b/.claude/hooks/fallow-stop.sh @@ -87,7 +87,17 @@ mkdir -p "$OUT_DIR" 2>/dev/null || true ts="$(date -u +%Y%m%dT%H%M%SZ)" report="${OUT_DIR}/audit-${ts}.json" -"${FALLOW[@]}" audit --changed-since "$base_ref" --format json > "$report" 2>/dev/null || true +# Audit exactly the set the gate selected above. fallow's own --changed-since +# re-derives a file set internally, which can diverge from $changed (different +# diff base, deletions, non-TS/JS noise). Passing the explicit list keeps the +# gate authoritative. Until fallow's CLI is pinned (#15) we don't assume it +# accepts positional files: try the explicit form, and if that yields no +# usable report, fall back to --changed-since (the prior behavior). +mapfile -t changed_files <<< "$changed" + +"${FALLOW[@]}" audit --format json -- "${changed_files[@]}" > "$report" 2>/dev/null || true +[[ -s "$report" ]] || \ + "${FALLOW[@]}" audit --format json --changed-since "$base_ref" > "$report" 2>/dev/null || true # No usable report? Bail quietly — still a success. [[ -s "$report" ]] || exit 0