javi-forge 1.17.0 → 1.19.0

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.
@@ -1,30 +1,188 @@
1
1
  #!/bin/bash
2
- # Commit-msg: block AI attribution in commit messages
3
2
  set -e
3
+ # =============================================================================
4
+ # COMMIT-MSG: Block accidental AI-attribution + enforce Conventional Commits
5
+ # =============================================================================
6
+ # Best-effort, NOT an adversarial-grade security control.
7
+ #
8
+ # Two INDEPENDENT guards run in order; both must pass:
9
+ # 1. AI-attribution guard (always-on raw-literal matching; best-effort NFKC
10
+ # normalization layered on top when perl + Unicode::Normalize exist).
11
+ # 2. Conventional-commit subject guard (raw first non-comment line).
12
+ #
13
+ # Scope of the attribution guard:
14
+ # Defends against: accidental copy-paste, whitespace tricks (double space,
15
+ # NBSP, ZWSP), markdown formatting, full-width latin, combining diacritics,
16
+ # dashes/underscores in model names, punctuation/emoji separators.
17
+ # Does NOT defend against: cross-script homoglyphs, tag characters, variation
18
+ # selectors, standalone provider names without verb/model, motivated
19
+ # adversaries with a Unicode table. If you need adversarial defense, use
20
+ # signed commits, not regex. The threat model is an HONEST committer.
21
+ #
22
+ # The test suite (commit-msg.test.sh) documents covered/accepted bypasses.
23
+ # =============================================================================
24
+
4
25
  COMMIT_MSG_FILE="$1"
