crewforth 0.0.1 → 3.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3805 -0
- package/LICENSE +21 -0
- package/README.md +37 -7
- package/VERSION +1 -0
- package/adopt.sh +1739 -0
- package/bin/cli.js +497 -0
- package/kit/AGENT_TEMPLATE.md +92 -0
- package/kit/CLAUDE.md +228 -0
- package/kit/README.md +107 -0
- package/kit/agents/crew-backend-expert.md +95 -0
- package/kit/agents/crew-commit-agent.md +96 -0
- package/kit/agents/crew-database-expert.md +84 -0
- package/kit/agents/crew-devops-expert.md +82 -0
- package/kit/agents/crew-frontend-expert.md +85 -0
- package/kit/agents/crew-performance-expert.md +78 -0
- package/kit/agents/crew-planner.md +60 -0
- package/kit/agents/crew-privacy-agent.md +71 -0
- package/kit/agents/crew-review-agent.md +95 -0
- package/kit/agents/crew-security-expert.md +73 -0
- package/kit/agents/crew-session-manager.md +77 -0
- package/kit/agents/crew-test-expert.md +64 -0
- package/kit/eval/doctor.sh +639 -0
- package/kit/eval/gate-report.sh +137 -0
- package/kit/eval/golden-holdout.txt +53 -0
- package/kit/eval/golden-routing.txt +199 -0
- package/kit/eval/lib/crew-env.sh +14 -0
- package/kit/eval/lib/settings-json.awk +154 -0
- package/kit/eval/lib/skill-listing.awk +43 -0
- package/kit/eval/lib/star.sh +70 -0
- package/kit/eval/parser-conformance.sh +612 -0
- package/kit/eval/preflight.sh +186 -0
- package/kit/eval/routing-eval.sh +209 -0
- package/kit/eval/scan-skill.sh +162 -0
- package/kit/eval/smoke-test.sh +6736 -0
- package/kit/eval/update-guard.sh +81 -0
- package/kit/eval/utilization.sh +130 -0
- package/kit/hooks/board-sync.sh +106 -0
- package/kit/hooks/board.sh +962 -0
- package/kit/hooks/commit-msg +49 -0
- package/kit/hooks/context-usage.sh +353 -0
- package/kit/hooks/floor-blocklist.txt +124 -0
- package/kit/hooks/guard-bash.sh +1433 -0
- package/kit/hooks/guard-commit-scan.sh +511 -0
- package/kit/hooks/guard-write.sh +513 -0
- package/kit/hooks/pre-commit +577 -0
- package/kit/hooks/route-hint.sh +185 -0
- package/kit/hooks/secret-blocklist.txt +82 -0
- package/kit/hooks/session-guard.sh +149 -0
- package/kit/hooks/session-rehydrate.sh +41 -0
- package/kit/hooks/session-stats.sh +252 -0
- package/kit/hooks/session-update-check.sh +236 -0
- package/kit/hooks/skill-trust.sh +163 -0
- package/kit/hooks/trace-blocklist.txt +53 -0
- package/kit/settings.json +132 -0
- package/kit/skills/a11y/SKILL.md +44 -0
- package/kit/skills/adr/SKILL.md +74 -0
- package/kit/skills/api-design/SKILL.md +50 -0
- package/kit/skills/automode-policy/SKILL.md +91 -0
- package/kit/skills/automode-policy/references/policy.json +13 -0
- package/kit/skills/automode-policy/scripts/apply.sh +84 -0
- package/kit/skills/automode-policy/scripts/check.sh +102 -0
- package/kit/skills/backend-architecture/SKILL.md +103 -0
- package/kit/skills/brainstorm/SKILL.md +47 -0
- package/kit/skills/ci-pipeline/SKILL.md +38 -0
- package/kit/skills/commit-message/SKILL.md +82 -0
- package/kit/skills/commit-message/references/examples.md +8 -0
- package/kit/skills/confidence-check/SKILL.md +86 -0
- package/kit/skills/crew-board/SKILL.md +55 -0
- package/kit/skills/crew-brainstorm/SKILL.md +19 -0
- package/kit/skills/crew-code-review/SKILL.md +151 -0
- package/kit/skills/crew-code-review/references/history-search.md +19 -0
- package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
- package/kit/skills/crew-code-review/references/receiving.md +23 -0
- package/kit/skills/crew-doctor/SKILL.md +46 -0
- package/kit/skills/crew-gates/SKILL.md +25 -0
- package/kit/skills/crew-handoff/SKILL.md +14 -0
- package/kit/skills/crew-plan/SKILL.md +18 -0
- package/kit/skills/crew-review/SKILL.md +26 -0
- package/kit/skills/crew-ship/SKILL.md +19 -0
- package/kit/skills/crew-skill/SKILL.md +62 -0
- package/kit/skills/crew-studio/SKILL.md +129 -0
- package/kit/skills/crew-update/SKILL.md +43 -0
- package/kit/skills/db-migration/SKILL.md +120 -0
- package/kit/skills/db-migration/references/backup-restore.md +33 -0
- package/kit/skills/db-migration/references/raw-sql.md +30 -0
- package/kit/skills/db-migration/references/tool-matrix.md +35 -0
- package/kit/skills/dependency-audit/SKILL.md +61 -0
- package/kit/skills/dependency-upgrade/SKILL.md +82 -0
- package/kit/skills/deploy/SKILL.md +100 -0
- package/kit/skills/deploy/references/proxy-ssl.md +56 -0
- package/kit/skills/deploy/references/rollback.md +18 -0
- package/kit/skills/deploy/references/scripts.md +9 -0
- package/kit/skills/deploy/references/topology-a.md +128 -0
- package/kit/skills/deploy/references/topology-b.md +44 -0
- package/kit/skills/docs-writer/SKILL.md +53 -0
- package/kit/skills/docs-writer/references/coauthoring.md +34 -0
- package/kit/skills/eval-grader/SKILL.md +75 -0
- package/kit/skills/eval-grader/references/method.md +51 -0
- package/kit/skills/frontend/SKILL.md +56 -0
- package/kit/skills/frontend/references/verify-contract.md +41 -0
- package/kit/skills/frontend-design/SKILL.md +71 -0
- package/kit/skills/frontend-design/references/design-review.md +34 -0
- package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
- package/kit/skills/handoff/SKILL.md +71 -0
- package/kit/skills/handoff/references/cold-reader.md +51 -0
- package/kit/skills/i18n-integrity/SKILL.md +31 -0
- package/kit/skills/incident-runbook/SKILL.md +40 -0
- package/kit/skills/incident-runbook/references/postmortem.md +16 -0
- package/kit/skills/iterate/SKILL.md +40 -0
- package/kit/skills/mcp-builder/SKILL.md +54 -0
- package/kit/skills/mcp-builder/references/building.md +71 -0
- package/kit/skills/observability/SKILL.md +50 -0
- package/kit/skills/performance/SKILL.md +38 -0
- package/kit/skills/performance/references/bottlenecks.md +10 -0
- package/kit/skills/privacy-compliance/SKILL.md +75 -0
- package/kit/skills/red-team/SKILL.md +48 -0
- package/kit/skills/reflect/SKILL.md +57 -0
- package/kit/skills/release/SKILL.md +44 -0
- package/kit/skills/security-scan/SKILL.md +99 -0
- package/kit/skills/security-scan/references/ai-agents.md +107 -0
- package/kit/skills/security-scan/references/fronts.md +99 -0
- package/kit/skills/security-scan/references/prompting.md +23 -0
- package/kit/skills/security-scan/references/reporting.md +75 -0
- package/kit/skills/security-scan/references/verify.md +131 -0
- package/kit/skills/sonarqube-check/SKILL.md +108 -0
- package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
- package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
- package/kit/skills/spec-planning/SKILL.md +92 -0
- package/kit/skills/systematic-debugging/SKILL.md +57 -0
- package/kit/skills/systematic-debugging/references/techniques.md +44 -0
- package/kit/skills/teamboard/SKILL.md +88 -0
- package/kit/skills/teamboard/references/setup.md +37 -0
- package/kit/skills/testing/SKILL.md +49 -0
- package/kit/skills/testing/references/flaky-triage.md +51 -0
- package/kit/skills/threat-model/SKILL.md +57 -0
- package/kit/skills/threat-model/references/interview.md +51 -0
- package/kit/skills/threat-model/references/schema.md +42 -0
- package/kit/skills/token-budget/SKILL.md +51 -0
- package/kit/skills/trace-scan/SKILL.md +43 -0
- package/kit/skills/worktree/SKILL.md +40 -0
- package/kit/studio/README.md +266 -0
- package/kit/studio/ensure-node.sh +471 -0
- package/kit/studio/package.json +14 -0
- package/kit/studio/server/hooks/studio-gate.sh +169 -0
- package/kit/studio/server/index.js +902 -0
- package/kit/studio/server/lib/crew-env.js +11 -0
- package/kit/studio/server/lib/fleet.js +146 -0
- package/kit/studio/server/lib/graph.js +533 -0
- package/kit/studio/server/lib/instance.js +135 -0
- package/kit/studio/server/lib/kit-telemetry.js +182 -0
- package/kit/studio/server/lib/kit.js +158 -0
- package/kit/studio/server/lib/palette.js +118 -0
- package/kit/studio/server/lib/peers.js +72 -0
- package/kit/studio/server/lib/permissions.js +150 -0
- package/kit/studio/server/lib/projects.js +294 -0
- package/kit/studio/server/lib/roster.js +191 -0
- package/kit/studio/server/lib/session.js +309 -0
- package/kit/studio/server/lib/terminal.js +91 -0
- package/kit/studio/server/lib/transcript.js +100 -0
- package/kit/studio/web/app.js +1098 -0
- package/kit/studio/web/canvas.js +1179 -0
- package/kit/studio/web/chat.js +543 -0
- package/kit/studio/web/index.html +86 -0
- package/kit/studio/web/md.js +110 -0
- package/kit/studio/web/storage-migrate.js +23 -0
- package/kit/studio/web/style.css +1453 -0
- package/package.json +46 -8
- package/start.sh +764 -0
- package/bin/crewforth.js +0 -10
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-commit gate on staged ADDED lines:
|
|
3
|
+
# (1) §4.1/§4.2 trace scanner — AI-authorship traces / vendor names (trace-blocklist.txt)
|
|
4
|
+
# (2) secret scanner — API keys / tokens / private keys (secret-blocklist.txt)
|
|
5
|
+
# (3) private-path scanner — this machine's $HOME + .private-terms.txt (§4.3)
|
|
6
|
+
# Skipping requires an explicit request per §4.5 (git commit --no-verify).
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
# The 2.x names of the variables a user can set still work (one helper: eval/lib/crew-env.sh).
|
|
9
|
+
_crew_d="${BASH_SOURCE%/*}"; [ "$_crew_d" = "${BASH_SOURCE}" ] && _crew_d=.
|
|
10
|
+
[ -f "$_crew_d/../eval/lib/crew-env.sh" ] && . "$_crew_d/../eval/lib/crew-env.sh"; unset _crew_d
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
BL="$HERE/trace-blocklist.txt"
|
|
13
|
+
SL="$HERE/secret-blocklist.txt"
|
|
14
|
+
|
|
15
|
+
# Repo-root exception lists (optional): a pattern appearing here as a FULL line is SKIPPED.
|
|
16
|
+
# These files are NOT part of the payload; they are specific to their own repo.
|
|
17
|
+
GITROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
18
|
+
AL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.trace-allowlist.txt" ] && AL="$GITROOT/.trace-allowlist.txt"
|
|
19
|
+
SAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.secret-allowlist.txt" ] && SAL="$GITROOT/.secret-allowlist.txt"
|
|
20
|
+
|
|
21
|
+
# Only added content lines (start with +, excluding the +++ file header).
|
|
22
|
+
#
|
|
23
|
+
# The added lines go to FILES, and every scan greps a file. Never `printf "$ADDED" | grep -q`: grep -q exits on
|
|
24
|
+
# the first match, the pipe closes, printf dies of SIGPIPE (141), and `set -o pipefail` turns that into a failed
|
|
25
|
+
# `if` — so a match in a LARGE staged diff would silently count as no match. A gate that only works on small
|
|
26
|
+
# commits is worse than no gate.
|
|
27
|
+
TRACE_F="$(mktemp)"; SECRET_F="$(mktemp)"
|
|
28
|
+
# Cleanup runs on EXIT; the SIGNAL handlers additionally EXIT. A bare `trap '...' EXIT INT TERM` looks like it
|
|
29
|
+
# handles all three, but bash returns to the script after a signal handler that does not exit — so TERM removed
|
|
30
|
+
# the temp files this hook is still reading from and then let it carry on, printing `grep: /tmp/tmp.X: No such
|
|
31
|
+
# file or directory` for the rest of the run. Measured here: after a TERM the hook exited 0, not 143, which is
|
|
32
|
+
# the signature — a process killed by a signal cannot exit 0.
|
|
33
|
+
#
|
|
34
|
+
# That is how a slow commit becomes a STUCK one. A wrapper with a two-minute timeout sends TERM, the hook
|
|
35
|
+
# swallows it, git keeps running, and `.git/index.lock` stays behind holding the repository. Every later git
|
|
36
|
+
# command then fails with "Another git process seems to be running", which names nothing about the real cause.
|
|
37
|
+
# 143 = 128 + SIGTERM, the exit status a shell reports for a process the signal actually stopped.
|
|
38
|
+
_crew_cleanup(){ rm -f "$TRACE_F" "$SECRET_F" ${PRIV_F:+"$PRIV_F"} ${PTERM_F:+"$PTERM_F"} ${FLOOR_F:+"$FLOOR_F"} ${FLOOR_P:+"$FLOOR_P"}; }
|
|
39
|
+
trap _crew_cleanup EXIT
|
|
40
|
+
trap '_crew_cleanup; exit 143' INT TERM
|
|
41
|
+
|
|
42
|
+
# Secrets are scanned EVERYWHERE. A token pasted into .claude/settings.json is still a token.
|
|
43
|
+
# Crewforth's own blocklist files are excluded — they definitionally contain the patterns (circular), and since
|
|
44
|
+
# 1.8.0 they also carry their own `#test:` cases. Matched by NAME rather than by the installed path: the same
|
|
45
|
+
# files live at .claude/hooks/ in a project, kit/hooks/ in Crewforth's own repo and hooks/ in the
|
|
46
|
+
# plugin build, and a path-anchored exclusion silently stopped applying in every layout but the first.
|
|
47
|
+
git diff --cached --unified=0 -- . \
|
|
48
|
+
':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
|
|
49
|
+
':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
|
|
50
|
+
| grep -E '^\+' | grep -Ev '^\+\+\+' > "$SECRET_F" || true
|
|
51
|
+
|
|
52
|
+
# Traces (§4.1/§4.2) are scanned in the PROJECT's own files only. `.claude/` is Crewforth's tree: it configures the
|
|
53
|
+
# assistant, it legitimately names the tool it configures, and an update overwrites it — it is not an artifact the
|
|
54
|
+
# project authored. Teams that share `.claude/` would otherwise be unable to commit it at all.
|
|
55
|
+
# The blocklist is excluded by name for the same circularity reason as above: in an installed project it already
|
|
56
|
+
# falls under .claude/, but in Crewforth's own repo it sits at kit/hooks/ where that exclusion misses it.
|
|
57
|
+
git diff --cached --unified=0 -- . ':(top,exclude).claude' \
|
|
58
|
+
':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
|
|
59
|
+
| grep -E '^\+' | grep -Ev '^\+\+\+' > "$TRACE_F" || true
|
|
60
|
+
|
|
61
|
+
# `git commit -a` stages tracked changes as part of the commit. As a git hook that is invisible — git stages
|
|
62
|
+
# first, so --cached already contains them by the time this runs. Called AHEAD of the commit (the plugin
|
|
63
|
+
# edition's PreToolUse path, which has no core.hooksPath to hook into), it is not: the content about to be
|
|
64
|
+
# committed is still unstaged, and scanning only --cached would wave it through. The caller sets this when it
|
|
65
|
+
# sees -a/--all.
|
|
66
|
+
if [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ]; then
|
|
67
|
+
git diff --unified=0 -- . \
|
|
68
|
+
':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
|
|
69
|
+
':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
|
|
70
|
+
| grep -E '^\+' | grep -Ev '^\+\+\+' >> "$SECRET_F" || true
|
|
71
|
+
git diff --unified=0 -- . ':(top,exclude).claude' \
|
|
72
|
+
':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
|
|
73
|
+
| grep -E '^\+' | grep -Ev '^\+\+\+' >> "$TRACE_F" || true
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
HIT=0
|
|
77
|
+
# (0) repo-bloat gate — scans the staged FILE LIST, not added lines: a binary produces no '^+' line, so this
|
|
78
|
+
# must run BEFORE the added-text early-exit below or it would never fire on the very files it targets.
|
|
79
|
+
MAXBYTES="${CREW_MAX_FILE_BYTES:-5242880}" # 5 MiB; override per-repo via env
|
|
80
|
+
BLOAT_DIRS='(^|/)(node_modules|bower_components|dist|build|out|\.next|\.nuxt|\.svelte-kit|target|bin|obj|vendor|__pycache__|\.venv|venv|coverage|\.gradle)/'
|
|
81
|
+
# COST NOTE, measured on a 373-file merge: the loop below used to spawn ~7 processes per file — three
|
|
82
|
+
# `printf | grep` pairs plus a `git cat-file` — for 2,644 processes in total. On Git Bash a process costs
|
|
83
|
+
# 20-50 ms and this hook ran for over twenty minutes on a real merge, which is how a gate turns into the thing
|
|
84
|
+
# people reach for --no-verify to avoid. The pattern scanning was never the expensive part: 23 patterns are one
|
|
85
|
+
# grep each. The file loop was. So the name tests are pure bash `case` now (no process at all) and the two
|
|
86
|
+
# object lookups are batched into one `git cat-file --batch-check` after the loop. Same rules, same verdicts.
|
|
87
|
+
BLOAT_CANDIDATES=""; BLOAT_QUERY=""
|
|
88
|
+
SIZE_TARGETS=""; SIZE_QUERY=""
|
|
89
|
+
NL='
|
|
90
|
+
'
|
|
91
|
+
while IFS= read -r f; do
|
|
92
|
+
[ -z "$f" ] && continue
|
|
93
|
+
base="${f##*/}"
|
|
94
|
+
# (F) secret-FILE gate — a file that is a secret by its very NAME. The content scan (2) can miss it: a private
|
|
95
|
+
# key is high-entropy, not a known prefix. .env.example/.sample/.template/.dist are committable templates.
|
|
96
|
+
is_secret_file=0
|
|
97
|
+
case "$base" in
|
|
98
|
+
.env|.env.*)
|
|
99
|
+
# Case-insensitive for THIS test only — the rule it replaces used `grep -qiE` here and plain `grep -qE`
|
|
100
|
+
# everywhere else. Turning nocasematch on for the whole loop looked harmless and quietly widened a
|
|
101
|
+
# different rule: `server.PEM` went from allowed to blocked. Arguably an improvement, but not one to make
|
|
102
|
+
# inside a performance change, and not one to make without casing the "must not block" half.
|
|
103
|
+
shopt -s nocasematch 2>/dev/null || true
|
|
104
|
+
case "$base" in
|
|
105
|
+
*.example|*.sample|*.template|*.dist|*.schema|*.md) : ;;
|
|
106
|
+
*) is_secret_file=1 ;;
|
|
107
|
+
esac
|
|
108
|
+
shopt -u nocasematch 2>/dev/null || true ;;
|
|
109
|
+
esac
|
|
110
|
+
# Case-insensitive, and that IS a widening — the rule this replaced used a case-SENSITIVE `grep -qE`, so
|
|
111
|
+
# `server.PEM`, `id.KEY` and `cert.P12` were committable while their lowercase twins were blocked. Left open
|
|
112
|
+
# once already, on the grounds that widening a gate does not belong inside a performance change. It belongs
|
|
113
|
+
# here: Windows and macOS filesystems are case-insensitive, so `server.PEM` and `server.pem` are the SAME
|
|
114
|
+
# FILE, and a rule that blocks one spelling of a private key while allowing another has protected nothing.
|
|
115
|
+
# Same class as the `chmod 777` vs `chmod 1777` hole and the `rm -rf` vs `rm -Rf` one this file already fixed.
|
|
116
|
+
#
|
|
117
|
+
# nocasematch is opened and closed around this block ALONE. Left on across the whole loop it silently changed
|
|
118
|
+
# the .env test above, which is exactly how `server.PEM` moved from allowed to blocked as an invisible side
|
|
119
|
+
# effect the first time. The must-not-block half is cased too: `.pem.example`, `key.md`, `monkey.ts`,
|
|
120
|
+
# `KEYS.md` and `public.pub` all stay committable, and smoke-test pins them in both directions.
|
|
121
|
+
shopt -s nocasematch 2>/dev/null || true
|
|
122
|
+
case "$base" in
|
|
123
|
+
*.example|*.sample|*.template|*.dist|*.md|*.pub) : ;;
|
|
124
|
+
id_rsa|id_dsa|id_ecdsa|id_ed25519|.npmrc|.pypirc|.htpasswd|credentials.json) is_secret_file=1 ;;
|
|
125
|
+
*.pem|*.pfx|*.p12|*.ppk|*.key|*.keystore|*.jks) is_secret_file=1 ;;
|
|
126
|
+
esac
|
|
127
|
+
shopt -u nocasematch 2>/dev/null || true
|
|
128
|
+
if [ "$is_secret_file" = 1 ] && ! { [ -n "$SAL" ] && grep -qxF -- "$f" "$SAL"; }; then
|
|
129
|
+
echo "SECRET-FILE: '$f' is a secrets / credentials / private-key file — never commit it (gitignore it; ship a .env.example instead)."
|
|
130
|
+
HIT=1; continue
|
|
131
|
+
fi
|
|
132
|
+
# The PATTERN half only judges files that are NEW to the repository. A path already in HEAD is one the project
|
|
133
|
+
# decided to keep, and refusing it means that file can never be edited again without --no-verify — the exact
|
|
134
|
+
# shape of a gate that gets worked around. This repo hit it on its own `bin/cli.js`: `bin/` is build output in
|
|
135
|
+
# .NET and Java, and the conventional home of a CLI entry point in Node, where package.json's `bin` field
|
|
136
|
+
# points at it. The SIZE check below still applies to tracked files, so a tracked path cannot quietly grow.
|
|
137
|
+
case "/$f" in
|
|
138
|
+
*/node_modules/*|*/bower_components/*|*/dist/*|*/build/*|*/out/*|*/.next/*|*/.nuxt/*|*/.svelte-kit/*|*/target/*|*/bin/*|*/obj/*|*/vendor/*|*/__pycache__/*|*/.venv/*|*/venv/*|*/coverage/*|*/.gradle/*)
|
|
139
|
+
BLOAT_CANDIDATES="$BLOAT_CANDIDATES$f
|
|
140
|
+
"; BLOAT_QUERY="$BLOAT_QUERY${NL}HEAD:$f" ;;
|
|
141
|
+
esac
|
|
142
|
+
SIZE_TARGETS="$SIZE_TARGETS$f
|
|
143
|
+
"; SIZE_QUERY="$SIZE_QUERY${NL}:$f"
|
|
144
|
+
done < <(git diff --cached --name-only --diff-filter=AM -- . 2>/dev/null)
|
|
145
|
+
|
|
146
|
+
# Both object lookups, batched. `--batch-check` answers in input order, so the paths are pasted back on rather
|
|
147
|
+
# than looked up again — bash 3.2 (what macOS ships) has no associative arrays, and a second lookup per file
|
|
148
|
+
# would put back the cost this removes.
|
|
149
|
+
if [ -n "$BLOAT_CANDIDATES" ]; then
|
|
150
|
+
while IFS=' ' read -r f verdict; do
|
|
151
|
+
[ -z "$f" ] && continue
|
|
152
|
+
case "$verdict" in *missing*)
|
|
153
|
+
echo "REPO-BLOAT: '$f' is a build/vendored artifact — gitignore it, don't commit it."
|
|
154
|
+
HIT=1 ;;
|
|
155
|
+
esac
|
|
156
|
+
done < <(printf '%s\n' "${BLOAT_QUERY#$NL}" | git cat-file --batch-check 2>/dev/null \
|
|
157
|
+
| paste -d'\t' <(printf '%s' "$BLOAT_CANDIDATES") - 2>/dev/null)
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
if [ -n "$SIZE_TARGETS" ]; then
|
|
161
|
+
while IFS=' ' read -r f info; do
|
|
162
|
+
[ -z "$f" ] && continue
|
|
163
|
+
sz="${info##* }"
|
|
164
|
+
case "$sz" in ''|*[!0-9]*) sz=0;; esac
|
|
165
|
+
if [ "$sz" -gt "$MAXBYTES" ]; then
|
|
166
|
+
echo "REPO-BLOAT: '$f' is $((sz/1024)) KiB (> $((MAXBYTES/1024)) KiB) — large blobs bloat history; use Git LFS or gitignore."
|
|
167
|
+
HIT=1
|
|
168
|
+
fi
|
|
169
|
+
done < <(printf '%s\n' "${SIZE_QUERY#$NL}" | git cat-file --batch-check 2>/dev/null \
|
|
170
|
+
| paste -d'\t' <(printf '%s' "$SIZE_TARGETS") - 2>/dev/null)
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
# Floor guard, STRUCTURAL half — a test file deleted outright, or assertions taken out of one that stays.
|
|
174
|
+
#
|
|
175
|
+
# It sits here, ahead of the early exit below, because neither move adds a line: a commit that only deletes has
|
|
176
|
+
# nothing for the text scans, and that exit used to end the hook before this ever ran — measured, a deleted test
|
|
177
|
+
# file committed cleanly. The line-pattern half of the floor guard is further down with the other text scans.
|
|
178
|
+
# (The line patterns and why each exists: floor-blocklist.txt.)
|
|
179
|
+
FAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.floor-allowlist.txt" ] && FAL="$GITROOT/.floor-allowlist.txt"
|
|
180
|
+
# .floor-allowlist.txt: `path:<glob>` exempts a path, `rule:<name>` turns a rule off, any other line exempts that
|
|
181
|
+
# EXACT pattern line. `path:` is matched as a shell `case` pattern: `*` crosses `/` and `**` is not special, so
|
|
182
|
+
# `path:*.cs` exempts every C# file in the tree. Documented in floor-blocklist.txt and pinned in smoke §7h2. The allowlist sits in the same diff as the code it excuses, which is the point: an exception
|
|
183
|
+
# is a decision someone can see, not a rule that quietly got weaker.
|
|
184
|
+
_floor_allowed(){ # $1 = rule, $2 = pattern (may be empty), $3 = path
|
|
185
|
+
[ -n "$FAL" ] || return 1
|
|
186
|
+
local a
|
|
187
|
+
while IFS= read -r a || [ -n "$a" ]; do
|
|
188
|
+
a="${a%$'\r'}"
|
|
189
|
+
case "$a" in
|
|
190
|
+
''|\#*) continue ;;
|
|
191
|
+
path:*) case "$3" in ${a#path:}) return 0 ;; esac ;;
|
|
192
|
+
rule:*) [ "${a#rule:}" = "$1" ] && return 0 ;;
|
|
193
|
+
*) [ -n "$2" ] && [ "$a" = "$2" ] && return 0 ;;
|
|
194
|
+
esac
|
|
195
|
+
done < "$FAL"
|
|
196
|
+
return 1
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Test files, by the naming each ecosystem uses. Names and not whole `test/` directories, because deleting a
|
|
200
|
+
# fixture or a helper there is not a test made easier. Rust integration tests are the one convention that is a
|
|
201
|
+
# directory rather than a name.
|
|
202
|
+
_floor_is_test(){ # $1 = path
|
|
203
|
+
case "$1" in
|
|
204
|
+
*.test.*|*.spec.*|*_test.*|*_spec.rb|test_*.py|*/test_*.py) return 0 ;;
|
|
205
|
+
*Test.java|*Tests.java|*Test.kt|*Tests.kt|*Test.cs|*Tests.cs|*Test.php|*Spec.scala|*Tests.swift) return 0 ;;
|
|
206
|
+
__tests__/*|*/__tests__/*|tests/*.rs|*/tests/*.rs) return 0 ;;
|
|
207
|
+
esac
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# One diff pass serves both checks, so the cost on an ordinary commit is two processes rather than four. Headers
|
|
212
|
+
# are read only between `diff --git` and the first hunk: a removed content line that itself starts with `-- ` (a
|
|
213
|
+
# SQL comment) shows up as `--- ` and would otherwise be mistaken for a file header.
|
|
214
|
+
#
|
|
215
|
+
# Assertions are counted NET per file, not line by line: changing an expectation removes one and adds one, and a
|
|
216
|
+
# line-level rule would flag every such edit. A test only loses its checks when it ends with fewer than it began.
|
|
217
|
+
# A rename is not a deletion and git does not report it as one, so moving a test file stays free.
|
|
218
|
+
# Whether a path is a test is decided once, by _floor_is_test, rather than written a second time in awk.
|
|
219
|
+
# ONE diff and ONE awk serve the whole floor guard — the deleted-test and assertion checks here, and the added-line
|
|
220
|
+
# corpus the pattern half reads further down. They used to be two diffs and two awks, plus two mktemps, which is
|
|
221
|
+
# what an ordinary commit paid for this guard: +7 processes, measured on Windows 11 at about +304 ms over a ~900 ms
|
|
222
|
+
# hook. Merging them removes four processes; the corpus files are named off TRACE_F and created with a builtin
|
|
223
|
+
# redirection, so no process makes them. Nothing about the trace or secret scans changed.
|
|
224
|
+
#
|
|
225
|
+
# Headers are read only between `diff --git` and the first hunk. The corpus parser used to treat any `+++ ` line as a
|
|
226
|
+
# file header, so an added line that itself began `++ ` came through as `+++ …` and re-pointed the path of every line
|
|
227
|
+
# after it; tracking the header state closes that as well. Unfiltered on purpose: deletions, modifications, additions
|
|
228
|
+
# and renames all arrive in one stream. A new file is never counted for assertions (it cannot lose any); a renamed
|
|
229
|
+
# test that also lost assertions is counted, which the old DM-only filter let through.
|
|
230
|
+
FLOOR_F="$TRACE_F.floor"; FLOOR_P="$TRACE_F.floorp"; : > "$FLOOR_F"; : > "$FLOOR_P"
|
|
231
|
+
_floor_diff(){ # $@ = extra git diff arguments
|
|
232
|
+
git diff "$@" --unified=0 --no-color --src-prefix=a/ --dst-prefix=b/ -- . ':(top,exclude).claude' 2>/dev/null
|
|
233
|
+
}
|
|
234
|
+
while IFS=$'\t' read -r kind f r a; do
|
|
235
|
+
[ -n "$f" ] || continue
|
|
236
|
+
_floor_is_test "$f" || continue
|
|
237
|
+
case "$kind" in
|
|
238
|
+
D) _floor_allowed "test-deleted" "" "$f" && continue
|
|
239
|
+
echo "FLOOR-GUARD [test-deleted]: $f is a test file this commit removes"
|
|
240
|
+
HIT=1 ;;
|
|
241
|
+
M) [ "$r" -gt "$a" ] || continue
|
|
242
|
+
_floor_allowed "assertion-removed" "" "$f" && continue
|
|
243
|
+
echo "FLOOR-GUARD [assertion-removed]: $f loses $((r - a)) assertion(s) and stays in the tree (removed $r, added $a)"
|
|
244
|
+
HIT=1 ;;
|
|
245
|
+
esac
|
|
246
|
+
done < <({ _floor_diff --cached; [ "${CREW_SCAN_UNSTAGED:-0}" = 1 ] && _floor_diff; } \
|
|
247
|
+
| LC_ALL=C awk -v F="$FLOOR_F" -v P="$FLOOR_P" '
|
|
248
|
+
/^diff --git / { hdr = 1; old = ""; p = ""; isnew = 0; skip = 1; next }
|
|
249
|
+
hdr && /^--- / { old = $0; sub(/^--- (a\/)?/, "", old); isnew = (old == "/dev/null"); next }
|
|
250
|
+
hdr && /^\+\+\+ / { np = $0; sub(/^\+\+\+ (b\/)?/, "", np)
|
|
251
|
+
if (np == "/dev/null") { if (old != "" && old != "/dev/null") printf "D\t%s\t0\t0\n", old; p = ""; skip = 1 }
|
|
252
|
+
else { p = np; skip = (np ~ /\.(md|mdx|txt|rst|adoc)$/) }
|
|
253
|
+
next }
|
|
254
|
+
/^@@/ { hdr = 0; s = $0; sub(/^@@ -[0-9,]+ \+/, "", s); sub(/[, ].*$/, "", s); ln = s + 0; next }
|
|
255
|
+
hdr { next }
|
|
256
|
+
p == "" { next }
|
|
257
|
+
/^[-+]/ {
|
|
258
|
+
b = substr($0, 2); plus = (substr($0, 1, 1) == "+")
|
|
259
|
+
if (plus && !skip) { print b >> F; print p ":" ln >> P }
|
|
260
|
+
if (plus) ln++
|
|
261
|
+
if (!isnew && (b ~ /(^|[^A-Za-z0-9_])(assert|Assert|expect|Expect|should|Should|verify|Verify)[A-Za-z_]*[[:space:]]*[.(]/ ||
|
|
262
|
+
b ~ /(^|[^A-Za-z0-9_])t\.(Error|Errorf|Fatal|Fatalf)\(/ ||
|
|
263
|
+
b ~ /(^|[^A-Za-z0-9_])require\./ ||
|
|
264
|
+
b ~ /(^|[[:space:]])assert[[:space:]]/)) {
|
|
265
|
+
if (plus) add[p]++; else rem[p]++
|
|
266
|
+
seen[p] = 1
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
END { for (f in seen) printf "M\t%s\t%d\t%d\n", f, rem[f] + 0, add[f] + 0 }')
|
|
270
|
+
|
|
271
|
+
# Nothing staged for the text scans AND the bloat gate is clean -> done.
|
|
272
|
+
{ [ -s "$TRACE_F" ] || [ -s "$SECRET_F" ] || [ "$HIT" -ne 0 ]; } || exit 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Ask "can ANY pattern match" once, before asking "which one".
|
|
276
|
+
#
|
|
277
|
+
# The three loops below spawn one grep PER PATTERN — 12 + 11 + 3 = 26 processes on every commit, and the
|
|
278
|
+
# overwhelming majority of commits are clean, so 26 of them exist only to say "no". Measured on 205 added
|
|
279
|
+
# lines: the trace loop 0.621 s -> 0.063 s, the secret loop 0.572 s -> 0.059 s. One decision, then the loop
|
|
280
|
+
# ONLY when the decision says there is something to name.
|
|
281
|
+
#
|
|
282
|
+
# Three-way on purpose, because grep has three answers and two of them must not shortcut:
|
|
283
|
+
# rc=1 nothing matches -> done. This is the fast path and the common one.
|
|
284
|
+
# rc=0 something matches -> run the loop, which names the pattern. The user still reads
|
|
285
|
+
# "forbidden expression -> '<pattern>'" verbatim; nothing is lost but time.
|
|
286
|
+
# rc=2 grep could not decide -> run the loop. TODAY'S BEHAVIOUR, deliberately.
|
|
287
|
+
#
|
|
288
|
+
# That last rung is what makes this safe rather than clever, and it was earned: joining the patterns compiles
|
|
289
|
+
# them TOGETHER, so ONE broken regex kills the whole alternation. Measured with a pattern file carrying a valid
|
|
290
|
+
# `Co-Authored-By` and a broken `foo[`, against a message that really does carry the trailer:
|
|
291
|
+
# grep -inEf together rc=2, ZERO matches <- the trailer goes unreported
|
|
292
|
+
# one grep per pattern catches it; only the broken pattern is skipped
|
|
293
|
+
# A blocklist is a file users edit by hand, so a broken pattern is not exotic — and failing OPEN there would be
|
|
294
|
+
# the worst possible direction. rc=2 also covers an alternation too large to compile (measured: 40 KB fine,
|
|
295
|
+
# 200 KB -> "grep: Memory exhausted", rc=2 -> loop), so the ARG_MAX question answers itself.
|
|
296
|
+
#
|
|
297
|
+
# Verified on the blocklists' OWN #test:/#test-clean: cases — 32 of them, the payload the gate actually
|
|
298
|
+
# defends: 32/32 identical verdicts. Calibrated by mutation (strip the parentheses so a group-scoped `|`
|
|
299
|
+
# escapes to top level): 8 mismatches, in BOTH directions — three clean messages newly rejected and two real
|
|
300
|
+
# secrets newly passed. Also checked structurally: no pattern in either shipped blocklist carries a bare
|
|
301
|
+
# top-level `|` (every one is inside a group) and none uses a back-reference, so joining cannot reshape them.
|
|
302
|
+
_crew_any(){ # $1 = pattern list $2 = corpus $3 = grep flags $4 = 1 for newline-joined fixed strings
|
|
303
|
+
local pat alt="" sep="|"
|
|
304
|
+
[ "$4" = 1 ] && sep="
|
|
305
|
+
"
|
|
306
|
+
while IFS= read -r pat || [ -n "$pat" ]; do
|
|
307
|
+
pat="${pat%$'\r'}"
|
|
308
|
+
case "$pat" in ''|\#*) continue;; esac
|
|
309
|
+
alt="${alt:+$alt$sep}$pat"
|
|
310
|
+
done < "$1"
|
|
311
|
+
[ -n "$alt" ] || return 1
|
|
312
|
+
grep -q$3 -- "$alt" "$2" 2>/dev/null
|
|
313
|
+
}
|
|
314
|
+
# (1) trace scan — case-INsensitive (vendor / AI-authorship strings), project files only
|
|
315
|
+
# `!= 1` AND NOT `= 0`, AND THE DIFFERENCE IS THE WHOLE GATE. _crew_any joins every pattern into ONE
|
|
316
|
+
# alternation and runs a single grep, so one malformed pattern anywhere in the file takes that grep down with
|
|
317
|
+
# it -- exit 2, not 1. Measured, with a list holding `(AKIA|ASIA)[A-Z0-9]{16}` and `[unclosed`:
|
|
318
|
+
# good list + matching corpus -> 0 good list + clean corpus -> 1
|
|
319
|
+
# broken list + matching corpus -> 2 broken list + clean corpus -> 2
|
|
320
|
+
# rc 2 means "I could not look", which is not "there is nothing there". So the guard treats anything that is
|
|
321
|
+
# not a clean 1 as a reason to run the per-pattern loop below, where every pattern is tried on its own and the
|
|
322
|
+
# broken one merely fails by itself.
|
|
323
|
+
#
|
|
324
|
+
# `= 0` reads as the same thing and is not: it skips the loop whenever the combined grep errors, so ONE typo
|
|
325
|
+
# in a project's own added pattern turns the entire scanner fail-open and lets the commit through.
|
|
326
|
+
#
|
|
327
|
+
# NOTHING WOULD CATCH THAT SUBSTITUTION. On a well-formed blocklist the two spellings are identical in every
|
|
328
|
+
# case -- measured above, 0 and 1 both route the same way -- so every test in this repo stays green, including
|
|
329
|
+
# the malformed-pattern case, which only goes red BECAUSE the loop still runs. This comment is the only thing
|
|
330
|
+
# standing between that rewrite and a silently open gate.
|
|
331
|
+
if _crew_any "$BL" "$TRACE_F" iE 0; then _dec=0; else _dec=$?; fi
|
|
332
|
+
if [ -f "$BL" ] && [ -s "$TRACE_F" ] && [ "$_dec" != 1 ]; then
|
|
333
|
+
while IFS= read -r pat || [ -n "$pat" ]; do
|
|
334
|
+
pat="${pat%$'\r'}" # tolerate a CRLF blocklist (Windows/autocrlf checkout) — a trailing \r
|
|
335
|
+
case "$pat" in ''|\#*) continue;; esac # in the pattern never matches the LF-normalised diff, blinding the gate
|
|
336
|
+
if [ -n "$AL" ] && grep -qxF -- "$pat" "$AL"; then continue; fi
|
|
337
|
+
# A PATTERN THAT CANNOT COMPILE IS A GATE THAT IS NOT THERE, and it used to say so in grep's words, not
|
|
338
|
+
# ours: an unbalanced bracket printed "grep: brackets ([ ]) not balanced" into the middle of the commit
|
|
339
|
+
# output with nothing naming the file, the line, or the consequence. This file invites users to add their
|
|
340
|
+
# own vendor pattern, and a user who adds a broken one believes they are covered while nothing matches.
|
|
341
|
+
# CI catches it in this repo -- smoke drives every pattern with its own case and goes red -- but a consumer
|
|
342
|
+
# project runs no such suite, so the hook has to say it itself. It warns rather than blocks: the other
|
|
343
|
+
# patterns all still ran, so the commit is no less scanned than it was, and refusing every commit over one
|
|
344
|
+
# typo would cost more than the typo does.
|
|
345
|
+
# `|| _gr=$?` and not a bare call: `set -e` is in force at the top of this file, and a grep that simply
|
|
346
|
+
# does not match exits 1, which would kill the hook on the first clean pattern. Measured the wrong way once
|
|
347
|
+
# already -- the bare form made every commit exit 1 with no output at all.
|
|
348
|
+
_gr=0; grep -iqE -- "$pat" "$TRACE_F" 2>/dev/null || _gr=$?
|
|
349
|
+
if [ "$_gr" = 0 ]; then
|
|
350
|
+
echo "TRACE-SCANNER: forbidden expression in added code -> '$pat' (§4.1/§4.2)"
|
|
351
|
+
HIT=1
|
|
352
|
+
elif [ "$_gr" -ge 2 ]; then
|
|
353
|
+
echo "TRACE-SCANNER: /$pat/ in $BL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
|
|
354
|
+
fi
|
|
355
|
+
done < "$BL"
|
|
356
|
+
fi
|
|
357
|
+
# (2) secret scan — case-SENSITIVE (token prefixes are case-specific); prints the PATTERN, never the value
|
|
358
|
+
if _crew_any "$SL" "$SECRET_F" E 0; then _dec=0; else _dec=$?; fi
|
|
359
|
+
if [ -f "$SL" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
|
|
360
|
+
while IFS= read -r pat || [ -n "$pat" ]; do
|
|
361
|
+
pat="${pat%$'\r'}" # tolerate a CRLF blocklist (Windows/autocrlf checkout)
|
|
362
|
+
case "$pat" in ''|\#*) continue;; esac
|
|
363
|
+
if [ -n "$SAL" ] && grep -qxF -- "$pat" "$SAL"; then continue; fi
|
|
364
|
+
_gr=0; grep -qE -- "$pat" "$SECRET_F" 2>/dev/null || _gr=$?
|
|
365
|
+
if [ "$_gr" = 0 ]; then
|
|
366
|
+
echo "SECRET-SCANNER: a staged line matches a secret pattern -> /$pat/"
|
|
367
|
+
HIT=1
|
|
368
|
+
elif [ "$_gr" -ge 2 ]; then
|
|
369
|
+
# Same reasoning as the trace loop above; the comment there is the long form.
|
|
370
|
+
echo "SECRET-SCANNER: /$pat/ in $SL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
|
|
371
|
+
fi
|
|
372
|
+
done < "$SL"
|
|
373
|
+
fi
|
|
374
|
+
|
|
375
|
+
# (3) private-path scan — a path that only exists on THIS machine has no business in a shared artifact.
|
|
376
|
+
#
|
|
377
|
+
# Why this is not a blocklist regex: "is this path private?" cannot be answered by a pattern. `/Users/me` is a
|
|
378
|
+
# placeholder every README wants, `/Users/ada` is a real person's home, and no ERE separates them (and ERE has
|
|
379
|
+
# no negative lookahead to spell "not a placeholder"). So the terms are derived from the machine doing the
|
|
380
|
+
# commit — where the answer is knowable exactly — plus whatever the repo owner adds by hand.
|
|
381
|
+
#
|
|
382
|
+
# It exists because this failure is not hypothetical: a work project's absolute path, pasted from a terminal
|
|
383
|
+
# into a CHANGELOG entry, shipped in eight consecutive releases before anyone noticed. The paste is the vector,
|
|
384
|
+
# so the gate has to sit where pasted text becomes a commit.
|
|
385
|
+
#
|
|
386
|
+
# automatic : $HOME, in the three spellings the same directory gets written as on Windows
|
|
387
|
+
# (/c/Users/x · C:/Users/x · C:\Users\x) — case-insensitively, since that filesystem is.
|
|
388
|
+
# by hand : .private-terms.txt at the repo root, one literal term per line ('#' comments). Put internal
|
|
389
|
+
# project names, client names, and host names there. GITIGNORE IT — it is a list of the very
|
|
390
|
+
# strings you do not want published.
|
|
391
|
+
# escape : .private-allowlist.txt, a term per full line, for the rare case a term is legitimately public.
|
|
392
|
+
PRIV_F="$(mktemp)"; PTERM_F="$(mktemp)"
|
|
393
|
+
PTF=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-terms.txt" ] && PTF="$GITROOT/.private-terms.txt"
|
|
394
|
+
PAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$GITROOT/.private-allowlist.txt"
|
|
395
|
+
{
|
|
396
|
+
# A home path shorter than this cannot be distinguishing (`/root`, `/`), and matching it would flag everything.
|
|
397
|
+
if [ -n "${HOME:-}" ] && [ "${#HOME}" -ge 8 ]; then
|
|
398
|
+
printf '%s\n' "$HOME"
|
|
399
|
+
# Git Bash reports /c/Users/x for what Windows itself calls C:\Users\x — both spellings reach a file.
|
|
400
|
+
case "$HOME" in
|
|
401
|
+
/[a-zA-Z]/?*)
|
|
402
|
+
d="$(printf '%s' "${HOME:1:1}" | tr 'a-z' 'A-Z')"; r="${HOME:3}"
|
|
403
|
+
printf '%s\n' "$d:/$r" "$d:\\${r//\//\\}" ;;
|
|
404
|
+
esac
|
|
405
|
+
fi
|
|
406
|
+
[ -n "$PTF" ] && sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$PTF"
|
|
407
|
+
} > "$PTERM_F" 2>/dev/null || true
|
|
408
|
+
if _crew_any "$PTERM_F" "$SECRET_F" iF 1; then _dec=0; else _dec=$?; fi
|
|
409
|
+
if [ -s "$PTERM_F" ] && [ -s "$SECRET_F" ] && [ "$_dec" != 1 ]; then
|
|
410
|
+
while IFS= read -r term || [ -n "$term" ]; do
|
|
411
|
+
term="${term%$'\r'}"
|
|
412
|
+
[ -n "$term" ] || continue
|
|
413
|
+
if [ -n "$PAL" ] && grep -qxF -- "$term" "$PAL"; then continue; fi
|
|
414
|
+
if grep -qiF -- "$term" "$SECRET_F"; then
|
|
415
|
+
# The term is echoed back deliberately: unlike a secret, the author has to see WHICH private string to
|
|
416
|
+
# replace, and it is already sitting in their own working tree.
|
|
417
|
+
echo "PRIVATE-PATH SCANNER: a staged line contains a machine-private string -> '$term' (§4.3)"
|
|
418
|
+
HIT=1
|
|
419
|
+
fi
|
|
420
|
+
done < "$PTERM_F"
|
|
421
|
+
fi
|
|
422
|
+
rm -f "$PRIV_F"
|
|
423
|
+
|
|
424
|
+
# (4) floor guard — a commit must not LOWER the quality bar on its way to a green check.
|
|
425
|
+
#
|
|
426
|
+
# An agent that hits a red check does not invent a clever loophole; it takes the cheapest road to green. That road
|
|
427
|
+
# is nearly always visible in the lines a commit adds (a checker switched off where it fired, a test told to stop
|
|
428
|
+
# running, a stub or a swallowed error where the work should be) or in what it takes away (a test file, the
|
|
429
|
+
# assertions inside one that stays). The patterns and their cases live in floor-blocklist.txt; the two structural
|
|
430
|
+
# checks that a single line cannot show are here.
|
|
431
|
+
#
|
|
432
|
+
# Built for this hook: the STAGED diff rather than a merge base, since at commit time that is exactly what enters
|
|
433
|
+
# history; bash, since every gate here must run with nothing installed; and the two exemptions a real stack needs,
|
|
434
|
+
# documentation and generated code.
|
|
435
|
+
#
|
|
436
|
+
# It reports the rule, the file, the line and the PATTERN, never the line's text: a suppressed line can carry a
|
|
437
|
+
# secret beside the comment, and the secret scan above is the only place that decides how a value is shown.
|
|
438
|
+
FL="$HERE/floor-blocklist.txt"
|
|
439
|
+
# FLOOR_F (added lines of project code) and FLOOR_P (`path:line` at the same line number) were built by the single
|
|
440
|
+
# floor pass ahead of the early exit above.
|
|
441
|
+
|
|
442
|
+
# A file that opens with a generator marker was not written by this commit's author, and an EF Core project needs
|
|
443
|
+
# this: EF Core puts a warning pragma into every model snapshot it emits (395k such files on GitHub, measured).
|
|
444
|
+
# `sed -n '1,5p'` and NOT `head -n 5`: under `set -o pipefail`, head closing the pipe early makes the producer die
|
|
445
|
+
# of SIGPIPE, and the pipeline reports 141 even when grep matched — measured, so the exemption would never apply.
|
|
446
|
+
_FLOOR_GEN="|"
|
|
447
|
+
_floor_generated(){ # $1 = repo-relative path; 0 when the file opens with a generator marker
|
|
448
|
+
case "$_FLOOR_GEN" in *"|$1=1|"*) return 0 ;; *"|$1=0|"*) return 1 ;; esac
|
|
449
|
+
local hdr
|
|
450
|
+
hdr="$(git show ":$1" 2>/dev/null | sed -n '1,5p')" || hdr=""
|
|
451
|
+
[ -n "$hdr" ] || hdr="$(sed -n '1,5p' -- "${GITROOT:-.}/$1" 2>/dev/null)" || hdr=""
|
|
452
|
+
if printf '%s\n' "$hdr" | grep -qE '<auto-generated|@generated|Code generated .*DO NOT EDIT|DO NOT EDIT|[Aa]uto-?generated|This file (is|was) (automatically )?generated'; then
|
|
453
|
+
_FLOOR_GEN="$_FLOOR_GEN$1=1|"; return 0
|
|
454
|
+
fi
|
|
455
|
+
_FLOOR_GEN="$_FLOOR_GEN$1=0|"; return 1
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
# (4a) line patterns. `!= 1` and not `= 0`, for the reason spelled out at the trace scan: a malformed pattern takes
|
|
459
|
+
# the combined grep down with exit 2, which means "could not look", not "nothing is there".
|
|
460
|
+
#
|
|
461
|
+
# EXEMPTIONS ARE APPLIED BEFORE THE PER-PATTERN LOOP, not inside it, and that ordering is a measured cost fix.
|
|
462
|
+
# Attributing a hit to its rule costs one grep per pattern (34 of them). The first version paid that on every
|
|
463
|
+
# commit the cheap pre-check flagged, then exempted the lines — so an EF Core migration, whose three generated files
|
|
464
|
+
# all carry a warning pragma, went from 20 processes to 73 and was allowed anyway. At the 62-135 ms a Git Bash
|
|
465
|
+
# process was measured to cost, that is several seconds on every migration commit of an EF Core project. Now the
|
|
466
|
+
# candidate lines are found with one grep, their files checked once each, and the loop runs only when a line that
|
|
467
|
+
# is NOT exempt remains. A malformed pattern takes the combined grep down with exit 2; then the slow path runs over
|
|
468
|
+
# every line, exactly as before, so a broken list still reports itself and still scans.
|
|
469
|
+
if _crew_any "$FL" "$FLOOR_F" E 0; then _dec=0; else _dec=$?; fi
|
|
470
|
+
if [ -f "$FL" ] && [ -s "$FLOOR_F" ] && [ "$_dec" != 1 ]; then
|
|
471
|
+
FLOOR_ALT=""
|
|
472
|
+
while IFS= read -r pat || [ -n "$pat" ]; do
|
|
473
|
+
pat="${pat%$'\r'}"
|
|
474
|
+
case "$pat" in ''|\#*) continue ;; esac
|
|
475
|
+
FLOOR_ALT="${FLOOR_ALT:+$FLOOR_ALT|}$pat"
|
|
476
|
+
done < "$FL"
|
|
477
|
+
KEEP="|"; SLOW=0; FLOOR_MAP=""
|
|
478
|
+
_cg=0; cand="$(grep -nE -- "$FLOOR_ALT" "$FLOOR_F" 2>/dev/null)" || _cg=$?
|
|
479
|
+
if [ "$_cg" -ge 2 ]; then
|
|
480
|
+
SLOW=1
|
|
481
|
+
elif [ -n "$cand" ]; then
|
|
482
|
+
nums="$(printf '%s\n' "$cand" | sed 's/:.*//' | tr '\n' ' ')"
|
|
483
|
+
# One awk maps every candidate line number to its `path:line` at once, instead of one sed per line.
|
|
484
|
+
CANDMAP="$(LC_ALL=C awk -v L="$nums" 'BEGIN { n = split(L, a, " "); for (i = 1; i <= n; i++) w[a[i]] = 1 } (NR in w) { print NR ":" $0 }' "$FLOOR_P")"
|
|
485
|
+
FLOOR_MAP="
|
|
486
|
+
$CANDMAP
|
|
487
|
+
"
|
|
488
|
+
# WHICH CANDIDATE FILES ARE GENERATED, ASKED ONCE FOR ALL OF THEM — measured on Windows, not assumed. Asking per
|
|
489
|
+
# file cost a Git Bash process pipeline each (~40 ms marginal there, three processes here), so 120 candidate
|
|
490
|
+
# files took 4.8 s while a single pass over all of them stayed flat at ~130 ms whatever the count. The pass is
|
|
491
|
+
# awk over the first FIVE lines of each file, not `grep -l` over whole files: a hand-written file that merely
|
|
492
|
+
# mentions a generator marker in a comment further down must not be exempted. It reads the working tree rather
|
|
493
|
+
# than the index; a generator header present in one and absent from the other is not a case worth a process per
|
|
494
|
+
# file, and the direction that could matter only makes the guard stricter.
|
|
495
|
+
# The answer seeds the same cache _floor_generated uses, so the attribution loop below pays nothing again.
|
|
496
|
+
FGEN_FILES=""; FGEN_SEEN="|"; set --
|
|
497
|
+
while IFS=: read -r n fpath _; do
|
|
498
|
+
[ -n "$fpath" ] || continue
|
|
499
|
+
case "$FGEN_SEEN" in *"|$fpath|"*) continue ;; esac
|
|
500
|
+
FGEN_SEEN="$FGEN_SEEN$fpath|"
|
|
501
|
+
[ -f "${GITROOT:-.}/$fpath" ] && set -- "$@" "${GITROOT:-.}/$fpath"
|
|
502
|
+
done <<EOF_FLOOR_CAND
|
|
503
|
+
$CANDMAP
|
|
504
|
+
EOF_FLOOR_CAND
|
|
505
|
+
FGEN_LIST=""
|
|
506
|
+
if [ "$#" -gt 0 ]; then
|
|
507
|
+
FGEN_LIST="$(LC_ALL=C awk 'FNR > 5 { nextfile }
|
|
508
|
+
/<auto-generated|@generated|Code generated .*DO NOT EDIT|DO NOT EDIT|[Aa]uto-?generated|This file (is|was) (automatically )?generated/ { print FILENAME; nextfile }' "$@" 2>/dev/null)" || FGEN_LIST=""
|
|
509
|
+
fi
|
|
510
|
+
for f in "$@"; do
|
|
511
|
+
rel="${f#"${GITROOT:-.}"/}"
|
|
512
|
+
case "
|
|
513
|
+
$FGEN_LIST
|
|
514
|
+
" in *"
|
|
515
|
+
$f
|
|
516
|
+
"*) _FLOOR_GEN="$_FLOOR_GEN$rel=1|" ;; *) _FLOOR_GEN="$_FLOOR_GEN$rel=0|" ;; esac
|
|
517
|
+
done
|
|
518
|
+
set --
|
|
519
|
+
while IFS=: read -r n fpath _; do
|
|
520
|
+
[ -n "$n" ] || continue
|
|
521
|
+
_floor_allowed "" "" "$fpath" && continue # path: exemptions only — no rule or pattern is known yet
|
|
522
|
+
_floor_generated "$fpath" && continue # answered from the cache seeded above
|
|
523
|
+
KEEP="$KEEP$n|"
|
|
524
|
+
done <<EOF_FLOOR_CAND2
|
|
525
|
+
$CANDMAP
|
|
526
|
+
EOF_FLOOR_CAND2
|
|
527
|
+
fi
|
|
528
|
+
if [ "$SLOW" = 1 ] || [ "$KEEP" != "|" ]; then
|
|
529
|
+
rule="unclassified"
|
|
530
|
+
while IFS= read -r pat || [ -n "$pat" ]; do
|
|
531
|
+
pat="${pat%$'\r'}"
|
|
532
|
+
case "$pat" in
|
|
533
|
+
'# rule: '*) rule="${pat#\# rule: }"; continue ;;
|
|
534
|
+
''|\#*) continue ;;
|
|
535
|
+
esac
|
|
536
|
+
_gr=0; hits="$(grep -nE -- "$pat" "$FLOOR_F" 2>/dev/null)" || _gr=$?
|
|
537
|
+
if [ "$_gr" -ge 2 ]; then
|
|
538
|
+
echo "FLOOR-GUARD: /$pat/ in $FL is not a valid regular expression, so it matches NOTHING. Every other pattern still ran; fix or remove this line."
|
|
539
|
+
continue
|
|
540
|
+
fi
|
|
541
|
+
[ -n "$hits" ] || continue
|
|
542
|
+
while IFS=: read -r n _; do
|
|
543
|
+
[ -n "$n" ] || continue
|
|
544
|
+
[ "$SLOW" = 1 ] || case "$KEEP" in *"|$n|"*) ;; *) continue ;; esac
|
|
545
|
+
# The location comes from CANDMAP, already in memory, with parameter expansion — not one sed per hit. A commit
|
|
546
|
+
# that sweeps a suppression across many files is exactly the one this loop sees, and it is typically retried
|
|
547
|
+
# after each fix, so a process per hit was paid again on every attempt. The slow path has no map; it uses sed.
|
|
548
|
+
if [ "$SLOW" = 1 ]; then
|
|
549
|
+
loc="$(sed -n "${n}p" "$FLOOR_P")"
|
|
550
|
+
else
|
|
551
|
+
loc="${FLOOR_MAP#*"
|
|
552
|
+
$n:"}"; loc="${loc%%
|
|
553
|
+
*}"
|
|
554
|
+
fi
|
|
555
|
+
fpath="${loc%:*}"
|
|
556
|
+
_floor_allowed "$rule" "$pat" "$fpath" && continue
|
|
557
|
+
_floor_generated "$fpath" && continue
|
|
558
|
+
echo "FLOOR-GUARD [$rule]: $loc adds a move that lowers the bar -> /$pat/"
|
|
559
|
+
HIT=1
|
|
560
|
+
done <<EOF_FLOOR_HITS
|
|
561
|
+
$hits
|
|
562
|
+
EOF_FLOOR_HITS
|
|
563
|
+
done < "$FL"
|
|
564
|
+
fi
|
|
565
|
+
fi
|
|
566
|
+
|
|
567
|
+
if [ "$HIT" -ne 0 ]; then
|
|
568
|
+
echo "-----------------------------------------------------------"
|
|
569
|
+
echo "Commit stopped. Remove the AI-authorship trace / vendor name / secret / bloated file / bar-lowering move from the staged changes."
|
|
570
|
+
echo "A bar-lowering move that is genuinely warranted? record it in .floor-allowlist.txt at the repo root (path:<glob>, rule:<name>, or the exact pattern line) — in the same commit, where review sees it. A path: glob's * also crosses directories: write the narrowest path."
|
|
571
|
+
echo "False positive (trace/secret)? add the EXACT pattern line to .trace-allowlist.txt or .secret-allowlist.txt at the repo root."
|
|
572
|
+
echo "Private path flagged but genuinely public? add that exact term to .private-allowlist.txt at the repo root."
|
|
573
|
+
echo "Large file is intentional? raise CREW_MAX_FILE_BYTES, use Git LFS, or gitignore the build/vendored path."
|
|
574
|
+
echo "Deliberate exception: 'git commit --no-verify' — only on an EXPLICIT request (§4.5)."
|
|
575
|
+
exit 1
|
|
576
|
+
fi
|
|
577
|
+
exit 0
|