#!/usr/bin/env bash # lib/floor-guard.sh — diff-scoped detector of a quietly weakened quality bar. # # bash ~/.claude/lib/floor-guard.sh [-- ...] # # rc 0 = clean no floor finding in the diff # 2 = finding(s), waived # 3 = usage error (missing , or it does not resolve to a commit) # # WHY (BDR-100 class): "no weakened check in this diff" is exactly the kind # of judgment an LLM verifier can miss, or be talked past one line at a time # — a single added TS-ignore comment, a skipped test, a dropped assertion, a # coverage threshold shaved by one point. This makes that judgment # deterministic: grep the diff for the known ways a change quietly lowers # the bar, same floor doctrine as gates.sh (contract oracles) and # doctrine-citers.test.sh (citation census) — a mechanism, not a lesson. # # Adapted from addyosmani/agent-skills constraint-driven-development's # "floor guard" to this repo's own gate model: `git diff` instead of a # staged-diff assumption, wired into agents/verifier.md STEP 3 rather than a # pre-commit hook. # # Scope: `git diff ` — working tree included (uncommitted changes # count) — restricted to when given. Every ADDED line is # classified into one of six kinds (full pattern tables below): # SUPPRESS a checker-silencing comment added, any file # SKIP a test disabled or isolated, test files only # DELETED_TEST a whole test file removed # ASSERT_DROP a test file's assertion-line count went down # STUB a not-implemented marker added, any file # THRESHOLD_DOWN a numeric value lowered on the same key, config files only # # Waiver: an added line also carrying `floor-guard: allow ` prints as # WAIVED and does not count toward the finding total or the rc. set -uo pipefail _usage() { echo "usage: floor-guard.sh [-- ...]" >&2 exit 3 } [ $# -ge 1 ] || _usage BASE_REF="$1"; shift PATHSPEC=() if [ $# -gt 0 ]; then [ "$1" = "--" ] || _usage shift PATHSPEC=("$@") fi git rev-parse --verify -q "${BASE_REF}^{commit}" >/dev/null 2>&1 || _usage TMPDIFF="$(mktemp)" || { echo "floor-guard: mktemp failed" >&2; exit 3; } trap 'rm -f "$TMPDIFF"' EXIT git diff --unified=0 "$BASE_REF" -- "${PATHSPEC[@]}" > "$TMPDIFF" 2>/dev/null FLOOR_DELETED_FILES="$(git diff --diff-filter=D --name-only \ "$BASE_REF" -- "${PATHSPEC[@]}" 2>/dev/null)" export FLOOR_DELETED_FILES # "working tree included" means brand-new, still-untracked files too: plain # `git diff ` never shows them (git only diffs what it already tracks), # so a file added on this branch and never `git add`-ed would be invisible # to every kind below. --no-index against /dev/null emits the same unified # format as the tracked diff above (diff --git / +++ b/path / @@ hunks), # so the parser needs no separate code path for it. while IFS= read -r f; do [ -n "$f" ] || continue git diff --no-index --unified=0 -- /dev/null "$f" >> "$TMPDIFF" 2>/dev/null done < <(git ls-files --others --exclude-standard -- "${PATHSPEC[@]}" 2>/dev/null) python3 - "$TMPDIFF" <<'PY' import fnmatch import os import re import sys # file classes (CLARIFICATIONS): "path contains test/spec/__tests__" is a # superset of the explicit globs (*.test.*, *.spec.*, *_test.go, *_test.py, # test_*.py all contain one of these substrings themselves), so one check # covers all five. TEST_SUBSTRINGS = ('test', 'spec', '__tests__') CONFIG_GLOBS = ('jest.config*', 'vitest.config*', '.nycrc*', 'codecov*', 'sonar-project.properties', 'lighthouserc*', 'CONSTRAINTS.md') # ── pattern tables — the trigger strings themselves, waived on this file's # own diff so the guard stays clean on itself (also exercises the waiver # path for real) ──────────────────────────────────────────────────────────── SUPPRESS_SUBSTRINGS = ( '@ts-ignore', # floor-guard: allow pattern table 'eslint-disable', # floor-guard: allow pattern table '# noqa', # floor-guard: allow pattern table '# type: ignore', # floor-guard: allow pattern table 'nosemgrep', # floor-guard: allow pattern table 'nosec', # floor-guard: allow pattern table 'shellcheck disable', # floor-guard: allow pattern table ) TS_EXPECT_ERROR = '@ts-expect-error' # floor-guard: allow pattern table SKIP_SUBSTRINGS = ( '.skip(', '.only(', 'it.todo(', '@pytest.mark.skip', '@unittest.skip', 't.Skip(', ) # bare Jasmine/Jest focus-or-skip calls (xit/fit/xdescribe/fdescribe); the # lookbehind keeps `exit(`, `SystemExit(`, `model.fit(` out (BLK-023). SKIP_IDENT_RE = re.compile(r'(? %d' % (removed, added) return ('ASSERT_DROP', path, lineno, snippet, waived) def extract_kv(lines): # → {key: (lineno, value, raw text)} last-wins kv = {} for lineno, text in lines: m = KEYVAL_RE.search(text) if m: kv[m.group(1)] = (lineno, float(m.group(2)), text) return kv def threshold_down_findings(path, entry): removed_kv = extract_kv(entry['dels']) added_kv = extract_kv(entry['adds']) out = [] for key, (lineno, new_val, text) in added_kv.items(): old = removed_kv.get(key) if old and new_val < old[1]: out.append(('THRESHOLD_DOWN', path, lineno, text, is_waived(text))) return out def classify_file(entry, deleted_paths): path = effective_path(entry) if path is None: return [] # pure rename/mode-change: no --- / +++ header, no content diff test_file = is_test_file(path) if path in deleted_paths and test_file: return [('DELETED_TEST', path, 1, path, False)] findings = [] for lineno, text in entry['adds']: findings += line_findings(path, lineno, text, test_file) if test_file: dropped = assert_drop_finding(path, entry) if dropped: findings.append(dropped) if is_config_file(path): findings += threshold_down_findings(path, entry) return findings def load_deleted_paths(): raw = os.environ.get('FLOOR_DELETED_FILES', '') return {p for p in raw.splitlines() if p} def snippet_of(text): return text.strip()[:100] def emit(findings): ordered = sorted(findings, key=lambda f: (f[1], f[2], f[0])) n_found = n_waived = 0 for kind, path, lineno, text, waived in ordered: tag = 'WAIVED' if waived else 'FLOOR' print('%s %s %s:%d %s' % (tag, kind, path, lineno, snippet_of(text))) n_waived += 1 if waived else 0 n_found += 0 if waived else 1 if n_found: print('FLOOR GUARD: %d finding(s), %d waived' % (n_found, n_waived)) return 2 print('FLOOR GUARD: clean') return 0 def main(): with open(sys.argv[1], 'r', errors='replace') as fh: lines = fh.read().split('\n') deleted = load_deleted_paths() findings = [] for entry in parse_diff(lines): findings += classify_file(entry, deleted) return emit(findings) if __name__ == '__main__': sys.exit(main()) PY rc=$? exit "$rc"