@chrono-meta/fh-gate 1.4.71 → 1.4.73
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/.claude/rules/.public-surface-patterns.defaults +44 -0
- package/.claude/rules/fh_4axis_gate.md +207 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +26 -2
- package/CATALOG.md +59 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/package.json +21 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh-gate.sh +76 -2
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/prepush_guard_check.sh +374 -0
- package/scripts/psa_scan_lib.sh +153 -0
- package/scripts/public_surface_scan_files.sh +157 -0
- package/scripts/selfcheck.sh +16 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_fh_gate_regressions.sh +46 -2
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.git-hooks/pre-commit +848 -0
- package/templates/.git-hooks/pre-push +585 -0
- package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/predelete_check.sh +72 -0
- package/templates/regression_guard.sh +563 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gate_pathspec_check.sh — known-pair regression anchor for gate PATH COVERAGE.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS
|
|
5
|
+
# The gate-locality class has now recurred four times: scripts/ (2026-06-26), AGENTS.md
|
|
6
|
+
# inheritance (#111/#117), agent definitions (2026-06-27), and SKILL_detail.md (2026-07-26).
|
|
7
|
+
# Every instance had the same shape: an asset class the canonical rule *declared* covered, which
|
|
8
|
+
# the gate implementation's path term did not actually match — and the miss rendered as PASS,
|
|
9
|
+
# because "no file matched" and "all files passed" are indistinguishable downstream.
|
|
10
|
+
#
|
|
11
|
+
# The 07-26 instance was the sharpest: the term was the literal `SKILL\.md`, and the string
|
|
12
|
+
# `SKILL_detail.md` does not contain `SKILL.md` (the underscore breaks it). 17 files, 208,710 B,
|
|
13
|
+
# 27.7% of the skill-spec surface, 16 of 17 holding fenced code blocks — ungated. It leaked twice
|
|
14
|
+
# for real (371c04f, e661931: single-file edits to a GATE SKILL's own behavioral spec).
|
|
15
|
+
#
|
|
16
|
+
# So this is not a style check. It is the mechanical anchor for the fix, per the FH rule that a
|
|
17
|
+
# harness edit is a draft until a check fails when the mistake recurs.
|
|
18
|
+
#
|
|
19
|
+
# METHOD — known-pair, per CLAUDE.md §Instrument-Calibration. Every case asserts BOTH directions:
|
|
20
|
+
# a known-positive that MUST match and a known-negative that MUST NOT. A checker that only ever
|
|
21
|
+
# confirms positives cannot tell "covers everything" from "matches everything".
|
|
22
|
+
#
|
|
23
|
+
# Usage: bash scripts/gate_pathspec_check.sh # exit 0 = all pairs hold, 1 = a pair broke
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
27
|
+
HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
|
|
28
|
+
GUARD="$REPO_ROOT/templates/regression_guard.sh"
|
|
29
|
+
|
|
30
|
+
fail=0
|
|
31
|
+
pass=0
|
|
32
|
+
|
|
33
|
+
# Extract a live regex from the implementation instead of restating it here. A copy would drift
|
|
34
|
+
# from the thing it claims to verify — which is the very defect class this file exists to catch.
|
|
35
|
+
extract_term() { # $1 = file, $2 = variable-assignment marker
|
|
36
|
+
grep -A2 "^${2}=" "$1" 2>/dev/null | grep -oE '\| grep -E "[^"]+"' | head -1 \
|
|
37
|
+
| sed -E 's/^\| grep -E "//; s/"$//'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
check() { # $1 = label, $2 = regex, $3 = should-match path, $4 = should-NOT-match path
|
|
41
|
+
local label="$1" re="$2" pos="$3" neg="$4" ok=1 rc
|
|
42
|
+
# grep exits 2 on a MALFORMED regex. `if ! grep -q` would negate that 2 into "true" and report
|
|
43
|
+
# it as "known-positive not covered" — fail-closed in direction, but it misnames the cause, and
|
|
44
|
+
# a checker that misreports why it failed sends the next reader to fix the wrong thing.
|
|
45
|
+
echo "$pos" | grep -qE "$re"; rc=$?
|
|
46
|
+
if [ "$rc" -eq 2 ]; then
|
|
47
|
+
echo " ❌ $label — extracted pattern is not a valid regex (instrument error, NOT a coverage result)"
|
|
48
|
+
echo " pattern: $re"
|
|
49
|
+
fail=$((fail + 1)); return
|
|
50
|
+
fi
|
|
51
|
+
[ "$rc" -ne 0 ] && { echo " ❌ $label — known-POSITIVE not covered: $pos"; ok=0; }
|
|
52
|
+
if echo "$neg" | grep -qE "$re"; then
|
|
53
|
+
echo " ❌ $label — known-NEGATIVE wrongly covered: $neg"; ok=0
|
|
54
|
+
fi
|
|
55
|
+
if [ "$ok" -eq 1 ]; then
|
|
56
|
+
echo " ✅ $label"; pass=$((pass + 1))
|
|
57
|
+
else
|
|
58
|
+
fail=$((fail + 1))
|
|
59
|
+
fi
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
echo "gate_pathspec_check — known-pair coverage anchors"
|
|
63
|
+
echo
|
|
64
|
+
|
|
65
|
+
# ── 1. pre-commit HEAVY term ──────────────────────────────────────────────────
|
|
66
|
+
HEAVY_RE="$(extract_term "$HOOK" HEAVY)"
|
|
67
|
+
if [ -z "$HEAVY_RE" ]; then
|
|
68
|
+
echo " ❌ could not extract HEAVY term from $HOOK — instrument error, NOT a pass"
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
|
71
|
+
# The 07-26 regression: detail files must be HEAVY. Negative: a tracks/ record must not be.
|
|
72
|
+
check "HEAVY covers SKILL_detail.md" "$HEAVY_RE" \
|
|
73
|
+
"plugins/fh-meta/skills/frontier-digest/SKILL_detail.md" \
|
|
74
|
+
"tracks/_meta/fh_signal_2026-07-26_ai.md"
|
|
75
|
+
check "HEAVY still covers SKILL.md" "$HEAVY_RE" \
|
|
76
|
+
"plugins/fh-meta/skills/frontier-digest/SKILL.md" \
|
|
77
|
+
"README.md"
|
|
78
|
+
# Prior gate-locality instances — anchored so a future edit cannot silently drop them.
|
|
79
|
+
check "HEAVY covers agent definitions (seam #3)" "$HEAVY_RE" \
|
|
80
|
+
"plugins/fh-meta/agents/challenger.md" \
|
|
81
|
+
"docs/README.md"
|
|
82
|
+
check "HEAVY covers scripts/*.sh (seam #1)" "$HEAVY_RE" \
|
|
83
|
+
"scripts/gate_pathspec_check.sh" \
|
|
84
|
+
"tracks/_audit/session_2026_07_26_agentsmith-sister.md"
|
|
85
|
+
|
|
86
|
+
# ── 2. regression_guard GUARD_PATHSPEC ────────────────────────────────────────
|
|
87
|
+
# Read the array as the guard itself defines it; match with the same glob semantics git uses.
|
|
88
|
+
# NOTE: no `mapfile` — macOS ships bash 3.2, where it does not exist. This is the documented
|
|
89
|
+
# bash-3.2 portability class; a 4.x-only builtin here would make the anchor itself the thing that
|
|
90
|
+
# breaks on the operator's own machine.
|
|
91
|
+
SPEC=()
|
|
92
|
+
while IFS= read -r line; do
|
|
93
|
+
[ -n "$line" ] && SPEC+=("$line")
|
|
94
|
+
done < <(sed -n '/^GUARD_PATHSPEC=(/,/^)/p' "$GUARD" | grep -oE "'[^']+'" | tr -d "'")
|
|
95
|
+
if [ "${#SPEC[@]:-0}" -eq 0 ]; then
|
|
96
|
+
echo " ❌ could not extract GUARD_PATHSPEC from $GUARD — instrument error, NOT a pass"
|
|
97
|
+
exit 1
|
|
98
|
+
fi
|
|
99
|
+
spec_matches() { # $1 = path
|
|
100
|
+
local p="$1" g
|
|
101
|
+
for g in "${SPEC[@]}"; do
|
|
102
|
+
# shellcheck disable=SC2254
|
|
103
|
+
case "$p" in $g) return 0 ;; esac
|
|
104
|
+
done
|
|
105
|
+
return 1
|
|
106
|
+
}
|
|
107
|
+
for pair in \
|
|
108
|
+
"plugins/fh-meta/skills/frontier-digest/SKILL_detail.md|tracks/_meta/x.md|PATHSPEC covers SKILL_detail.md" \
|
|
109
|
+
"plugins/fh-meta/skills/frontier-digest/SKILL.md|README.md|PATHSPEC still covers SKILL.md" \
|
|
110
|
+
"CLAUDE.md|CLAUDE.local.md|PATHSPEC covers CLAUDE.md but not the local override"
|
|
111
|
+
do
|
|
112
|
+
IFS='|' read -r pos neg label <<< "$pair"
|
|
113
|
+
ok=1
|
|
114
|
+
spec_matches "$pos" || { echo " ❌ $label — known-POSITIVE not covered: $pos"; ok=0; }
|
|
115
|
+
spec_matches "$neg" && { echo " ❌ $label — known-NEGATIVE wrongly covered: $neg"; ok=0; }
|
|
116
|
+
if [ "$ok" -eq 1 ]; then echo " ✅ $label"; pass=$((pass + 1)); else fail=$((fail + 1)); fi
|
|
117
|
+
done
|
|
118
|
+
|
|
119
|
+
# ── 3. Canonical-vs-implementation parity ─────────────────────────────────────
|
|
120
|
+
# regression_guard.sh's own comment: "두 목록이 갈리면 갈린 쪽이 조용히 무검사 구간이 된다."
|
|
121
|
+
# Anchor that warning mechanically for the asset class that just broke.
|
|
122
|
+
CANON="$REPO_ROOT/.claude/rules/fh_4axis_gate.md"
|
|
123
|
+
# Scope the match to the ASSET-LIST sentence, not the whole file. A bare whole-file grep would go
|
|
124
|
+
# green on a line that says "SKILL_detail.md is excluded" — i.e. it would certify parity against
|
|
125
|
+
# documentation that contradicts the code. Match the declaration line itself.
|
|
126
|
+
if grep -q 'Whenever the AI modifies FH assets.*SKILL_detail\.md' "$CANON" 2>/dev/null; then
|
|
127
|
+
echo " ✅ canonical rule declares SKILL_detail.md (in the asset-list line)"; pass=$((pass + 1))
|
|
128
|
+
else
|
|
129
|
+
echo " ❌ canonical rule ($CANON) no longer declares SKILL_detail.md — the two lists diverged,"
|
|
130
|
+
echo " which is exactly the silent no-check condition this anchor exists to prevent."
|
|
131
|
+
fail=$((fail + 1))
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# ── 4. Enumeration sweep — the anti-guessing check ────────────────────────────
|
|
135
|
+
# Every fix above answers "is THIS name covered?" — which only ever closes the names someone
|
|
136
|
+
# thought of. This one inverts it: enumerate what actually EXISTS under plugins/*/skills/ and
|
|
137
|
+
# assert the HEAVY term covers all of it. A new companion-file convention (SKILL_summary.md,
|
|
138
|
+
# a nested docs/ page, a deeper skill directory) then fails HERE, at introduction, instead of
|
|
139
|
+
# waiting for someone to notice the naming gap years later. Reality is the input, not a guess.
|
|
140
|
+
# (Adversarial credit: an Axis-2 sidecar pass argued the name-by-name fixes could not, in
|
|
141
|
+
# principle, close the class — correct, and this is the answer to it.)
|
|
142
|
+
uncovered=""
|
|
143
|
+
while IFS= read -r f; do
|
|
144
|
+
[ -z "$f" ] && continue
|
|
145
|
+
echo "$f" | grep -qE "$HEAVY_RE" || uncovered="$uncovered$f
|
|
146
|
+
"
|
|
147
|
+
done < <(cd "$REPO_ROOT" && find plugins -path '*/skills/*' -name '*.md' -type f 2>/dev/null | sort)
|
|
148
|
+
if [ -z "$uncovered" ]; then
|
|
149
|
+
echo " ✅ enumeration: every .md under plugins/*/skills/ is covered by the HEAVY term"
|
|
150
|
+
pass=$((pass + 1))
|
|
151
|
+
else
|
|
152
|
+
echo " ❌ enumeration: files exist under plugins/*/skills/ that NO gate term covers —"
|
|
153
|
+
printf '%s' "$uncovered" | sed 's/^/ /'
|
|
154
|
+
echo " Either widen the gate term, or state in fh_4axis_gate.md why this class is exempt."
|
|
155
|
+
fail=$((fail + 1))
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
echo
|
|
159
|
+
if [ "$fail" -eq 0 ]; then
|
|
160
|
+
echo "gate_pathspec_check: PASS ($pass pairs)"
|
|
161
|
+
exit 0
|
|
162
|
+
fi
|
|
163
|
+
echo "gate_pathspec_check: FAIL ($fail broken, $pass ok)"
|
|
164
|
+
echo "A gate path term stopped covering an asset class it is declared to cover."
|
|
165
|
+
echo "Do NOT relax the anchor to make it green — fix the term, or retire the pair deliberately."
|
|
166
|
+
exit 1
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# prepush_guard_check.sh — known-pair anchor for the pre-push PUBLISH-boundary guards.
|
|
3
|
+
#
|
|
4
|
+
# Sibling of scripts/universal_guard_check.sh (which anchors the pre-COMMIT universal guards).
|
|
5
|
+
# This one anchors what pre-push added on 2026-07-26: the confidentiality CONTENT scan over the
|
|
6
|
+
# commits a push would actually publish, and the load-bearing cross-family acknowledgment.
|
|
7
|
+
#
|
|
8
|
+
# WHY IT EXISTS AS A SCRIPT AND NOT AS A CHECKLIST — measured, twice, in one session:
|
|
9
|
+
# While repairing this very hook, TWO fail-opens were authored into it and neither was caught by
|
|
10
|
+
# the checks in use at the time:
|
|
11
|
+
# (1) `git show $(git rev-list …)` expanded every SHA into argv; on a large push that hits
|
|
12
|
+
# ARG_MAX, git dies, the capture is empty, and an empty capture reads as "no hits" → PASS.
|
|
13
|
+
# (2) A mangled `${...}` produced a RUNTIME "bad substitution" that aborted the hook — and the
|
|
14
|
+
# hook still exited 0, i.e. every push allowed. `bash -n` PASSED on that file: bad
|
|
15
|
+
# substitution is a runtime error, not a parse error. Syntax-checking a gate is not testing it.
|
|
16
|
+
# So this anchor always runs the hook FOR REAL and greps the output for runtime faults, in addition
|
|
17
|
+
# to checking verdicts. A gate that aborts must never be readable as a gate that passed.
|
|
18
|
+
#
|
|
19
|
+
# Runs in throwaway repos (mktemp): never touches this repo's index or worktree.
|
|
20
|
+
# Usage: bash scripts/prepush_guard_check.sh → exit 0 all pairs hold, 1 otherwise.
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
24
|
+
HOOK_SRC="$REPO_ROOT/templates/.git-hooks/pre-push"
|
|
25
|
+
DEF_SRC="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
|
|
26
|
+
[ -f "$HOOK_SRC" ] || { echo "❌ FAIL — pre-push hook not found"; exit 1; }
|
|
27
|
+
[ -f "$DEF_SRC" ] || { echo "❌ FAIL — pattern defaults not found"; exit 1; }
|
|
28
|
+
|
|
29
|
+
# Test the STAGED blob when the hook is staged — same reasoning as universal_guard_check.sh: an
|
|
30
|
+
# anchor that reads the worktree can be bypassed by staging a regression and restoring the worktree.
|
|
31
|
+
WORK=$(mktemp -d) || exit 1
|
|
32
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
33
|
+
_status=$(git -C "$REPO_ROOT" -c core.quotePath=false diff --cached --name-status --no-renames 2>/dev/null \
|
|
34
|
+
| awk -F'\t' '$2 == "templates/.git-hooks/pre-push" { print $1; exit }')
|
|
35
|
+
case "$_status" in
|
|
36
|
+
D) echo "❌ FAIL — pre-push is being DELETED from the index — fail-closed."; exit 1 ;;
|
|
37
|
+
'') cp "$HOOK_SRC" "$WORK/hook" ;;
|
|
38
|
+
*) git -C "$REPO_ROOT" show ":templates/.git-hooks/pre-push" > "$WORK/hook" 2>/dev/null \
|
|
39
|
+
|| { echo "❌ FAIL — staged pre-push blob unreadable — fail-closed."; exit 1; } ;;
|
|
40
|
+
esac
|
|
41
|
+
HOOK="$WORK/hook"
|
|
42
|
+
|
|
43
|
+
FAILED=0
|
|
44
|
+
# Fixtures assembled at runtime so this file's own bytes carry no matching credential shape
|
|
45
|
+
# (same rule as universal_guard_check.sh — a fixture file excluded from scanning would be a hole).
|
|
46
|
+
AWSK="AKIA""1234567890ABCDEF"
|
|
47
|
+
PATK="ghp""_abcdefghijklmnopqrstuvwxyz012345"
|
|
48
|
+
DOCK="AKIAIOSFODNN7EXAMPLE" # the documented example key: must be exempt, so it stays literal
|
|
49
|
+
|
|
50
|
+
newrepo() { # echoes a fresh repo path with the pattern layers in place and one base commit
|
|
51
|
+
local d; d=$(mktemp -d)
|
|
52
|
+
mkdir -p "$d/.claude/rules" "$d/templates/.git-hooks" "$d/tracks/_meta"
|
|
53
|
+
cp "$DEF_SRC" "$d/.claude/rules/.public-surface-patterns.defaults"
|
|
54
|
+
# The operator override is GITIGNORED in the real repo, so it never enters history. Committing it
|
|
55
|
+
# here made its own literals show up as leaks in the pushed content — a fixture artifact that read
|
|
56
|
+
# exactly like a product defect. Mirror reality instead.
|
|
57
|
+
printf '.claude/rules/.public-surface-patterns\n' > "$d/.gitignore"
|
|
58
|
+
printf 'HIGH\tzzsynthoperator\n' > "$d/.claude/rules/.public-surface-patterns"
|
|
59
|
+
cp "$HOOK" "$d/templates/.git-hooks/pre-push"
|
|
60
|
+
mkdir -p "$d/scripts"
|
|
61
|
+
cp "$REPO_ROOT/scripts/psa_scan_lib.sh" "$d/scripts/psa_scan_lib.sh"
|
|
62
|
+
( cd "$d" && git init -q -b main && git config user.email t@example.com && git config user.name t \
|
|
63
|
+
&& git add -A >/dev/null 2>&1 && git commit -qm base >/dev/null 2>&1 ) || return 1
|
|
64
|
+
printf '%s' "$d"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# check <name> <repo> <expect: block|pass> <refline...>
|
|
68
|
+
check() {
|
|
69
|
+
local name="$1" repo="$2" expect="$3"; shift 3
|
|
70
|
+
local out rc got
|
|
71
|
+
out=$(printf '%s\n' "$@" | ( cd "$repo" && bash templates/.git-hooks/pre-push origin git@example:x/y.git 2>&1 )); rc=$?
|
|
72
|
+
# A runtime fault is its own failure mode, checked BEFORE the verdict: an aborted hook that exits 0
|
|
73
|
+
# would otherwise be scored as a clean pass — the exact defect this anchor was written for.
|
|
74
|
+
if printf '%s' "$out" | grep -qiE 'bad substitution|unbound variable|syntax error|command not found'; then
|
|
75
|
+
echo " ❌ $name — RUNTIME FAULT in the hook (a hook that aborts is not a hook that passed)"
|
|
76
|
+
printf '%s\n' "$out" | grep -iE 'bad substitution|unbound|syntax error|command not found' | sed 's/^/ /' | head -3
|
|
77
|
+
FAILED=1; return
|
|
78
|
+
fi
|
|
79
|
+
# A hook killed by a signal, or one that dies printing nothing, produces no fault TEXT — the grep
|
|
80
|
+
# above cannot see it, and rc alone would score it as a pass (R7 audit, 2026-07-26). So a `pass`
|
|
81
|
+
# additionally requires the leg's own marker line: proof it ran to the point of making a claim.
|
|
82
|
+
# "It blocked" is not the same as "it blocked correctly". A missing dependency, an unreadable
|
|
83
|
+
# file, or any harness error also exits non-zero — and every BLOCK pair would score green while
|
|
84
|
+
# the gate was actually broken. (Observed: after the scan logic moved into a shared library, the
|
|
85
|
+
# sandbox repos lacked it, so 6 of 8 BLOCK pairs still "passed" — for the wrong reason.) A block
|
|
86
|
+
# therefore has to name a confidentiality cause.
|
|
87
|
+
if [ "$rc" -ne 0 ]; then
|
|
88
|
+
if printf '%s' "$out" | grep -qE '(leak —|leak in pushed|incomplete confidentiality instrument|unusable pattern)'; then
|
|
89
|
+
got=block
|
|
90
|
+
else
|
|
91
|
+
echo " ❌ $name — blocked, but for a HARNESS reason, not a confidentiality finding"
|
|
92
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -6
|
|
93
|
+
FAILED=1; return
|
|
94
|
+
fi
|
|
95
|
+
elif printf '%s' "$out" | grep -qF 'FH Pre-Publish'; then
|
|
96
|
+
got=pass
|
|
97
|
+
else
|
|
98
|
+
echo " ❌ $name — hook exited 0 without reaching the publish check (silent abort is not a pass)"
|
|
99
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -5
|
|
100
|
+
FAILED=1; return
|
|
101
|
+
fi
|
|
102
|
+
if [ "$got" = "$expect" ]; then
|
|
103
|
+
echo " ✅ $name (expected $expect)"
|
|
104
|
+
else
|
|
105
|
+
echo " ❌ $name — expected $expect, got $got"
|
|
106
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -8
|
|
107
|
+
FAILED=1
|
|
108
|
+
fi
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
echo "[prepush-guard] known-pair anchor"
|
|
112
|
+
|
|
113
|
+
# ── Pair 1: a token in the pushed HISTORY, with a clean tip. Net-diff-based scanning misses this. ──
|
|
114
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
115
|
+
( cd "$R" && printf 'tok %s\n' "$PATK" > s.md && git add s.md && git commit -qm add >/dev/null \
|
|
116
|
+
&& git rm -q s.md && git commit -qm remove >/dev/null )
|
|
117
|
+
check "token added then REMOVED (history) → BLOCK" "$R" block \
|
|
118
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
119
|
+
rm -rf "$R"
|
|
120
|
+
|
|
121
|
+
# ── Pair 2: evidence truncation. Twenty documented example keys ahead of one real key used to
|
|
122
|
+
# produce zero hits AND an affirmative "no token" line — wrong out loud, not merely incomplete. ──
|
|
123
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
124
|
+
( cd "$R" && for i in $(seq 1 20); do echo "example: $DOCK"; done > d.md \
|
|
125
|
+
&& echo "real: $AWSK" >> d.md && git add d.md && git commit -qm docs >/dev/null )
|
|
126
|
+
check "20 example keys THEN a real key → BLOCK" "$R" block \
|
|
127
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
128
|
+
rm -rf "$R"
|
|
129
|
+
|
|
130
|
+
# ── Pair 3: same line, placeholder first. Taking only the first match per line hid the real token. ──
|
|
131
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
132
|
+
( cd "$R" && printf '%s then %s\n' "$DOCK" "$AWSK" > e.md && git add e.md && git commit -qm same >/dev/null )
|
|
133
|
+
check "placeholder BEFORE real, same line → BLOCK" "$R" block \
|
|
134
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
135
|
+
rm -rf "$R"
|
|
136
|
+
|
|
137
|
+
# ── Pair 4: no over-blocking. Documented example keys alone must push cleanly, or the override
|
|
138
|
+
# becomes routine and the gate is disarmed. ──
|
|
139
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
140
|
+
( cd "$R" && for i in $(seq 1 20); do echo "example: $DOCK"; done > f.md && git add f.md && git commit -qm only >/dev/null )
|
|
141
|
+
check "documented example keys only → PASS " "$R" pass \
|
|
142
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
143
|
+
rm -rf "$R"
|
|
144
|
+
|
|
145
|
+
# ── Pair 5: multi-ref push. Ranges concatenated into one arg list let `--not` from ref A flip
|
|
146
|
+
# polarity for ref B, so a token on the second branch went unseen. ──
|
|
147
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
148
|
+
( cd "$R" && git checkout -qb clean1 "$B" && echo ok > c1.md && git add c1.md && git commit -qm c1 >/dev/null \
|
|
149
|
+
&& git checkout -qb dirty2 "$B" && printf 'tok %s\n' "$PATK" > c2.md && git add c2.md && git commit -qm c2 >/dev/null )
|
|
150
|
+
C1=$(cd "$R" && git rev-parse clean1); C2=$(cd "$R" && git rev-parse dirty2)
|
|
151
|
+
check "multi-ref push, token on 2nd ref → BLOCK" "$R" block \
|
|
152
|
+
"refs/heads/clean1 $C1 refs/heads/clean1 $B" \
|
|
153
|
+
"refs/heads/dirty2 $C2 refs/heads/dirty2 $B"
|
|
154
|
+
rm -rf "$R"
|
|
155
|
+
|
|
156
|
+
# ── Pair 6: instrument completeness, and the line between "not configured" and "broken".
|
|
157
|
+
# CHANGED DELIBERATELY 2026-07-26 — this pair used to expect BLOCK on an absent operator override.
|
|
158
|
+
# Two things overturned that: selfcheck flagged it as over-blocking (T7: "guard over-fires; that
|
|
159
|
+
# trains the override"), and the reasoning did not hold — the override contains THIS operator's
|
|
160
|
+
# literals, so another environment lacking it was never protected by it anyway. What protects a fresh
|
|
161
|
+
# clone is the generic credential shapes in the COMMITTED layer. So an absent override now warns, and
|
|
162
|
+
# what still blocks is a genuinely BROKEN pattern source, which affects everyone. Both pinned. ──
|
|
163
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
164
|
+
( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null && rm -f .claude/rules/.public-surface-patterns )
|
|
165
|
+
check "operator override absent (per-operator) → PASS " "$R" pass \
|
|
166
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
167
|
+
rm -rf "$R"
|
|
168
|
+
|
|
169
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
170
|
+
( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null \
|
|
171
|
+
&& : > .claude/rules/.public-surface-patterns.defaults ) # present but EMPTY = broken, not unconfigured
|
|
172
|
+
check "committed defaults EMPTY (broken) → BLOCK" "$R" block \
|
|
173
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
174
|
+
rm -rf "$R"
|
|
175
|
+
|
|
176
|
+
# Applicability is mechanical: no committed pattern source at all = not an FH checkout = legs N/A.
|
|
177
|
+
# Without this the hook blocked every push in any bare repo it was copied into, which is how the
|
|
178
|
+
# over-block was found in the first place.
|
|
179
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
180
|
+
( cd "$R" && echo ok > g.md && git add g.md && git commit -qm g >/dev/null \
|
|
181
|
+
&& rm -f .claude/rules/.public-surface-patterns.defaults .claude/rules/.public-surface-patterns )
|
|
182
|
+
check "no committed pattern source (not FH) → PASS " "$R" pass \
|
|
183
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
184
|
+
rm -rf "$R"
|
|
185
|
+
|
|
186
|
+
# ── Pair 7: a pattern row with a SPACE instead of a TAB defines no detector. Silently skipping it
|
|
187
|
+
# (this copy's behaviour until the R7 sweep) certifies a push clean against a pattern that never
|
|
188
|
+
# existed. pre-commit and the publish scanner were already fail-closed; this one was missed. ──
|
|
189
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
190
|
+
( cd "$R" && printf 'HIGH zzsynthoperator\n' > .claude/rules/.public-surface-patterns \
|
|
191
|
+
&& printf 'token zzsynthoperator\n' > n.md && git add -A && git commit -qm notab >/dev/null )
|
|
192
|
+
check "pattern row with SPACE not TAB → BLOCK" "$R" block \
|
|
193
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
194
|
+
rm -rf "$R"
|
|
195
|
+
|
|
196
|
+
# ── Pair 8: a NEW branch whose commits already exist on a DIFFERENT remote. `--not --remotes`
|
|
197
|
+
# excludes commits reachable from ANY remote, so the range came out empty and the push published
|
|
198
|
+
# them to THIS remote unscanned. Simulated by planting a refs/remotes ref for another remote. ──
|
|
199
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
200
|
+
( cd "$R" && printf 'tok %s\n' "$PATK" > o.md && git add o.md && git commit -qm other >/dev/null \
|
|
201
|
+
&& git update-ref refs/remotes/otherremote/main "$(git rev-parse HEAD)" )
|
|
202
|
+
check "new branch, commits on ANOTHER remote → BLOCK" "$R" block \
|
|
203
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f 0000000000000000000000000000000000000000"
|
|
204
|
+
rm -rf "$R"
|
|
205
|
+
|
|
206
|
+
# ── Pair 9: the LOW allowlist must survive into the PUSH leg. Files like scripts/sync-to-be.sh name
|
|
207
|
+
# the companion store as part of doing their job; pre-commit exempts them at LOW severity and the
|
|
208
|
+
# push leg did not, because it had flattened the diff and kept no file context. That over-block was
|
|
209
|
+
# found by the FIRST REAL PUSH of this very change, after seven adversarial rounds missed it — an
|
|
210
|
+
# over-blocking gate trains PUBLIC_SURFACE_OK into reflex, so it is pinned in both directions. ──
|
|
211
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
212
|
+
( cd "$R" && mkdir -p scripts && printf '# backs up to zzsynthoperator\n' > scripts/sync-to-be.sh \
|
|
213
|
+
&& printf 'LOW\tzzsynthoperator\n' > .claude/rules/.public-surface-patterns \
|
|
214
|
+
&& git add -A && git commit -qm allowlisted >/dev/null )
|
|
215
|
+
check "LOW token in an allowlisted file → PASS " "$R" pass \
|
|
216
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
217
|
+
rm -rf "$R"
|
|
218
|
+
|
|
219
|
+
# ── Pair 9-b: the same LOW token in a file that is NOT allowlisted must still block. ──
|
|
220
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
221
|
+
( cd "$R" && printf 'mentions zzsynthoperator\n' > notes.md \
|
|
222
|
+
&& printf 'LOW\tzzsynthoperator\n' > .claude/rules/.public-surface-patterns \
|
|
223
|
+
&& git add -A && git commit -qm notallowlisted >/dev/null )
|
|
224
|
+
check "LOW token in a NON-allowlisted file → BLOCK" "$R" block \
|
|
225
|
+
"refs/heads/f $(cd "$R" && git rev-parse HEAD) refs/heads/f $B"
|
|
226
|
+
rm -rf "$R"
|
|
227
|
+
|
|
228
|
+
# ── Pair 10: stacked-branch ADVISORY. Unlike every pair above, the assertion is on the WARNING,
|
|
229
|
+
# not on the verdict — this leg exists precisely because the advisory must never change the exit
|
|
230
|
+
# code. Origin (2026-07-27, field harness PRs #38/#39): a branch cut off a feature branch produced a
|
|
231
|
+
# child PR carrying the parent's three commits. Both ways out bill at parent-merge time — keeping the
|
|
232
|
+
# integration base leaves the child CONFLICTING after the parent is squashed, and retargeting onto the
|
|
233
|
+
# parent branch makes --delete-branch CLOSE the child. The observed run hit both (state=CLOSED,
|
|
234
|
+
# mergeable=CONFLICTING), and the tempting recovery is a force-push — the irreversible surface this
|
|
235
|
+
# hook guards.
|
|
236
|
+
#
|
|
237
|
+
# Assertions key on the STABLE MARKER `[fh-advisory:stacked-branch]`, not on the human prose around
|
|
238
|
+
# it (Wave-1 B): a prose-coupled assertion silently desyncs the moment the message is reworded. ──
|
|
239
|
+
SB_MARK='[fh-advisory:stacked-branch]'
|
|
240
|
+
sb_check() { # sb_check <name> <repo> <expect: warn|skip|none> <refline> [must-name]
|
|
241
|
+
# [must-name]: a branch the warning MUST name. "a warning appeared" is too weak an assertion —
|
|
242
|
+
# under a mutation that broke the self-exclusion, the advisory still warned, but about the
|
|
243
|
+
# pushed branch ITSELF. The leg went green while the defect was live (measured 2026-07-27).
|
|
244
|
+
local name="$1" repo="$2" expect="$3" refline="$4" mustname="${5:-}" out rc got
|
|
245
|
+
out=$(printf '%s\n' "$refline" | ( cd "$repo" && bash templates/.git-hooks/pre-push origin git@example:x/y.git 2>&1 )); rc=$?
|
|
246
|
+
if printf '%s' "$out" | grep -qiE 'bad substitution|unbound variable|syntax error|command not found'; then
|
|
247
|
+
echo " ❌ $name — RUNTIME FAULT in the hook"; FAILED=1; return
|
|
248
|
+
fi
|
|
249
|
+
if printf '%s' "$out" | grep -qF "$SB_MARK"; then
|
|
250
|
+
# Key on a machine token, not prose. Measured 2026-07-27: this classifier first grepped the
|
|
251
|
+
# phrase "skipped —", and a one-word rewording of the hook's message silently reclassified a
|
|
252
|
+
# correct SKIP as a WARN — the same prose-coupling defect Wave-1 flagged in the leg assertions.
|
|
253
|
+
if printf '%s' "$out" | grep -qF "$SB_MARK SKIPPED"; then got=skip; else got=warn; fi
|
|
254
|
+
else
|
|
255
|
+
got=none
|
|
256
|
+
fi
|
|
257
|
+
if [ "$got" != "$expect" ]; then
|
|
258
|
+
echo " ❌ $name — expected $expect, got $got"
|
|
259
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -8
|
|
260
|
+
FAILED=1; return
|
|
261
|
+
fi
|
|
262
|
+
# The advisory must not move the verdict. These legs are otherwise-clean pushes, so a non-zero
|
|
263
|
+
# exit means the advisory (or something it perturbed) started blocking.
|
|
264
|
+
if [ "$rc" -ne 0 ]; then
|
|
265
|
+
echo " ❌ $name — advisory changed the verdict (exit $rc); it must be advisory only"
|
|
266
|
+
printf '%s\n' "$out" | sed 's/^/ | /' | head -6
|
|
267
|
+
FAILED=1; return
|
|
268
|
+
fi
|
|
269
|
+
if [ -n "$mustname" ] && ! printf '%s' "$out" | grep -qF "$mustname"; then
|
|
270
|
+
echo " ❌ $name — warned, but never named '$mustname' (warning for the wrong reason)"
|
|
271
|
+
printf '%s\n' "$out" | grep -F "$SB_MARK" -A5 | sed 's/^/ | /' | head -8
|
|
272
|
+
FAILED=1; return
|
|
273
|
+
fi
|
|
274
|
+
echo " ✅ $name ($expect, verdict unchanged${mustname:+, named $mustname})"
|
|
275
|
+
}
|
|
276
|
+
# ⚠️ 계기 주의 (실측): 처음엔 remote_sha 를 all-zero(=신규 브랜치)로 줬는데, 그러면 push 범위에
|
|
277
|
+
# 샌드박스의 base 커밋(= templates/.git-hooks/pre-push 사본을 담고 있다)이 들어가 이 훅의 **기존**
|
|
278
|
+
# load-bearing cross-family 가드가 발화해 차단됐다. sb_check 은 그 rc!=0 을 "advisory 가 verdict 를
|
|
279
|
+
# 바꿨다"로 오귀속했다 — 다른 가드의 차단을 이 기능 탓으로 읽는 계기 결함이다.
|
|
280
|
+
# base 커밋을 remote_sha 로 주면 범위가 픽스처 커밋만으로 좁혀져 그 혼선이 사라진다.
|
|
281
|
+
|
|
282
|
+
# 10-a known-positive: cut off a REMOTE feature branch.
|
|
283
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
284
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
285
|
+
&& git switch -q -c feat/parent && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
286
|
+
&& git update-ref refs/remotes/origin/feat/parent "$(git rev-parse HEAD)" \
|
|
287
|
+
&& git switch -q -c feat/child && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
288
|
+
sb_check "cut off a REMOTE feature branch → warn" "$R" warn \
|
|
289
|
+
"refs/heads/feat/child $(cd "$R" && git rev-parse feat/child) refs/heads/feat/child $B"
|
|
290
|
+
rm -rf "$R"
|
|
291
|
+
|
|
292
|
+
# 10-b known-positive (Wave-1 A): the parent was never pushed. A remote-only check misses exactly
|
|
293
|
+
# the most likely moment for this mistake — before the parent's first push.
|
|
294
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
295
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
296
|
+
&& git switch -q -c feat/localparent && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
297
|
+
&& git switch -q -c feat/child2 && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
298
|
+
sb_check "cut off a LOCAL-only feature branch → warn" "$R" warn \
|
|
299
|
+
"refs/heads/feat/child2 $(cd "$R" && git rev-parse feat/child2) refs/heads/feat/child2 $B"
|
|
300
|
+
rm -rf "$R"
|
|
301
|
+
|
|
302
|
+
# 10-c known-positive (Wave-1 S, live-reproduced): a branch name carrying a regex metacharacter.
|
|
303
|
+
# The first draft interpolated the name into `grep -vE ".../${self}$"`, so `feat/a.b`'s `.` also
|
|
304
|
+
# matched a genuinely different branch `feat/aXb` — grep -v dropped the REAL hit and the advisory
|
|
305
|
+
# silently no-opped on a true positive. Exclusion is exact-name now; this leg pins that.
|
|
306
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
307
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
308
|
+
&& git switch -q -c feat/aXb && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
309
|
+
&& git update-ref refs/remotes/origin/feat/aXb "$(git rev-parse HEAD)" \
|
|
310
|
+
&& git switch -q -c 'feat/a.b' \
|
|
311
|
+
&& git branch -q -D feat/aXb )
|
|
312
|
+
# ⚠️ the local `feat/aXb` is DELETED on purpose. Left in place, the local listing line
|
|
313
|
+
# (`local feat/aXb`, no `/` before `feat`) dodges the vulnerable pattern `/feat/a.b$` and the leg
|
|
314
|
+
# goes green even with the bug restored — i.e. it would pass for the wrong reason. Measured: the
|
|
315
|
+
# first version of this fixture did exactly that under a mutation test.
|
|
316
|
+
sb_check "regex-metachar branch name → warn" "$R" warn \
|
|
317
|
+
"refs/heads/feat/a.b $(cd "$R" && git rev-parse 'feat/a.b') refs/heads/feat/a.b $B" \
|
|
318
|
+
"origin/feat/aXb"
|
|
319
|
+
rm -rf "$R"
|
|
320
|
+
|
|
321
|
+
# 10-d (Wave-1 A): base unresolvable → must SAY it did not scan. A silent return is
|
|
322
|
+
# indistinguishable from "scanned, nothing found" — 부재는 통과가 아니다.
|
|
323
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
324
|
+
( cd "$R" && git switch -q -c feat/nobase && printf 'n\n' > n.md && git add n.md && git commit -qm x >/dev/null )
|
|
325
|
+
sb_check "base unresolvable → skip announced" "$R" skip \
|
|
326
|
+
"refs/heads/feat/nobase $(cd "$R" && git rev-parse feat/nobase) refs/heads/feat/nobase $B"
|
|
327
|
+
rm -rf "$R"
|
|
328
|
+
|
|
329
|
+
# 10-e (cross-family LOW-7): parser edge cases. The first draft parsed human `git branch -r`
|
|
330
|
+
# output, where `origin/HEAD -> origin/main` parses as ref="HEAD" and a branch name with a space is
|
|
331
|
+
# truncated. for-each-ref emits refs, not a listing — this leg pins that the symbolic HEAD ref does
|
|
332
|
+
# not manufacture a warning and does not mask a real one.
|
|
333
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
334
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
335
|
+
&& git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main \
|
|
336
|
+
&& git switch -q -c feat/parent2 && printf 'p\n' > p.md && git add p.md && git commit -qm parent >/dev/null \
|
|
337
|
+
&& git update-ref refs/remotes/origin/feat/parent2 "$(git rev-parse HEAD)" \
|
|
338
|
+
&& git switch -q -c feat/child3 && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
339
|
+
sb_check "origin/HEAD symref present → warn" "$R" warn \
|
|
340
|
+
"refs/heads/feat/child3 $(cd "$R" && git rev-parse feat/child3) refs/heads/feat/child3 $B" \
|
|
341
|
+
"origin/feat/parent2"
|
|
342
|
+
rm -rf "$R"
|
|
343
|
+
|
|
344
|
+
# 10-f (cross-family MED-2): the integration branch is excluded by its RESOLVED name, not by a
|
|
345
|
+
# hard-coded main/master. A stack built on a LOCAL branch merely named `master` in a repo whose base
|
|
346
|
+
# is origin/main must still warn — hard-coding hid exactly that case.
|
|
347
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
348
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
349
|
+
&& git switch -q -c master && printf 'p\n' > p.md && git add p.md && git commit -qm onmaster >/dev/null \
|
|
350
|
+
&& git switch -q -c feat/child4 && printf 'c\n' > c.md && git add c.md && git commit -qm child >/dev/null )
|
|
351
|
+
sb_check "stack on a local 'master' branch → warn" "$R" warn \
|
|
352
|
+
"refs/heads/feat/child4 $(cd "$R" && git rev-parse feat/child4) refs/heads/feat/child4 $B" \
|
|
353
|
+
"master"
|
|
354
|
+
rm -rf "$R"
|
|
355
|
+
|
|
356
|
+
# 10-g CONTROL: cut off the integration branch. Without this leg an advisory that fired
|
|
357
|
+
# unconditionally would score green on every positive leg above while being useless.
|
|
358
|
+
R=$(newrepo); B=$(cd "$R" && git rev-parse HEAD)
|
|
359
|
+
( cd "$R" && git update-ref refs/remotes/origin/main "$(git rev-parse HEAD)" \
|
|
360
|
+
&& git switch -q -c feat/clean && printf 'w\n' > w.md && git add w.md && git commit -qm clean >/dev/null )
|
|
361
|
+
sb_check "control: cut off main → no warn" "$R" none \
|
|
362
|
+
"refs/heads/feat/clean $(cd "$R" && git rev-parse feat/clean) refs/heads/feat/clean $B"
|
|
363
|
+
rm -rf "$R"
|
|
364
|
+
|
|
365
|
+
echo
|
|
366
|
+
if [ "$FAILED" -eq 0 ]; then
|
|
367
|
+
echo "[prepush-guard] ✅ all known pairs hold"
|
|
368
|
+
exit 0
|
|
369
|
+
fi
|
|
370
|
+
echo "[prepush-guard] ❌ BLOCKED — a known pair broke."
|
|
371
|
+
echo " BLOCK→PASS = the publish boundary stopped covering something it claims to cover."
|
|
372
|
+
echo " PASS→BLOCK = over-blocking, which trains PUBLIC_SURFACE_OK into reflex and disarms the gate."
|
|
373
|
+
echo " RUNTIME FAULT = the hook aborted; that is never a pass, however it exited."
|
|
374
|
+
exit 1
|