5
- COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
26
+ RAW_MSG=$(cat "$COMMIT_MSG_FILE")
27
+
28
+ RED='\033[0;31m'
29
+ GREEN='\033[0;32m'
30
+ YELLOW='\033[1;33m'
31
+ NC='\033[0m'
32
+
33
+ # ─── Normalization ──────────────────────────────────────────────────
34
+ # If perl fails (not installed, Unicode::Normalize module absent), normalize
35
+ # returns the raw message. The pattern loop still matches against RAW_MSG, so a
36
+ # broken perl degrades gracefully but does not open everything up.
37
+ normalize_msg() {
38
+ if ! command -v perl >/dev/null 2>&1; then
39
+ printf "%s" "$1"
40
+ return
41
+ fi
42
+ # 2>/dev/null suppresses perl warnings. If the script fails outright,
43
+ # || printf "%s" "$1" lets the raw through — the match against RAW_MSG
44
+ # below still covers the literal patterns.
45
+ printf "%s" "$1" | perl -CSAD -pe '
46
+ # 1. Replace Unicode formatting/invisible chars with a SPACE (not delete).
47
+ # Deleting them joins "by" + "Claude" -> "byClaude" and the regex loses the match.
48
+ # ZWSP/ZWNJ/ZWJ/LRM/RLM, NBSP, bidi controls, BOM, word-joiner.
49
+ s/[\x{00A0}\x{200B}-\x{200F}\x{2028}-\x{202F}\x{2060}-\x{206F}\x{FEFF}]/ /g;
50
+ # 2. Strip combining diacritics. This folds "Ćlaude" (C + U+0301) to "Claude".
51
+ s/[\x{0300}-\x{036F}]//g;
52
+ # 3. NFKC normalizes compatibility forms (full-width latin -> ascii)
53
+ use Unicode::Normalize qw(NFKC);
54
+ $_ = NFKC($_);
55
+ # 4. Markdown / punctuation / emoji -> space
56
+ # Markdown: * _ ` ~
57
+ # Punctuation: . , : ; ! ? ( ) [ ] { } < > / \ | " '\''
58
+ # Emoji: Misc Symbols (2600-27BF), Pictographs (1F300-1FAFF)
59
+ s/[*_`~.,:;!?()\[\]{}<>\\\/|"'\''\x{2600}-\x{27BF}\x{1F300}-\x{1FAFF}]/ /g;
60
+ # 5. Collapse whitespace (includes \n, \t, multiple spaces)
61
+ s/\s+/ /g;
62
+ ' 2>/dev/null || printf "%s" "$1"
63
+ }
64
+
65
+ NORMALIZED_MSG=$(normalize_msg "$RAW_MSG")
66
+
67
+ # ─── Patterns (case insensitive, grep -iE) ──────────────────────────
68
+ #
69
+ # Notes:
70
+ # \b = word boundary (GNU grep ext.)
71
+ # [[:space:]]+ = one or more spaces (POSIX, portable)
72
+ # [-_ ]? = optional separator between provider and model
73
+ #
74
+ # Known, accepted limitation: Unicode homoglyphs (Cyrillic С instead of
75
+ # Latin C) require transliteration (Text::Unidecode), a non-core perl module.
76
+ # Documented, not defended against.
77
+ PROVIDER_PAT='(claude|gpt|chatgpt|openai|anthropic|gemini|copilot|llm|cursor|windsurf|codeium|cody|aider)'
6
78
 
7
79
  AI_PATTERNS=(
8
- "co-authored-by:.*claude" "co-authored-by:.*anthropic"
9
- "co-authored-by:.*gpt" "co-authored-by:.*openai"
10
- "co-authored-by:.*copilot" "co-authored-by:.*gemini"
11
- "co-authored-by:.*\\bai\\b"
12
- "made by claude" "made by gpt" "made by ai"
13
- "generated by claude" "generated by gpt" "generated by ai"
14
- "written by claude" "written by ai"
15
- "claude code" "claude opus" "claude sonnet" "claude haiku"
16
- "gpt-4" "gpt-3" "chatgpt"
17
- "@anthropic.com" "@openai.com"
80
+ # ── Co-authored-by trailer ────────────────────────────────────────
81
+ # Captures any provider in the same trailer.
82
+ "co-authored-by:.*\b${PROVIDER_PAT}\b"
83
+ # AI in trailer: AI alone OR AIAssistant / AIBot / AIAgent compounds
84
+ "co-authored-by:.*\bai(assistant|bot|agent|helper)?\b"
85
+ # Standalone Copilot/Codeium/Cursor in trailer (no vendor prefix)
86
+ "co-authored-by:.*\b(copilot|cursor|codeium|cody|aider|chatgpt)\b"
87
+
88
+ # ── Claude-Session trailer (Claude Code harness auto-appends this) ─
89
+ # The canonical harness trailer key + its session URL host. Anchored to
90
+ # the trailer key "claude-session:" and the "claude.ai" host, so it does
91
+ # NOT fire on a person named Claude or a bare "session" word (zero FP).
92
+ "claude-session:"
93
+ "claude\\.ai"
94
+
95
+ # ── Attribution verbs + by/with + provider ───────────────────────
96
+ # e.g.: "made by Claude", "generated with GPT", "built with OpenAI"
97
+ # Verbs expanded in audit-2 to cover "I used X", "X helped me",
98
+ # "wrote by AI", "prompted to write", "asked Claude to".
99
+ "(made|generated|written|created|assisted|built|developed|authored|produced|coded|crafted|helped|used|wrote|prompted|asked|fixed|refactored)[[:space:]]+(by|with|using|via)[[:space:]]+${PROVIDER_PAT}"
100
+ "(made|generated|written|created|assisted|built|developed|authored|produced|coded|crafted|helped|used|wrote|prompted|asked|fixed|refactored)[[:space:]]+(by|with|using|via)[[:space:]]+\bai(assistant|bot|agent|helper)?\b"
101
+
102
+ # ── "with help from / thanks to / powered by X" ──────────────────
103
+ "(with[[:space:]]+help[[:space:]]+from|thanks[[:space:]]+to|powered[[:space:]]+by|courtesy[[:space:]]+of)[[:space:]]+${PROVIDER_PAT}"
104
+ "(with[[:space:]]+help[[:space:]]+from|thanks[[:space:]]+to|powered[[:space:]]+by|courtesy[[:space:]]+of)[[:space:]]+\bai(assistant|bot|agent|helper)?\b"
105
+
106
+ # ── "X-assisted" / "X assisted" ──────────────────────────────────
107
+ # Covers: anthropic-assisted, openai assisted, ai_assisted, etc.
108
+ "${PROVIDER_PAT}[-_ ]?assisted"
109
+ "\bai[-_ ]?assisted"
110
+
111
+ # ── Standalone branding / tooling ────────────────────────────────
112
+ # ACCEPTED TRADEOFF (JDA-002): "cursor" is homonymous with the DB/UI term.
113
+ # Because "-" is a grep word boundary, ANY bare-cursor match also fires on
114
+ # "cursor-based" / "DB cursor"; blocking "built with Cursor" (an AI-tool
115
+ # attribution we must catch) necessarily catches those. No tightening
116
+ # separates them without overfitting a blocklist, so the FP is documented,
117
+ # not chased. Domain/IDE/AI forms are the reliable signal.
118
+ "claude[-_ ]?code"
119
+ "(github|microsoft)[-_ ]?copilot"
120
+ "\bchatgpt\b"
121
+ "\bcursor[-_ ]?(ai|ide)?\b"
122
+ "\bwindsurf\b"
123
+ "\bcodeium\b"
124
+ "\bcody\b"
125
+ "\baider\b"
126
+
127
+ # ── Claude + model variant ───────────────────────────────────────
128
+ # Covers: "claude opus", "claude-sonnet", "Claude3", "claude4.5", "Claude 4.7"
129
+ "claude[-_ ]?(opus|sonnet|haiku|[0-9])"
130
+
131
+ # ── GPT + version ────────────────────────────────────────────────
132
+ # Covers: gpt-4, gpt 3, gpt_5, gpt4.5
133
+ "gpt[-_ ]?[0-9]"
134
+
135
+ # ── Provider email domains ───────────────────────────────────────
136
+ "@(anthropic|openai|cursor|codeium)\\.(com|sh|ai)"
137
+ "noreply@(anthropic|openai)"
18
138
  )
19
139
 
140
+ # Run pattern matching against the NORMALIZED message.
141
+ # Use both raw and normalized — raw catches anything normalization might over-strip.
20
142
  for pattern in "${AI_PATTERNS[@]}"; do
21
- if echo "$COMMIT_MSG" | grep -iqE "$pattern"; then
22
- echo ""
23
- echo "COMMIT BLOCKED: AI Attribution Detected"
24
- echo " Pattern: $pattern"
25
- echo " Remove AI attribution. You are the sole author."
26
- echo ""
27
- exit 1
28
- fi
143
+ if printf "%s" "$NORMALIZED_MSG" | grep -iqE "$pattern" \
144
+ || printf "%s" "$RAW_MSG" | grep -iqE "$pattern"; then
145
+ echo -e ""
146
+ echo -e "${RED}COMMIT BLOCKED: AI Attribution Detected${NC}"
147
+ echo -e ""
148
+ echo -e "${RED}Pattern matched:${NC} ${YELLOW}$pattern${NC}"
149
+ echo -e ""
150
+ echo -e "${RED}Your commit message:${NC}"
151
+ echo -e "${YELLOW}$RAW_MSG${NC}"
152
+ echo -e ""
153
+ echo -e "${GREEN}Remove AI attribution. You are the sole author.${NC}"
154
+ echo -e ""
155
+ exit 1
156
+ fi
29
157
  done
158
+
159
+ # ─── Conventional-commit subject guard ──────────────────────────────
160
+ # Independent from the attribution guard above. Validates the RAW subject
161
+ # (first non-blank, non-comment line — git has not stripped comments yet at
162
+ # commit-msg time). Exempt prefixes skip the regex; everything else must
163
+ # match the Conventional Commit grammar.
164
+ CONV_COMMIT_RE='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?!?: .+'
165
+
166
+ SUBJECT=$(printf '%s\n' "$RAW_MSG" | grep -vE '^[[:space:]]*(#|$)' | head -n1 || true)
167
+
168
+ case "$SUBJECT" in
169
+ "Merge "*|"fixup! "*|"squash! "*|"amend! "*|"reword! "*|"Revert "*)
170
+ # Exempt: merge/autosquash/revert generated subjects skip the check.
171
+ exit 0
172
+ ;;
173
+ esac
174
+
175
+ if ! [[ "$SUBJECT" =~ $CONV_COMMIT_RE ]]; then
176
+ echo -e ""
177
+ echo -e "${RED}COMMIT BLOCKED: subject must be Conventional Commit${NC}"
178
+ echo -e ""
179
+ echo -e "${RED}Your subject:${NC} ${YELLOW}${SUBJECT}${NC}"
180
+ echo -e ""
181
+ echo -e "${GREEN}Expected:${NC} <type>[optional scope][!]: <description>"
182
+ echo -e " types: build chore ci docs feat fix perf refactor revert style test"
183
+ echo -e " e.g. feat(hooks): add native gate"
184
+ echo -e ""
185
+ exit 1
186
+ fi
187
+
30
188
  exit 0
@@ -1,32 +1,36 @@
1
1
  {
2
- "pre-commit": {
3
- "version": 1,
4
- "sha256": "811f34ce57517e129554bc9c09801a66c0207332cd3e7f2950db43a40580e914",
5
- "historical": [
6
- {
7
- "sha256": "811f34ce57517e129554bc9c09801a66c0207332cd3e7f2950db43a40580e914",
8
- "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
9
- }
10
- ]
11
- },
12
- "pre-push": {
13
- "version": 1,
14
- "sha256": "7de58640aeef33085a49f31f1d9d0c8bacde0069d6d3265ae41aa8d3cd14d7a5",
15
- "historical": [
16
- {
17
- "sha256": "7de58640aeef33085a49f31f1d9d0c8bacde0069d6d3265ae41aa8d3cd14d7a5",
18
- "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
19
- }
20
- ]
21
- },
22
- "commit-msg": {
23
- "version": 1,
24
- "sha256": "1c23a60cd4ba7f6bc666da400b5d2971c4294782c8d9ce41543e7815de11a1d6",
25
- "historical": [
26
- {
27
- "sha256": "1c23a60cd4ba7f6bc666da400b5d2971c4294782c8d9ce41543e7815de11a1d6",
28
- "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
29
- }
30
- ]
31
- }
2
+ "pre-commit": {
3
+ "version": 1,
4
+ "sha256": "811f34ce57517e129554bc9c09801a66c0207332cd3e7f2950db43a40580e914",
5
+ "historical": [
6
+ {
7
+ "sha256": "811f34ce57517e129554bc9c09801a66c0207332cd3e7f2950db43a40580e914",
8
+ "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
9
+ }
10
+ ]
11
+ },
12
+ "pre-push": {
13
+ "version": 1,
14
+ "sha256": "7de58640aeef33085a49f31f1d9d0c8bacde0069d6d3265ae41aa8d3cd14d7a5",
15
+ "historical": [
16
+ {
17
+ "sha256": "7de58640aeef33085a49f31f1d9d0c8bacde0069d6d3265ae41aa8d3cd14d7a5",
18
+ "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
19
+ }
20
+ ]
21
+ },
22
+ "commit-msg": {
23
+ "version": 2,
24
+ "sha256": "127fb8bfebd81d6b06e6f04bdf1be0036a3a224268ac54f11784043f55796a18",
25
+ "historical": [
26
+ {
27
+ "sha256": "1c23a60cd4ba7f6bc666da400b5d2971c4294782c8d9ce41543e7815de11a1d6",
28
+ "firstCommit": "5587b3b9b9ed3c86809c0a3d89a850dd2bce7e1a"
29
+ },
30
+ {
31
+ "sha256": "127fb8bfebd81d6b06e6f04bdf1be0036a3a224268ac54f11784043f55796a18",
32
+ "firstCommit": "65cb02e7efc5581303e2b20727acf07e3d88de65"
33
+ }
34
+ ]
35
+ }
32
36
  }
@@ -316,6 +316,18 @@ function describeRunners(resolved) {
316
316
  }
317
317
  export async function runCI(options, onStep, onGateOutcome) {
318
318
  const { projectDir = process.cwd(), mode = "full", noDocker = false, noGhagga = false, noSecurity = false, timeout = 600, } = options;
319
+ // ── Run-scoped Docker availability (lazy-memoized) ─────────────────────────
320
+ // Computed at most ONCE per run and only when an image gate needs it. The
321
+ // full/quick prologue below assigns its own `isDockerAvailable()` result back
322
+ // into this cache so `runGates` never re-probes; the gates-only path leaves it
323
+ // undefined until an image gate lazily triggers the probe (a native-only
324
+ // gates-only repo never touches Docker — behaves exactly as before slice 3).
325
+ let dockerAvailableCache;
326
+ const dockerAvailable = async () => (dockerAvailableCache ??= await isDockerAvailable());
327
+ const dockerGate = {
328
+ noDocker,
329
+ isAvailable: dockerAvailable,
330
+ };
319
331
  // ── Resolve runners (once — nothing downstream re-detects) ─────────────────
320
332
  const stepDetect = "detect";
321
333
  report(onStep, stepDetect, "Detecting stack", "running");
@@ -343,7 +355,7 @@ export async function runCI(options, onStep, onGateOutcome) {
343
355
  report(onStep, mode, `${mode} mode`, "error", detail);
344
356
  throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
345
357
  }
346
- await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
358
+ await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
347
359
  return;
348
360
  }
349
361
  // Legacy single-runner view for the zero-config auto path. Keeping this
@@ -409,6 +421,9 @@ export async function runCI(options, onStep, onGateOutcome) {
409
421
  const stepDocker = "docker-check";
410
422
  report(onStep, stepDocker, "Checking Docker", "running");
411
423
  const dockerOk = await isDockerAvailable();
424
+ // Reuse this result for the gate fail-closed check — `runGates` must not
425
+ // run a second `docker info` (memoization seam).
426
+ dockerAvailableCache = dockerOk;
412
427
  if (!dockerOk) {
413
428
  report(onStep, stepDocker, "Docker not available", "error", "Start Docker or use --no-docker");
414
429
  throw new Error("Docker is not available");
@@ -515,7 +530,7 @@ export async function runCI(options, onStep, onGateOutcome) {
515
530
  // already `full` or `quick` here; the guard makes the contract explicit.
516
531
  // `runGates` no-ops on an empty gate list (a v1 repo carries none).
517
532
  if (mode === "full" || mode === "quick") {
518
- await runGates(resolved.gates, projectDir, onStep, onGateOutcome);
533
+ await runGates(resolved.gates, projectDir, onStep, dockerGate, onGateOutcome);
519
534
  }
520
535
  }
521
536
  // =============================================================================
@@ -938,7 +953,10 @@ async function runGateCommand(gate, cmd, projectDir, nativeEnv, containerEnv) {
938
953
  timedOut: result.timedOut,
939
954
  };
940
955
  }
941
- async function runGates(gates, projectDir, onStep, onOutcome) {
956
+ async function runGates(gates, projectDir, onStep,
957
+ // REQUIRED, so it MUST precede the optional `onOutcome` (TS1016: a required
958
+ // parameter cannot follow an optional one). Both call sites pass it positionally.
959
+ docker, onOutcome) {
942
960
  if (gates.length === 0)
943
961
  return;
944
962
  const blockingFailures = [];
@@ -989,6 +1007,33 @@ async function runGates(gates, projectDir, onStep, onOutcome) {
989
1007
  ...extra,
990
1008
  });
991
1009
  report(onStep, stepId, label, "running");
1010
+ // Fail-closed matrix (slice 3): an image gate that cannot reach Docker is
1011
+ // REFUSED — it MUST NOT fall through to native/unpinned execution and MUST
1012
+ // NOT be silently skipped/passed. Blocking → build failure (feeds the
1013
+ // aggregate throw); informative → `warning` (never a false-green). A gate
1014
+ // WITHOUT `image` is unaffected and runs native regardless of --no-docker.
1015
+ //
1016
+ // `isAvailable()` is touched ONLY for an image gate under Docker: the
1017
+ // `noDocker` short-circuit means an image-less set (or a --no-docker run)
1018
+ // never shells out to `docker info`, keeping the native path zero-cost.
1019
+ if (gate.image !== undefined) {
1020
+ if (docker.noDocker || !(await docker.isAvailable())) {
1021
+ const why = docker.noDocker
1022
+ ? "--no-docker set"
1023
+ : "Docker not available";
1024
+ const reason = `gate "${gate.id}" requires image "${gate.image}" but ${why} — refusing (never runs native/unpinned)`;
1025
+ if (blocking) {
1026
+ blockingFailures.push(gate.id);
1027
+ report(onStep, stepId, `${label} failed`, "error", reason);
1028
+ emit("error", { reason });
1029
+ }
1030
+ else {
1031
+ report(onStep, stepId, `${label} failed (informative)`, "warning", reason);
1032
+ emit("warning", { reason });
1033
+ }
1034
+ continue; // NEVER falls through to native execution.
1035
+ }
1036
+ }
992
1037
  // Per-gate env: build the INJECTED allowlist (engine keys + baseline) once,
993
1038
  // then split into two maps (JDB-001):
994
1039
  // - nativeEnv: full host env + injected + gate.env — a spawn env MAP (never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test:watch": "vitest",
17
17
  "test:coverage": "vitest run --coverage",
18
18
  "test:mutation": "stryker run",
19
- "test:hooks": "bash ci-local/hooks/commit-msg.test.sh",
19
+ "test:hooks": "bash assets/hooks/commit-msg.test.sh",
20
20
  "lint": "biome check src/",
21
21
  "lint:fix": "biome check --write src/",
22
22
  "format": "biome format --write src/",
@@ -29,6 +29,7 @@
29
29
  "!dist/__integration__/**",
30
30
  "!dist/e2e/**",
31
31
  "assets/",
32
+ "!assets/**/*.test.sh",
32
33
  "ci-local/",
33
34
  "modules/",
34
35
  "templates/",
@@ -1,286 +0,0 @@
1
- #!/bin/bash
2
- # =============================================================================
3
- # COMMIT-MSG TEST SUITE
4
- # =============================================================================
5
- # Valida que el hook commit-msg bloquea AI attribution en TODAS las variantes
6
- # conocidas, incluyendo bypasses identificados en auditoría 2026-05-17.
7
- #
8
- # Uso:
9
- # ./commit-msg.test.sh
10
- #
11
- # Exit code 0 = todos los tests pasan
12
- # Exit code 1 = al menos un test falla
13
- # =============================================================================
14
-
15
- set -u
16
-
17
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
- HOOK="$SCRIPT_DIR/commit-msg"
19
-
20
- if [ ! -x "$HOOK" ]; then
21
- echo "ERROR: commit-msg hook not found or not executable: $HOOK"
22
- exit 1
23
- fi
24
-
25
- PASS=0
26
- FAIL=0
27
- FAILED_TESTS=()
28
-
29
- # Garantiza limpieza de tmpfiles aún si el hook revienta con set -u, etc.
30
- TMP_FILES=()
31
- cleanup() {
32
- if [ ${#TMP_FILES[@]} -gt 0 ]; then
33
- rm -f "${TMP_FILES[@]}"
34
- fi
35
- }
36
- trap cleanup EXIT INT TERM
37
-
38
- make_tmp() {
39
- local f
40
- f=$(mktemp)
41
- TMP_FILES+=("$f")
42
- printf "%s" "$1" > "$f"
43
- printf "%s" "$f"
44
- }
45
-
46
- # expect_block <description> <message>
47
- # El hook debe BLOQUEAR este mensaje (exit != 0)
48
- expect_block() {
49
- local desc="$1"
50
- local msg="$2"
51
- local tmpfile
52
- tmpfile=$(make_tmp "$msg")
53
- if "$HOOK" "$tmpfile" >/dev/null 2>&1; then
54
- FAIL=$((FAIL + 1))
55
- FAILED_TESTS+=("[NOT BLOCKED] $desc | msg: '$msg'")
56
- echo " FAIL: $desc (should block but passed): '$msg'"
57
- else
58
- PASS=$((PASS + 1))
59
- fi
60
- }
61
-
62
- # expect_pass <description> <message>
63
- # El hook debe PERMITIR este mensaje (exit 0)
64
- expect_pass() {
65
- local desc="$1"
66
- local msg="$2"
67
- local tmpfile
68
- tmpfile=$(make_tmp "$msg")
69
- if "$HOOK" "$tmpfile" >/dev/null 2>&1; then
70
- PASS=$((PASS + 1))
71
- else
72
- FAIL=$((FAIL + 1))
73
- FAILED_TESTS+=("[FALSE POSITIVE] $desc | msg: '$msg'")
74
- echo " FAIL: $desc (should pass but blocked): '$msg'"
75
- fi
76
- }
77
-
78
- echo "=== Running commit-msg hook test suite ==="
79
- echo ""
80
-
81
- # ─── Cases que DEBEN bloquear ────────────────────────────────────────
82
-
83
- # Co-authored-by con cada proveedor
84
- expect_block "Co-authored-by Claude" "feat: add foo
85
-
86
- Co-authored-by: Claude <claude@anthropic.com>"
87
-
88
- expect_block "Co-authored-by claude lowercase" "feat: bar
89
-
90
- co-authored-by: claude <c@a.com>"
91
-
92
- expect_block "Co-authored-by double space" "feat: baz
93
-
94
- Co-authored-by: Claude <c@a.com>"
95
-
96
- expect_block "Co-authored-by GPT" "feat: x
97
-
98
- Co-authored-by: GPT-4 <g@o.com>"
99
-
100
- expect_block "Co-authored-by Copilot" "feat: y
101
-
102
- Co-authored-by: GitHub Copilot <c@gh.com>"
103
-
104
- expect_block "Co-authored-by ai standalone" "feat: z
105
-
106
- Co-authored-by: AI Assistant <a@b.com>"
107
-
108
- # Made/Generated/Written/Created by/with X — single space
109
- expect_block "Made by Claude" "feat: add Made by Claude"
110
- expect_block "Made with Claude" "feat: Made with Claude"
111
- expect_block "Generated by GPT" "feat: Generated by GPT"
112
- expect_block "Written with OpenAI" "fix: Written with OpenAI"
113
- expect_block "Created by Anthropic" "feat: Created by Anthropic"
114
- expect_block "Assisted by AI" "feat: Assisted by AI"
115
- expect_block "Built with Gemini" "feat: Built with Gemini"
116
- expect_block "Developed with Copilot" "feat: Developed with Copilot"
117
- expect_block "Authored by LLM" "feat: Authored by LLM"
118
-
119
- # Made/Generated/etc — DOUBLE space (bypass identificado en audit)
120
- expect_block "Made by Claude (double space)" "feat: Made by Claude"
121
- expect_block "Generated with GPT (double space)" "fix: Generated with GPT"
122
- expect_block "Made by\tClaude (tab)" "$(printf 'feat: Made by\tClaude')"
123
-
124
- # With help from X
125
- expect_block "With help from Claude" "feat: x
126
-
127
- With help from Claude"
128
- expect_block "with help from GPT-4" "feat: with help from GPT-4"
129
-
130
- # X-assisted variants (bypass identificado)
131
- expect_block "anthropic-assisted" "feat: anthropic-assisted refactor"
132
- expect_block "openai-assisted" "feat: openai-assisted change"
133
- expect_block "ai-assisted" "feat: ai-assisted commit"
134
- expect_block "Claude assisted (space)" "feat: Claude assisted work"
135
- expect_block "LLM_assisted (underscore)" "feat: llm_assisted feature"
136
-
137
- # Tool branding
138
- expect_block "claude code" "feat: built with claude code"
139
- expect_block "claude-code" "feat: via claude-code"
140
- expect_block "claude_code" "feat: claude_code generated"
141
- expect_block "GitHub Copilot" "feat: GitHub Copilot helped"
142
- expect_block "Microsoft Copilot" "feat: Microsoft Copilot did this"
143
-
144
- # Claude model variants (bypass identificado)
145
- expect_block "claude opus (space)" "feat: claude opus tested"
146
- expect_block "claude-sonnet (dash)" "feat: claude-sonnet used"
147
- expect_block "claude_haiku (underscore)" "feat: claude_haiku approach"
148
- expect_block "Claude3.5" "feat: Claude3.5 wrote this"
149
- expect_block "claude4.7" "feat: claude4.7 contributed"
150
- expect_block "Claude 4" "feat: Claude 4 logic"
151
-
152
- # GPT model variants
153
- expect_block "gpt-4" "feat: gpt-4 reviewed"
154
- expect_block "gpt 5" "feat: gpt 5 wrote"
155
- expect_block "gpt_3" "feat: gpt_3 helped"
156
- expect_block "GPT4" "feat: GPT4 was used"
157
-
158
- # ChatGPT
159
- expect_block "ChatGPT" "feat: ChatGPT generated"
160
- expect_block "chatgpt" "feat: powered by chatgpt"
161
-
162
- # Emails
163
- expect_block "@anthropic.com" "feat: x
164
-
165
- Author: foo@anthropic.com"
166
- expect_block "@openai.com" "feat: y
167
-
168
- Co-authored-by: bot@openai.com"
169
- expect_block "noreply@anthropic" "feat: noreply@anthropic.example"
170
-
171
- # ─── Bypasses identificados en review 2026-05-17 (post-hardening) ─────
172
-
173
- # Markdown formatting alrededor del proveedor
174
- expect_block "Made by **Claude** (markdown bold)" "feat: Made by **Claude**"
175
- expect_block "Made by __GPT__ (markdown italic)" "feat: Made by __GPT__"
176
- expect_block "Made by \`OpenAI\` (markdown code)" "feat: Made by \`OpenAI\`"
177
- expect_block "Made by ~~Gemini~~ (strikethrough)" "feat: Made by ~~Gemini~~"
178
-
179
- # Puntuación entre verbo y proveedor (no whitespace)
180
- expect_block "Made by.Claude" "feat: Made by.Claude"
181
- expect_block "Made by,Claude" "feat: Made by,Claude"
182
- expect_block "Made by:Claude" "feat: Made by:Claude"
183
- expect_block "Made by;Claude" "feat: Made by;Claude"
184
-
185
- # Newline entre verbo y proveedor
186
- expect_block "Made by newline Claude" "$(printf 'feat: Made by\nClaude')"
187
-
188
- # Emoji separator
189
- expect_block "Made by emoji Claude" "feat: Made by 🤖 Claude"
190
- expect_block "Generated by emoji GPT" "feat: Generated by ✨ GPT"
191
-
192
- # Unicode invisible chars (ZWSP, RLM, NBSP)
193
- expect_block "Made by ZWSP Claude" "$(printf 'feat: Made by​Claude')"
194
- expect_block "Made by NBSP Claude" "$(printf 'feat: Made by Claude')"
195
- expect_block "Made by RLM Claude" "$(printf 'feat: Made by‏Claude')"
196
-
197
- # "thanks/powered by/courtesy of" alternativas
198
- expect_block "thanks to Claude" "feat: thanks to Claude for help"
199
- expect_block "powered by GPT" "feat: powered by GPT"
200
- expect_block "courtesy of OpenAI" "feat: courtesy of OpenAI"
201
-
202
- # AI IDEs / herramientas
203
- expect_block "Cursor IDE" "feat: built with Cursor"
204
- expect_block "Windsurf" "feat: written with Windsurf"
205
- expect_block "Codeium" "feat: codeium suggested this"
206
- expect_block "Cody" "feat: with Cody help"
207
- expect_block "Aider" "feat: aider generated"
208
-
209
- # Co-authored-by sin espacio antes de AI (bypass del review Sonnet)
210
- expect_block "Co-authored-by AIAssistant" "feat: x
211
-
212
- Co-authored-by: AIAssistant <a@b.com>"
213
-
214
- # Co-authored-by Copilot standalone (sin GitHub/Microsoft prefix)
215
- expect_block "Co-authored-by bare Copilot" "feat: x
216
-
217
- Co-authored-by: Copilot <c@gh.com>"
218
-
219
- # Verbos alternativos
220
- expect_block "produced by Claude" "feat: produced by Claude"
221
- expect_block "coded by GPT" "feat: coded by GPT"
222
- expect_block "crafted with AI" "feat: crafted with AI"
223
-
224
- # Using/via como alternativas a by/with
225
- expect_block "made using Claude" "feat: made using Claude"
226
- expect_block "generated via GPT" "feat: generated via GPT"
227
-
228
- # Full-width characters (NFKC normalization)
229
- expect_block "Made by Claude (fullwidth)" "feat: Made by Claude"
230
-
231
- # ─── Bypasses adicionales identificados en audit-2 (2026-05-17 review B) ──
232
-
233
- # Combining diacritics (U+0300-U+036F) — Ćlaude = C + U+0301
234
- expect_block "Ćlaude (combining acute)" "$(printf 'feat: Made by Ćlaude')"
235
- expect_block "Cláude (combining grave)" "$(printf 'feat: Made by Clàude')"
236
-
237
- # Verbos expandidos — sólo aplican si hay (by|with|using|via) después del verbo.
238
- # "asked Claude to ..." NO bloquea por diseño: el riesgo de false positive en
239
- # nombres personales o conversaciones legítimas es alto. Si necesitás defensa
240
- # adversarial usá signed commits.
241
- expect_block "helped by Claude" "feat: helped by Claude"
242
- expect_block "wrote with GPT" "feat: wrote with GPT-4"
243
- expect_block "prompted via AI" "feat: prompted via AI"
244
- expect_block "fixed by Copilot" "feat: fixed by Copilot"
245
- expect_block "refactored with Cursor" "feat: refactored with Cursor"
246
-
247
- # Cases que DOCUMENTADAMENTE NO bloqueamos (bare provider sin verb-prepositional):
248
- # - "asked Claude to refactor" (FP risk: pidió a una persona llamada Claude)
249
- # - "I used Claude to write" (FP risk: usé al usuario Claude para escribir)
250
- # - "claude helped me debug" (FP risk: Claude la persona ayudó a debuggear)
251
- # Estos son límites aceptados; ver header del hook.
252
- expect_pass "asked Claude (person, not attribution)" "feat: asked Claude to review the PR"
253
- expect_pass "user Claude in seed" "feat: configure user Claude in fixtures"
254
-
255
- # ─── Cases que DEBEN PASAR (false positive checks) ────────────────────
256
-
257
- expect_pass "Simple feat" "feat: add user profile endpoint"
258
- expect_pass "Simple fix" "fix: handle empty array in parser"
259
- expect_pass "Refactor" "refactor: extract auth into middleware"
260
- expect_pass "Docs" "docs: clarify install instructions"
261
- expect_pass "Multi-line valid commit" "feat: add search
262
-
263
- Implements full-text search using PostgreSQL FTS.
264
- Includes integration tests."
265
- expect_pass "Author named Claude (not attribution)" "feat: add user named Claude in seed data"
266
- expect_pass "AI as acronym in product name" "feat: integrate with PaiAi API gateway"
267
- expect_pass "Word 'made' without AI context" "feat: changes I made last week to the parser"
268
-
269
- # ─── Summary ──────────────────────────────────────────────────────────
270
-
271
- echo ""
272
- echo "=== Results ==="
273
- echo " Passed: $PASS"
274
- echo " Failed: $FAIL"
275
- echo ""
276
-
277
- if [ $FAIL -gt 0 ]; then
278
- echo "Failed tests:"
279
- for t in "${FAILED_TESTS[@]}"; do
280
- echo " - $t"
281
- done
282
- exit 1
283
- fi
284
-
285
- echo "All tests passed."
286
- exit 0