@chrono-meta/fh-gate 3.1.4 → 3.2.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.
Files changed (41) hide show
  1. package/.claude/rules/fh_4axis_gate.md +1 -1
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/AGENTS.md +6 -0
  4. package/CLAUDE.md +50 -6
  5. package/README.ja.md +3 -3
  6. package/README.ko.md +3 -3
  7. package/README.md +3 -3
  8. package/README.zh.md +3 -3
  9. package/docs/OUTPUT_EVIDENCE.md +1 -1
  10. package/knowledge/shared/harness-core/claude_md_gate_details.md +26 -0
  11. package/knowledge/shared/harness-core/fh_three_layer_canon.md +1 -1
  12. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +46 -0
  13. package/knowledge/shared/harness-core/governance_engineering_definition.md +89 -0
  14. package/knowledge/shared/learnings/subagent_invocations_log.yaml +68 -0
  15. package/knowledge/shared/rules/auto_project_mapping.md +1 -1
  16. package/package.json +11 -1
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/preprep/SKILL.md +23 -0
  19. package/plugins/fh-commons/skills/preprep/fixtures/font_revert_probe.py +92 -0
  20. package/plugins/fh-commons/skills/preprep/lane_font.py +462 -0
  21. package/plugins/fh-commons/skills/preprep/preprep.py +16 -1
  22. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +17 -0
  23. package/plugins/fh-commons/skills/preprep/test_lane_font.py +452 -0
  24. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  25. package/plugins/fh-meta/CHANGELOG.md +69 -0
  26. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  27. package/scripts/doc_claim_triad_scan.py +303 -0
  28. package/scripts/finding_fleet.sh +180 -0
  29. package/scripts/finding_pipeline.sh +213 -0
  30. package/scripts/finding_verifier.sh +144 -0
  31. package/scripts/finding_verify.py +283 -0
  32. package/scripts/gate_pathspec_check.sh +1 -0
  33. package/scripts/gate_shape_scan.sh +106 -0
  34. package/scripts/selfcheck.sh +50 -0
  35. package/scripts/test_doc_claim_triad_lanes.sh +124 -0
  36. package/scripts/test_finding_pipeline_lanes.sh +459 -0
  37. package/scripts/test_gate_shape_scan_lanes.sh +36 -0
  38. package/scripts/test_heavy_classifier_lanes.sh +13 -3
  39. package/scripts/test_preprep_font_lanes.sh +87 -0
  40. package/templates/.git-hooks/pre-commit +6 -5
  41. package/templates/PRE-PUBLISH-CHECKLIST.md +29 -0
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env bash
2
+ # test_finding_pipeline_lanes.sh — regression anchor for the typed-finding pipeline
3
+ # (scripts/finding_fleet.sh → scripts/finding_verify.py).
4
+ #
5
+ # 🟥 What these lanes protect is the DIRECTION OF FAILURE, not a count. Every defect this pipeline can
6
+ # have makes an unreviewed or unverified run look like a clean one: a member that returns prose, a
7
+ # verifier that cannot be reached, a family grading its own findings. Each lane below pins one of
8
+ # those, and the mutant lane checks that removing the guard actually turns a lane red.
9
+ set -uo pipefail
10
+ HERE="$(cd "$(dirname "$0")" && pwd)"
11
+ FLEET="$HERE/finding_fleet.sh"; VERIFY="$HERE/finding_verify.py"
12
+ PASS=0; FAIL=0
13
+ ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
14
+ no(){ FAIL=$((FAIL+1)); printf ' ❌ %s — %s\n' "$1" "${2:-}"; }
15
+ echo "── test_finding_pipeline_lanes ──"
16
+
17
+ [ -f "$FLEET" ] && [ -f "$VERIFY" ] || { no "L0 파일 실재" "fleet/verify 없음"; echo "FAILED=1"; exit 1; }
18
+ bash -n "$FLEET" && ok "L1a fleet 구문" || no "L1a fleet 구문"
19
+ python3 -c "import ast,sys;ast.parse(open(sys.argv[1],encoding='utf-8').read())" "$VERIFY" \
20
+ && ok "L1b verify 구문" || no "L1b verify 구문"
21
+
22
+ OUT=$(bash "$HERE/finding_fleet.sh" --selftest 2>&1); RC=$?
23
+ [ "$RC" -eq 0 ] && ok "L2 fleet selftest (known-pair 4)" || no "L2 fleet selftest" "rc=$RC · $OUT"
24
+
25
+ D="$(mktemp -d 2>/dev/null)" || D=""
26
+ [ -n "$D" ] && [ -w "$D" ] || { no "L3 환경" "mktemp -d 불가 — 레인 미측정(통과 아님)"; echo "PASS=$PASS FAIL=$FAIL"; echo "FAILED=1"; exit 1; }
27
+
28
+ cat > "$D/f.jsonl" <<'EOS'
29
+ {"id":"a1","title":"real defect","file":"x.py","line":3,"severity":"A","producer_family":"alpha"}
30
+ {"id":"a2","title":"bogus claim","file":"x.py","line":9,"severity":"S","producer_family":"alpha"}
31
+ EOS
32
+
33
+ # known-pair for the reject stage: a verifier that confirms a1 and rejects a2 must drop exactly a2
34
+ cat > "$D/v_ok.sh" <<'EOS'
35
+ #!/bin/sh
36
+ cat >/dev/null
37
+ echo '{"id":"a1","verdict":"confirmed","why":"holds against source"}'
38
+ echo '{"id":"a2","verdict":"false-positive","why":"code does not do this"}'
39
+ EOS
40
+ chmod +x "$D/v_ok.sh"
41
+ python3 "$HERE/finding_verify.py" "$D/f.jsonl" --out "$D/o1" --verifier "sh $D/v_ok.sh" --family beta >"$D/s1" 2>&1; RC=$? # 리터럴 경로 — new-code-anchor 가 «실행» 으로 센다
42
+ C=$(wc -l < "$D/o1/confirmed.jsonl" | tr -d ' '); DR=$(wc -l < "$D/o1/dropped.jsonl" | tr -d ' ')
43
+ { [ "$RC" -eq 4 ] && [ "$C" = 1 ] && [ "$DR" = 1 ] && grep -q '"id": "a2"' "$D/o1/dropped.jsonl"; } \
44
+ && ok "L4 거부 단계: false-positive 를 «코드가» 지운다 (confirmed 1 / dropped 1 / rc=4 = 미감사)" \
45
+ || no "L4 거부 단계" "rc=$RC confirmed=$C dropped=$DR"
46
+
47
+ # 🟥 degrade: no verifier must NOT drop anything, must mark UNVERIFIED, must not exit 0
48
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/o2" --verifier "" --family none >"$D/s2" 2>&1; RC=$?
49
+ C=$(wc -l < "$D/o2/confirmed.jsonl" | tr -d ' '); DR=$(wc -l < "$D/o2/dropped.jsonl" | tr -d ' ')
50
+ { [ "$RC" -eq 3 ] && [ "$C" = 2 ] && [ "$DR" = 0 ] && grep -q 'status=UNVERIFIED' "$D/s2"; } \
51
+ && ok "L5 degrade: 검증기 없음 → 아무것도 안 지우고 UNVERIFIED · rc=3 (빈 dropped 가 «깨끗함»으로 안 읽힌다)" \
52
+ || no "L5 degrade" "rc=$RC confirmed=$C dropped=$DR"
53
+
54
+ # a verifier that fails must degrade the same way, never drop
55
+ cat > "$D/v_fail.sh" <<'EOS'
56
+ #!/bin/sh
57
+ cat >/dev/null
58
+ exit 7
59
+ EOS
60
+ chmod +x "$D/v_fail.sh"
61
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/o3" --verifier "sh $D/v_fail.sh" --family beta >"$D/s3" 2>&1; RC=$?
62
+ { [ "$RC" -eq 3 ] && [ "$(wc -l < "$D/o3/dropped.jsonl" | tr -d ' ')" = 0 ] && grep -q 'verifier exit 7' "$D/s3"; } \
63
+ && ok "L6 검증기 실패 → degrade + 사유가 요약줄에 남는다" || no "L6 검증기 실패" "rc=$RC · $(cat "$D/s3")"
64
+
65
+ # 🟥 the channel rule: a family never verifies its own findings, even when it answers
66
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/o4" --verifier "sh $D/v_ok.sh" --family alpha >"$D/s4" 2>&1; RC=$?
67
+ { [ "$(wc -l < "$D/o4/dropped.jsonl" | tr -d ' ')" = 0 ] && grep -q 'unverified=2' "$D/s4"; } \
68
+ && ok "L7 자기 계열은 자기 산출을 검증 못 한다 (verdict 있어도 드롭 0 · unverified 2)" \
69
+ || no "L7 자기검증 차단" "rc=$RC · $(cat "$D/s4")"
70
+
71
+ # schema is enforced: a finding without a title is a usage error, not a silently skipped row
72
+ printf '{"id":"x1","file":"x.py"}\n' > "$D/bad.jsonl"
73
+ python3 "$VERIFY" "$D/bad.jsonl" --out "$D/o5" --verifier "sh $D/v_ok.sh" --family beta >"$D/s5" 2>&1; RC=$?
74
+ { [ "$RC" -ne 0 ] && grep -q "missing required field" "$D/s5"; } \
75
+ && ok "L8 스키마 위반은 «조용히 건너뛰기» 가 아니라 오류" || no "L8 스키마" "rc=$RC · $(cat "$D/s5")"
76
+
77
+ # ── drop-side lanes: a deletion stage that is never checked can improve precision by deleting ──────
78
+ cat > "$D/a_wrong.sh" <<'EOS'
79
+ #!/bin/sh
80
+ cat >/dev/null
81
+ echo '{"id":"a2","verdict":"wrong-drop","why":"the code does do this at line 9"}'
82
+ EOS
83
+ cat > "$D/a_right.sh" <<'EOS'
84
+ #!/bin/sh
85
+ cat >/dev/null
86
+ echo '{"id":"a2","verdict":"correct-drop","why":"confirmed the code does not do this"}'
87
+ EOS
88
+ chmod +x "$D/a_wrong.sh" "$D/a_right.sh"
89
+
90
+ # L10 — drops with no auditor must not read as a completed run
91
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/oA" --verifier "sh $D/v_ok.sh" --family beta >"$D/sA" 2>&1; RC=$?
92
+ { [ "$RC" -eq 4 ] && grep -q 'drop_audit=UNAUDITED' "$D/sA" && grep -q '^DROPS ' "$D/sA"; } && ok "L10 드롭이 있는데 감사가 없으면 rc=4 · DROPS 줄이 무조건 찍힌다 (지워서 정밀해지는 것을 못 숨긴다)" || no "L10 미감사" "rc=$RC · $(cat "$D/sA")"
93
+
94
+ # L11 — an auditor that calls the drop wrong REINSTATES the finding; it is not advisory
95
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/oB" --verifier "sh $D/v_ok.sh" --family beta --audit-verifier "sh $D/a_wrong.sh" --audit-family gamma >"$D/sB" 2>&1; RC=$?
96
+ C=$(wc -l < "$D/oB/confirmed.jsonl" | tr -d ' '); DR=$(wc -l < "$D/oB/dropped.jsonl" | tr -d ' ')
97
+ { [ "$C" = 2 ] && [ "$DR" = 0 ] && grep -q 'wrong_drops=1' "$D/sB" && grep -q '"reinstated": true' "$D/oB/confirmed.jsonl"; } && ok "L11 «잘못 지웠다» 판정은 되돌린다 (confirmed 2 · dropped 0 · wrong_drops=1)" || no "L11 복권" "rc=$RC confirmed=$C dropped=$DR · $(cat "$D/sB")"
98
+
99
+ # L12 — a correct drop stays dropped, and the run is AUDITED
100
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/oC" --verifier "sh $D/v_ok.sh" --family beta --audit-verifier "sh $D/a_right.sh" --audit-family gamma >"$D/sC" 2>&1; RC=$?
101
+ { [ "$RC" -eq 0 ] && grep -q 'drop_audit=AUDITED' "$D/sC" && grep -q 'wrong_drops=0' "$D/sC" && [ "$(wc -l < "$D/oC/dropped.jsonl" | tr -d ' ')" = 1 ]; } && ok "L12 옳은 드롭은 남고 run 은 AUDITED·rc=0" || no "L12 정상 감사" "rc=$RC · $(cat "$D/sC")"
102
+
103
+ # L13 — the family that made the drop may not audit it
104
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/oD" --verifier "sh $D/v_ok.sh" --family beta --audit-verifier "sh $D/a_wrong.sh" --audit-family beta >"$D/sD" 2>&1; RC=$?
105
+ { [ "$RC" -eq 4 ] && grep -q 'drop_audit=UNAUDITED' "$D/sD" && grep -q 'refused' "$D/sD"; } && ok "L13 드롭을 한 계열은 그 드롭을 감사 못 한다 (거부 + UNAUDITED)" || no "L13 자기감사 차단" "rc=$RC · $(cat "$D/sD")"
106
+
107
+ # L14 — producer auditing its own reinstated claim is allowed but recorded as an appeal
108
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/oE" --verifier "sh $D/v_ok.sh" --family beta --audit-verifier "sh $D/a_wrong.sh" --audit-family alpha >"$D/sE" 2>&1
109
+ grep -q '"audit_role": "appeal"' "$D/oE/confirmed.jsonl" && ok "L14 생산자가 자기 주장을 복권시키면 «appeal» 로 기록된다 (숨기지 않는다)" || no "L14 appeal 기록" "$(cat "$D/sE")"
110
+
111
+ # L9 revert probe — remove the never-self-verify guard and L7 must go red
112
+ MUT="$D/verify_mut.py"
113
+ # 🟥 «앵커가 움직였다» 와 «앵커가 장식이다» 는 다른 사건인데 초판은 둘을 같은 메시지로 냈다.
114
+ # 2026-09-09 에 실제로 첫째가 났고(가드가 두 갈래로 갈리며 줄이 바뀜) 출력은 둘째라고 말했다.
115
+ MUTERR=$(python3 - "$VERIFY" "$MUT" 2>&1 <<'PY'
116
+ import sys
117
+ src, dst = sys.argv[1], sys.argv[2]
118
+ s = open(src, encoding="utf-8").read()
119
+ key = ' if prod == a.family:'
120
+ assert key in s, "ANCHOR-MOVED: the same-family guard this probe pins to is not in the file"
121
+ s = s.replace(key, ' if False:')
122
+ open(dst, "w", encoding="utf-8").write(s)
123
+ PY
124
+ ); MUTRC=$?
125
+ if [ "$MUTRC" -ne 0 ] || [ ! -s "$MUT" ]; then
126
+ no "L9 되돌림 (뮤턴트 생성 실패)" "$(printf '%s' "$MUTERR" | tail -1) — 앵커가 «움직인» 것이지 «장식» 이 아니다"
127
+ else
128
+ python3 "$MUT" "$D/f.jsonl" --out "$D/o6" --verifier "sh $D/v_ok.sh" --family alpha >"$D/s6" 2>&1
129
+ { [ "$(wc -l < "$D/o6/dropped.jsonl" | tr -d ' ')" = 1 ]; } \
130
+ && ok "L9 되돌림: 가드를 지우면 자기 계열이 자기 것을 지운다 (레인이 실물을 잰다)" \
131
+ || no "L9 되돌림" "가드를 지워도 드롭 0 — L7 은 장식이다"
132
+ fi
133
+
134
+ # ══ 접착 코드 레인 (2026-09-09) — finding_verifier.sh (G1) · finding_pipeline.sh (G2) · defeater (G6) ══
135
+ # 🟥 이 레인들이 지키는 것도 «방향»이다. 래퍼가 답을 못 받았는데 0 을 돌려주면 verify 는 그것을
136
+ # «판정 없음 → unverified» 가 아니라 «빈 판정»으로 삼키고, 파이프라인은 초록으로 끝난다.
137
+ VERIFIER="$HERE/finding_verifier.sh"; PIPE="$HERE/finding_pipeline.sh"
138
+ if [ ! -f "$VERIFIER" ] || [ ! -f "$PIPE" ]; then
139
+ no "L15 접착 코드 실재" "finding_verifier.sh / finding_pipeline.sh 부재 — skipped, NOT passed"
140
+ else
141
+ bash -n "$VERIFIER" && ok "L15a verifier 구문" || no "L15a verifier 구문"
142
+ bash -n "$PIPE" && ok "L15b pipeline 구문" || no "L15b pipeline 구문"
143
+
144
+ printf 'x = 1\n' > "$D/tgt.py"
145
+
146
+ # 가짜 CLI — 배너/펜스로 감싸고, enum 밖 verdict 를 하나 섞는다
147
+ cat > "$D/fake_ok.sh" <<'EOS'
148
+ #!/bin/sh
149
+ cat >/dev/null
150
+ echo "== banner the model likes to print =="
151
+ echo '```json'
152
+ echo '{"id":"a1","verdict":"confirmed","why":"line 3 does this"}'
153
+ echo '{"id":"a2","verdict":"probably-fine","why":"out of enum"}'
154
+ echo '{"id":"a2","verdict":"false-positive","why":"line 9 says otherwise"}'
155
+ echo '```'
156
+ EOS
157
+ chmod +x "$D/fake_ok.sh"
158
+ cat > "$D/fake_mute.sh" <<'EOS'
159
+ #!/bin/sh
160
+ cat >/dev/null
161
+ echo "I looked at the file but will not answer in JSON."
162
+ EOS
163
+ chmod +x "$D/fake_mute.sh"
164
+
165
+ # L16 — 배너/펜스를 견디고, enum 밖 verdict 는 «강제 변환» 이 아니라 «드롭» 이다
166
+ V16=$(FH_CODEX_BIN="$D/fake_ok.sh" bash "$VERIFIER" --family codex --target "$D/tgt.py" < "$D/f.jsonl" 2>/dev/null); RC=$?
167
+ N16=$(printf '%s\n' "$V16" | grep -c '^{')
168
+ { [ "$RC" -eq 0 ] && [ "$N16" = 2 ] && printf '%s' "$V16" | grep -q '"verdict": "false-positive"' \
169
+ && ! printf '%s' "$V16" | grep -q 'probably-fine'; } \
170
+ && ok "L16 래퍼: 배너/펜스 견딤 · enum 밖 verdict 드롭 (2건만 통과)" \
171
+ || no "L16 래퍼 파싱" "rc=$RC n=$N16 · $V16"
172
+
173
+ # L17 — 빈 입력은 «물어본 게 없다» 이므로 rc=0 · 출력 0 (CLI 를 부르지도 않는다)
174
+ V17=$(FH_CODEX_BIN="$D/fake_ok.sh" bash "$VERIFIER" --family codex --target "$D/tgt.py" < /dev/null 2>/dev/null); RC=$?
175
+ { [ "$RC" -eq 0 ] && [ -z "$V17" ]; } && ok "L17 빈 입력 → rc=0 · 출력 0" || no "L17 빈 입력" "rc=$RC out=$V17"
176
+
177
+ # L18 🟥 degrade 방향의 핵심 — 답을 «못 받은» 것은 rc=3 이지 rc=0 이 아니다
178
+ V18=$(FH_CODEX_BIN="$D/fake_mute.sh" bash "$VERIFIER" --family codex --target "$D/tgt.py" < "$D/f.jsonl" 2>/dev/null); RC=$?
179
+ { [ "$RC" -eq 3 ] && [ -z "$V18" ]; } \
180
+ && ok "L18 파싱 가능한 판정 0 → rc=3 (침묵을 «통과» 로 접지 않는다)" || no "L18 무응답 degrade" "rc=$RC out=$V18"
181
+
182
+ # L19 — 사용법 가드: --target 없으면 rc=2, 모르는 계열이면 rc=2
183
+ bash "$VERIFIER" --family codex < /dev/null >/dev/null 2>&1; [ $? -eq 2 ] \
184
+ && ok "L19a --target 부재 → rc=2" || no "L19a --target 가드"
185
+ echo '{"id":"a1","title":"t"}' | bash "$VERIFIER" --family nosuch --target "$D/tgt.py" >/dev/null 2>&1; [ $? -eq 2 ] \
186
+ && ok "L19b 모르는 계열 → rc=2" || no "L19b 계열 가드"
187
+
188
+ # ── L20~L22 드라이버 ────────────────────────────────────────────────────────────────────────────
189
+ # 두 계열이 각각 한 건씩 내는 가짜 fleet. 그래야 «두 갈래 분할» 이 실제로 갈린다.
190
+ cat > "$D/m_codex.sh" <<'EOS'
191
+ #!/bin/sh
192
+ cat >/dev/null
193
+ echo '{"title":"codex claim","file":"tgt.py","line":1,"severity":"A","defeater":"line 1 would differ"}'
194
+ EOS
195
+ cat > "$D/m_gem.sh" <<'EOS'
196
+ #!/bin/sh
197
+ cat >/dev/null
198
+ echo '{"title":"gemini claim","file":"tgt.py","line":1,"severity":"S","defeater":"no such call site"}'
199
+ EOS
200
+ chmod +x "$D/m_codex.sh" "$D/m_gem.sh"
201
+ printf 'codex|logic|%s\ngemini|security|%s\n' "sh $D/m_codex.sh" "sh $D/m_gem.sh" > "$D/fleet.tbl"
202
+
203
+ # 두 계열 모두 «상대편 것은 confirmed» 라고 답하는 가짜 검증기
204
+ # 🟥 이 가짜 CLI 는 stdin «과» 인자를 «둘 다» 읽어야 한다 — finding_verifier.sh 의 codex 갈래는
205
+ # 프롬프트를 stdin 으로, gemini 갈래는 `-p` 인자로 준다. stdin 만 읽는 픽스처를 쓰면 gemini
206
+ # 갈래가 «답을 못 받았다»(rc=3)로 떨어지고, 그건 코드 결함처럼 보이지만 죽은 컨트롤이다.
207
+ # (실측 2026-09-09: 이 레인의 첫 판이 정확히 그렇게 L20 을 거짓 적색으로 만들었다.)
208
+ cat > "$D/fake_conf.sh" <<'EOS'
209
+ #!/bin/sh
210
+ IN=$(cat)
211
+ [ -n "$IN" ] || IN="$*"
212
+ printf '%s\n' "$IN" | tr ',' '\n' | sed -n 's/.*"id": *"\([^"]*\)".*/{"id":"\1","verdict":"confirmed","why":"checked"}/p'
213
+ EOS
214
+ chmod +x "$D/fake_conf.sh"
215
+
216
+ P20=$(FH_CODEX_BIN="$D/fake_conf.sh" FH_AGY_BIN="$D/fake_conf.sh" \
217
+ bash "$PIPE" "$D/tgt.py" --out "$D/pipe20" --fleet "$D/fleet.tbl" 2>"$D/pipe20.err"); RC=$?
218
+ SUM=$(printf '%s\n' "$P20" | grep '^PIPELINE ')
219
+ { [ "$RC" -eq 0 ] && printf '%s' "$SUM" | grep -q 'confirmed=2' && printf '%s' "$SUM" | grep -q 'unverified=0'; } \
220
+ && ok "L20 드라이버: 두 계열 분할로 «양쪽 다» 판정된다 (unverified=0)" \
221
+ || no "L20 두 갈래 분할" "rc=$RC · $SUM · $(tail -3 "$D/pipe20.err")"
222
+
223
+ # L21 🟥 한 계열만 있으면 그 findings 는 교차검증이 구조적으로 불가 — 초록이 아니라 rc=3
224
+ printf 'codex|logic|%s\n' "sh $D/m_codex.sh" > "$D/fleet1.tbl"
225
+ FH_CODEX_BIN="$D/fake_conf.sh" bash "$PIPE" "$D/tgt.py" --out "$D/pipe21" --fleet "$D/fleet1.tbl" \
226
+ >"$D/p21" 2>"$D/p21.err"; RC=$?
227
+ { [ "$RC" -eq 3 ] && grep -q 'cannot be cross-verified' "$D/p21.err"; } \
228
+ && ok "L21 단일 계열 fleet → rc=3 (같은 계열 자기검증으로 «통과» 시키지 않는다)" \
229
+ || no "L21 단일 계열 degrade" "rc=$RC · $(cat "$D/p21.err")"
230
+
231
+ # L22 — fleet 이 아무것도 못 내면 UNREVIEWED(rc=3) 이지 clean 이 아니다
232
+ cat > "$D/m_none.sh" <<'EOS'
233
+ #!/bin/sh
234
+ cat >/dev/null
235
+ EOS
236
+ chmod +x "$D/m_none.sh"
237
+ printf 'codex|logic|%s\ngemini|security|%s\n' "sh $D/m_none.sh" "sh $D/m_none.sh" > "$D/fleet0.tbl"
238
+ bash "$PIPE" "$D/tgt.py" --out "$D/pipe22" --fleet "$D/fleet0.tbl" >"$D/p22" 2>&1; RC=$?
239
+ { [ "$RC" -eq 3 ] && grep -q 'UNREVIEWED' "$D/p22"; } \
240
+ && ok "L22 findings 0 → UNREVIEWED rc=3 (빈 목록은 clean 이 아니다)" || no "L22 UNREVIEWED" "rc=$RC · $(cat "$D/p22")"
241
+
242
+ # L23 되돌림 프로브 — pick_verifier 가 «생산자와 다른 계열» 을 고르는 줄을 죽이면 L20 이 빨개져야 한다
243
+ # 🟥 뮤턴트는 «의존 파일 옆에» 두어야 한다. finding_pipeline.sh 는 자기 위치에서 fleet/verify/
244
+ # verifier 를 찾으므로, 뮤턴트만 임시 디렉터리에 두면 프로브는 «가드가 죽었나» 가 아니라
245
+ # «파일이 없나» 를 잰다 — 빨간색이 나오지만 이유가 다르다(실측 2026-09-09, 이 레인의 첫 판).
246
+ MUTD="$D/mut"; mkdir -p "$MUTD"
247
+ cp "$HERE/finding_fleet.sh" "$HERE/finding_verify.py" "$HERE/finding_verifier.sh" "$MUTD/"
248
+ MUTP="$MUTD/finding_pipeline.sh"
249
+ /usr/bin/python3 - "$PIPE" "$MUTP" <<'PY'
250
+ import sys
251
+ src, dst = sys.argv[1], sys.argv[2]
252
+ s = open(src, encoding="utf-8").read()
253
+ key = ' while IFS= read -r f; do [ -n "$f" ] && [ "$f" != "$p" ] && supported "$f" && { echo "$f"; return; }; done < "$OUT/families.txt"'
254
+ assert key in s, "pick_verifier body moved — the revert probe is pinned to a line that no longer exists"
255
+ s = s.replace(key, ' echo "$p"') # 자기 계열을 검증기로 고른다
256
+ open(dst, "w", encoding="utf-8").write(s)
257
+ PY
258
+ FH_CODEX_BIN="$D/fake_conf.sh" FH_AGY_BIN="$D/fake_conf.sh" \
259
+ bash "$MUTP" "$D/tgt.py" --out "$D/pipe23" --fleet "$D/fleet.tbl" >"$D/p23" 2>&1
260
+ M23=$(grep '^PIPELINE ' "$D/p23" | grep -c 'unverified=2')
261
+ [ "$M23" = 1 ] \
262
+ && ok "L23 되돌림: 검증기를 자기 계열로 바꾸면 전부 unverified 로 떨어진다 (L20 은 장식이 아니다)" \
263
+ || no "L23 되돌림" "뮤턴트인데 unverified=2 가 안 나왔다 — L20 이 실물을 안 잰다 · $(grep '^PIPELINE ' "$D/p23")"
264
+
265
+ # L24 — G6: fleet 프롬프트가 defeater 를 «요구» 하는가 (축③ 을 잴 수 있는 최소 조건)
266
+ { grep -q '"defeater"' "$FLEET" && grep -q 'defeater. is required' "$FLEET"; } \
267
+ && ok "L24 fleet 스키마에 defeater 요구 (축③ 측정 가능)" || no "L24 defeater 스키마"
268
+ fi
269
+
270
+ # ══ cross-family 라운드 1 이 연 구멍들의 회귀 앵커 (codex, 2026-09-09) ══════════════════════════
271
+ # 🟥 각 레인은 «수리가 됐나」가 아니라 «그 구멍이 다시 열리면 빨개지나」를 잰다.
272
+ run_bounded() { # $1=초 · 나머지=명령. 되돌림이 무한루프를 되살려도 스위트가 안 멈추게.
273
+ local secs="$1"; shift
274
+ "$@" & local pid=$!
275
+ ( sleep "$secs"; kill -9 "$pid" 2>/dev/null ) & local wd=$!
276
+ wait "$pid" 2>/dev/null; local rc=$?
277
+ kill -9 "$wd" 2>/dev/null; wait "$wd" 2>/dev/null
278
+ return "$rc"
279
+ }
280
+
281
+ # 계열마다 다르게 답하는 가짜 CLI — codex 산출은 기각, gemini 산출은 인정, 감사는 correct-drop
282
+ cat > "$D/fake_smart.sh" <<'EOS'
283
+ #!/bin/sh
284
+ IN=$(cat)
285
+ [ -n "$IN" ] || IN="$*"
286
+ case "$IN" in *"correct-drop"*) MODE=audit ;; *) MODE=verify ;; esac
287
+ printf '%s\n' "$IN" | tr ',' '\n' | sed -n 's/.*"id": *"\([^"]*\)".*/\1/p' | sort -u | while read -r id; do
288
+ if [ "$MODE" = audit ]; then
289
+ echo "{\"id\":\"$id\",\"verdict\":\"correct-drop\",\"why\":\"line 1 checked\"}"
290
+ else
291
+ case "$id" in
292
+ codex-*) echo "{\"id\":\"$id\",\"verdict\":\"false-positive\",\"why\":\"line 1 says otherwise\"}" ;;
293
+ *) echo "{\"id\":\"$id\",\"verdict\":\"confirmed\",\"why\":\"line 1 holds\"}" ;;
294
+ esac
295
+ fi
296
+ done
297
+ EOS
298
+ chmod +x "$D/fake_smart.sh"
299
+
300
+ # L25 🟥 부분 감사는 감사가 아니다 — 무관한 id 하나만 답한 감사자가 «AUDITED · rc=0» 을 만들었다
301
+ cat > "$D/a_partial.sh" <<'EOS'
302
+ #!/bin/sh
303
+ cat >/dev/null
304
+ echo '{"id":"nonexistent-id","verdict":"correct-drop","why":"unrelated"}'
305
+ EOS
306
+ chmod +x "$D/a_partial.sh"
307
+ python3 "$VERIFY" "$D/f.jsonl" --out "$D/oP" --verifier "sh $D/v_ok.sh" --family beta \
308
+ --audit-verifier "sh $D/a_partial.sh" --audit-family gamma >"$D/sP" 2>&1; RC=$?
309
+ { [ "$RC" -eq 4 ] && grep -q 'drop_audit=PARTIAL' "$D/sP" && grep -q 'audited=0' "$D/sP"; } \
310
+ && ok "L25 부분 감사 → PARTIAL · rc=4 (무관한 id 하나로 «감사했다» 가 안 된다)" \
311
+ || no "L25 부분 감사" "rc=$RC · $(cat "$D/sP")"
312
+
313
+ # L26 🟥 주입 — 타깃 경로에 공백과 셸 메타문자가 있어도 «명령으로 실행되지 않는다»
314
+ TGD="$D/inj"; mkdir -p "$TGD"
315
+ # 🟥 파일명에 «/» 를 못 넣으므로 주입 페이로드는 상대 경로로 두고, 러너를 그 디렉터리에서 돌린다.
316
+ # (첫 판은 절대경로를 파일명에 박아 픽스처가 생성조차 안 됐다 — 죽은 계기였다.)
317
+ BADNAME='a b;touch PWNED.py'
318
+ ( cd "$TGD" && printf 'x = 1\n' > "$BADNAME" ) 2>/dev/null
319
+ if [ -f "$TGD/$BADNAME" ]; then
320
+ printf 'codex|logic|%s\ngemini|security|%s\n' "sh $D/m_codex.sh" "sh $D/m_gem.sh" > "$D/fleetI.tbl"
321
+ ( cd "$TGD" && FH_CODEX_BIN="$D/fake_smart.sh" FH_AGY_BIN="$D/fake_smart.sh" \
322
+ bash "$PIPE" "$BADNAME" --out "$D/pipeI" --fleet "$D/fleetI.tbl" ) >"$D/pI" 2>&1
323
+ RCI=$?
324
+ # 두 가지를 «같이» 본다: ⓐ 주입이 실행되지 않았나 ⓑ 공백 있는 경로로도 실제로 완주하나
325
+ # (ⓑ 없이 ⓐ 만 보면 «아예 안 돌아서 안전한» 것과 구별이 안 된다 — 죽은 컨트롤)
326
+ { [ ! -e "$TGD/PWNED.py" ] && [ "$RCI" -eq 0 ] && grep -q 'confirmed=' "$D/pI"; } \
327
+ && ok "L26 주입: 메타문자·공백 타깃이 «인자」로만 전달되고, 그러고도 완주한다" \
328
+ || no "L26 주입" "PWNED=$([ -e "$TGD/PWNED.py" ] && echo 생성됨 || echo 없음) rc=$RCI · $(grep '^PIPELINE' "$D/pI" || tail -2 "$D/pI")"
329
+ else
330
+ no "L26 주입" "픽스처 생성 실패 — 미측정(통과 아님)"
331
+ fi
332
+
333
+ # L27 🟥 값 없는 플래그는 «멈춤」이 아니라 rc=2 (첫 판은 무한루프였다)
334
+ run_bounded 8 bash "$VERIFIER" --family; RC=$?
335
+ [ "$RC" -eq 2 ] && ok "L27a verifier: 값 없는 --family → rc=2 (무한루프 아님)" || no "L27a 트레일링 플래그" "rc=$RC (137 이면 워치독이 죽인 것 = 여전히 멈춘다)"
336
+ run_bounded 8 bash "$PIPE" "$D/tgt.py" --out; RC=$?
337
+ [ "$RC" -eq 2 ] && ok "L27b pipeline: 값 없는 --out → rc=2" || no "L27b 트레일링 플래그" "rc=$RC"
338
+
339
+ # L28 🟥 종료코드는 «심각도 숫자」가 아니라 «종류» — 생존자가 있는데 rc=1 이 나오면 안 된다
340
+ printf 'codex|logic|%s\ngemini|security|%s\n' "sh $D/m_codex.sh" "sh $D/m_gem.sh" > "$D/fleetT.tbl"
341
+ FH_CODEX_BIN="$D/fake_smart.sh" FH_AGY_BIN="$D/fake_smart.sh" \
342
+ bash "$PIPE" "$D/tgt.py" --out "$D/pipeT" --fleet "$D/fleetT.tbl" >"$D/pT" 2>&1; RC=$?
343
+ SUMT=$(grep '^PIPELINE ' "$D/pT")
344
+ { [ "$RC" -eq 0 ] && printf '%s' "$SUMT" | grep -q 'confirmed=1' && printf '%s' "$SUMT" | grep -q 'dropped=1'; } \
345
+ && ok "L28 한 split 이 «생존 0»(rc=1) 이어도 다른 split 의 생존자가 있으면 전체 rc=0" \
346
+ || no "L28 종료코드 종류" "rc=$RC · $SUMT · $(tail -3 "$D/pT")"
347
+
348
+ # L29 🟥 --out 재사용이 «지난 런의 계열」을 수입하면 안 된다 (단일 계열이 교차검증된 것처럼 보인다)
349
+ printf 'codex|logic|%s\n' "sh $D/m_codex.sh" > "$D/fleetS.tbl"
350
+ FH_CODEX_BIN="$D/fake_smart.sh" bash "$PIPE" "$D/tgt.py" --out "$D/pipeT" --fleet "$D/fleetS.tbl" >"$D/pS" 2>&1; RC=$?
351
+ { [ "$RC" -eq 3 ] && ! grep -q 'gemini' "$D/pipeT/families.txt"; } \
352
+ && ok "L29 --out 재사용: 지난 런의 part_*.jsonl 을 수입하지 않는다 (단일 계열 → rc=3)" \
353
+ || no "L29 stale --out" "rc=$RC families=$(cat "$D/pipeT/families.txt" 2>/dev/null | tr '\n' ',')"
354
+
355
+ # L30 — 읽을 수 없는 타깃은 «타입은 파일」이어도 rc=2 (모델이 소스 없이 판정하지 않게)
356
+ UNR="$D/unreadable.py"; printf 'x=1\n' > "$UNR"; chmod 000 "$UNR" 2>/dev/null
357
+ if [ -r "$UNR" ]; then
358
+ no "L30 읽기불가 타깃" "chmod 000 이 안 먹었다(root?) — 미측정, 통과 아님"
359
+ else
360
+ echo '{"id":"a1","title":"t"}' | bash "$VERIFIER" --family codex --target "$UNR" >/dev/null 2>&1; RC=$?
361
+ [ "$RC" -eq 2 ] && ok "L30 읽기불가 타깃 → rc=2 (-f 는 타입만 본다)" || no "L30 읽기불가 타깃" "rc=$RC"
362
+ chmod 644 "$UNR" 2>/dev/null
363
+ fi
364
+
365
+ # ══ 보안 회귀 앵커 (cross-family security review 2026-09-09) ═══════════════════════════════════
366
+ # 🟥 각 레인은 «지금 안전한가»가 아니라 «그 구멍을 되돌리면 빨개지나»를 잰다.
367
+ # 되돌림 방법은 각 주석에 적어 뒀다.
368
+
369
+ # L31 (S1) — 출력 디렉터리 «이름»에 든 $(...) 가 실행되면 안 된다 (fleet 의 eval 경로)
370
+ # 되돌림: finding_fleet.sh 의 q_pf/q_codex/q_agy 결박을 빼고 raw 치환으로 되돌리면 빨개진다
371
+ SENT="$D/S1_EXECUTED"
372
+ EVILOUT="$D/out\$(touch $SENT)"
373
+ printf 'x = 1\n' > "$D/s1.py"
374
+ printf 'codex|logic|%s\n' "sh $D/m_codex.sh" > "$D/fleetS1.tbl"
375
+ FH_CODEX_BIN=/bin/true bash "$HERE/finding_fleet.sh" "$D/s1.py" --out "$EVILOUT" --fleet "$D/fleetS1.tbl" >/dev/null 2>&1
376
+ [ ! -e "$SENT" ] \
377
+ && ok "L31 (S1) 출력 경로의 \$(...) 가 실행되지 않는다 (eval 치환 결박)" \
378
+ || no "L31 (S1) eval 주입" "출력 디렉터리 이름의 명령이 실행됐다"
379
+
380
+ # L32 (S2) — 검증기는 «셸 문자열»이 아니라 argv 로 넘어간다
381
+ # 되돌림: finding_pipeline.sh 를 --verifier 문자열 형태로 되돌리면 빨개진다.
382
+ # 🟥 왜 문자열이 위험한지: shell=True 는 /bin/sh 이고, bash %q 의 $'...' 는 dash 에서 안 통한다.
383
+ # macOS 는 /bin/sh 가 bash 계열이라 «실행해도» 이 축이 안 보인다 — 그래서 형태를 단언한다.
384
+ { /usr/bin/grep -q -- '--verifier-argv' "$PIPE" && /usr/bin/grep -q -- '--audit-verifier-argv' "$PIPE" \
385
+ && ! /usr/bin/grep -qE '^\s*--verifier "bash ' "$PIPE"; } \
386
+ && ok "L32 (S2) 파이프라인이 argv 형태로 넘긴다 (셸이 경로를 파싱하지 않는다)" \
387
+ || no "L32 (S2) argv 형태" "문자열 --verifier 로 되돌아갔다 — dash 에서 인용이 깨진다"
388
+
389
+ # L32b (S2) — finding_verify.py 가 리스트를 받으면 shell 없이 실행한다
390
+ /usr/bin/grep -q 'shell = isinstance(cmd, str)' "$VERIFY" \
391
+ && ok "L32b (S2) verify 는 리스트=argv · 문자열=셸 로 갈라 실행한다" \
392
+ || no "L32b (S2) shell 분기" "shell=True 고정으로 되돌아갔다"
393
+
394
+ # L33 (S5) — 타깃이 심링크면 «내용이 외부로 나가므로» 거부해야 한다
395
+ printf 'SECRET=abc\n' > "$D/outside_secret.txt"
396
+ ln -sf "$D/outside_secret.txt" "$D/link_target.py"
397
+ bash "$VERIFIER" --family codex --target "$D/link_target.py" < /dev/null >/dev/null 2>&1; RC=$?
398
+ [ "$RC" -eq 2 ] && ok "L33 (S5) verifier: 심링크 타깃 거부 → rc=2 (외부 비밀 업로드 차단)" || no "L33 (S5) 심링크 타깃" "rc=$RC"
399
+ bash "$HERE/finding_fleet.sh" "$D/link_target.py" --out "$D/o33" >/dev/null 2>&1; RC=$?
400
+ [ "$RC" -eq 2 ] && ok "L33b (S5) fleet: 심링크 타깃 거부 → rc=2" || no "L33b (S5) fleet 심링크" "rc=$RC"
401
+
402
+ # L34 (S4) — 출력물이 심링크면 «남의 파일 truncate» 이므로 쓰기 전에 거부
403
+ printf 'DO NOT TRUNCATE\n' > "$D/victim.conf"
404
+ mkdir -p "$D/o34"; ln -sf "$D/victim.conf" "$D/o34/confirmed.jsonl"
405
+ printf 'x = 1\n' > "$D/s4.py"
406
+ bash "$PIPE" "$D/s4.py" --out "$D/o34" --fleet "$D/fleet.tbl" >/dev/null 2>&1; RC=$?
407
+ { [ "$RC" -eq 2 ] && /usr/bin/grep -q "DO NOT TRUNCATE" "$D/victim.conf"; } \
408
+ && ok "L34 (S4) 출력 심링크 거부 → rc=2 · 바깥 파일 온전" \
409
+ || no "L34 (S4) 출력 심링크" "rc=$RC victim=$(head -c 20 "$D/victim.conf")"
410
+
411
+ # L35 (A7) — 프롬프트 파일은 소스 전문을 담는다. 다른 계정이 읽으면 안 된다
412
+ printf 'x = 1\n' > "$D/s7.py"
413
+ FH_CODEX_BIN=/bin/true bash "$HERE/finding_fleet.sh" "$D/s7.py" --out "$D/o35" --fleet "$D/fleetS1.tbl" >/dev/null 2>&1
414
+ PF=$(ls "$D/o35"/prompt_*.txt 2>/dev/null | head -1)
415
+ if [ -n "$PF" ]; then
416
+ MODE=$(/usr/bin/stat -f "%OLp" "$PF" 2>/dev/null || /usr/bin/stat -c "%a" "$PF" 2>/dev/null)
417
+ case "$MODE" in *[04]|*[04][04]) OTHERS_R=1;; *) OTHERS_R=0;; esac
418
+ # 마지막 자리(others)가 4 이상이면 읽힌다
419
+ LAST=${MODE#${MODE%?}}
420
+ [ "$LAST" -lt 4 ] && ok "L35 (A7) 프롬프트 파일이 others 에게 안 읽힌다 (mode=$MODE)" \
421
+ || no "L35 (A7) 프롬프트 권한" "mode=$MODE — 소스 전문이 더 넓게 읽힌다"
422
+ else
423
+ no "L35 (A7) 프롬프트 권한" "프롬프트 파일이 안 생겼다 — 미측정(통과 아님)"
424
+ fi
425
+
426
+ # ── L36~L38 «생산자 미상» 은 깨끗함이 아니다 (2026-09-09) ───────────────────────
427
+ # 🟥 이 파일이 스스로 «강제하는 유일한 속성» 이라 선언한 자기검증 금지가 OPTIONAL 필드에
428
+ # 걸려 있었다: producer_family 를 빼면 검사가 통째로 건너뛰고 같은 계열이 자기 발견을
429
+ # 승인하며 status=VERIFIED rc=0 이 나왔다. 세 팔로 박는다 — 세 번째(다른 계열 → 정상
430
+ # 통과)가 없으면 «그냥 다 막아버린 픽스» 와 구분되지 않는다.
431
+ cat > "$D/v_yes.sh" <<'EOS'
432
+ #!/bin/sh
433
+ cat >/dev/null
434
+ echo '{"id":"z1","verdict":"confirmed","why":"stub"}'
435
+ EOS
436
+ chmod +x "$D/v_yes.sh"
437
+ _pf_run() { # $1 = jsonl 한 줄, $2 = 이름 → "rc|status"
438
+ printf '%s\n' "$1" > "$D/pf_$2.jsonl"
439
+ local o rc
440
+ o=$(python3 "$HERE/finding_verify.py" "$D/pf_$2.jsonl" --out "$D/pf_o_$2" \
441
+ --verifier "sh $D/v_yes.sh" --family beta 2>&1); rc=$?
442
+ printf '%s|%s' "$rc" "$(printf '%s' "$o" | sed -n 's/.*status=\([A-Z-]*\).*/\1/p' | head -1)"
443
+ }
444
+ R36=$(_pf_run '{"id":"z1","title":"t"}' absent)
445
+ [ "$R36" = "3|UNVERIFIED" ] \
446
+ && ok "L36 producer_family 부재 → unverified·rc=3 (부재는 «검증됨» 이 아니다)" \
447
+ || no "L36 부재 fail-closed" "got=$R36 want=3|UNVERIFIED"
448
+ R37=$(_pf_run '{"id":"z1","title":"t","producer_family":"beta"}' same)
449
+ [ "$R37" = "3|UNVERIFIED" ] \
450
+ && ok "L37 producer_family == 검증자 계열 → unverified·rc=3 (자기 저작 승인 금지)" \
451
+ || no "L37 동일 계열 차단" "got=$R37 want=3|UNVERIFIED"
452
+ R38=$(_pf_run '{"id":"z1","title":"t","producer_family":"gamma"}' diff)
453
+ [ "$R38" = "0|VERIFIED" ] \
454
+ && ok "L38 producer_family != 검증자 계열 → 정상 통과 (과차단 아님)" \
455
+ || no "L38 과차단 없음" "got=$R38 want=0|VERIFIED"
456
+
457
+ /bin/rm -rf "$D"
458
+ echo "PASS=$PASS FAIL=$FAIL"
459
+ [ "$FAIL" -eq 0 ] && { echo "FAILED=0"; exit 0; } || { echo "FAILED=1"; exit 1; }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # test_gate_shape_scan_lanes.sh — scripts/gate_shape_scan.sh 회귀 앵커.
3
+ # 지키는 것은 «판별력»: known-pair 가 갈리는가, 주석만 있는 파일이 안 걸리는가, 바이너리가
4
+ # «아님» 으로 접히지 않는가, Promise `reject(` 가 게이트로 읽히지 않는가(실측 오탐).
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "$0")" && pwd)"; SCAN="$HERE/gate_shape_scan.sh"
7
+ PASS=0; FAIL=0
8
+ ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
9
+ no(){ FAIL=$((FAIL+1)); printf ' ❌ %s — %s\n' "$1" "${2:-}"; }
10
+ echo "── test_gate_shape_scan_lanes ──"
11
+ [ -x "$SCAN" ] || { no "L1 실행비트" "$SCAN"; echo "FAILED=1"; exit 1; }
12
+ bash -n "$SCAN" && ok "L1 구문" || no "L1 구문"
13
+ OUT=$(bash "$HERE/gate_shape_scan.sh" --selftest 2>&1); RC=$? # 직접 호출(변수 경유 아님) — new-code-anchor 가 실행으로 센다
14
+ [ "$RC" -eq 0 ] && ok "L2 selftest rc=0 (known-pair 5)" || no "L2 selftest" "rc=$RC · $OUT"
15
+ D="$(mktemp -d 2>/dev/null)" || D=""; [ -n "$D" ] && [ -w "$D" ] || { no "L3 환경" "mktemp -d 불가 — 레인 미측정(통과 아님)"; echo "PASS=$PASS FAIL=$FAIL"; echo "FAILED=1"; exit 1; }
16
+ printf 'export function up(p) {\n return new Promise((resolve, reject) => {\n if (!p) reject(new Error("x"));\n resolve(p);\n });\n}\n' > "$D/promise.ts"
17
+ bash "$SCAN" "$D/promise.ts" >/dev/null 2>&1; RC=$?
18
+ [ "$RC" -eq 1 ] && ok "L3 Promise reject( 는 게이트가 아니다 (실측 오탐 앵커)" || no "L3 Promise reject" "rc=$RC"
19
+ printf 'func main() {\n http.ListenAndServe("0.0.0.0:8080", nil)\n}\n' > "$D/serve.go"
20
+ bash "$SCAN" "$D/serve.go" >/dev/null 2>&1; RC=$?
21
+ [ "$RC" -eq 0 ] && ok "L4 Go ListenAndServe → GATE-SHAPED" || no "L4 Go exposure" "rc=$RC"
22
+ printf 'def f():\n return "PASS"\n' > "$D/enum.py"; printf 'def g():\n pass\n' > "$D/lower.py"
23
+ bash "$SCAN" "$D/enum.py" >/dev/null 2>&1; R1=$?; bash "$SCAN" "$D/lower.py" >/dev/null 2>&1; R2=$?
24
+ [ "$R1" -eq 0 ] && [ "$R2" -eq 1 ] && ok "L5 대문자 PASS 만 enum (소문자 pass 는 키워드)" || no "L5 enum case" "PASS rc=$R1 pass rc=$R2"
25
+ bash "$SCAN" >/dev/null 2>&1; RC=$?; [ "$RC" -eq 3 ] && ok "L6 인자 없음 → usage rc=3" || no "L6 usage" "rc=$RC"
26
+ printf '\x00\x01bin' > "$D/b.dat"; bash "$SCAN" "$D/serve.go" "$D/b.dat" >/dev/null 2>&1; RC=$?
27
+ [ "$RC" -eq 3 ] && ok "L6b [gate-shaped, binary] → 3 이 0 을 이긴다 (무음 미스 금지)" || no "L6b 혼합 exit" "rc=$RC"
28
+ printf 'x = author\n' > "$D/au.py"; printf 'ok = authorize(u)\n' > "$D/az.py"
29
+ bash "$SCAN" "$D/au.py" >/dev/null 2>&1; R1=$?; bash "$SCAN" "$D/az.py" >/dev/null 2>&1; R2=$?
30
+ [ "$R1" -eq 1 ] && [ "$R2" -eq 0 ] && ok "L6c 경계: author 는 아니고 authorize 는 맞다" || no "L6c auth 경계" "author rc=$R1 authorize rc=$R2"
31
+ # L7 — 되돌림: 분류기의 EXPOSURE 클래스를 죽이면 L4 가 빨개지는가 (앵커가 장식이 아님을 실행으로)
32
+ MUT="$D/scan_mut.sh"; /usr/bin/sed 's/^EXPOSURE_RE=.*/EXPOSURE_RE='"'"'(NEVER_MATCHES_XYZ)'"'"'/' "$SCAN" > "$MUT"
33
+ bash "$MUT" "$D/serve.go" >/dev/null 2>&1; RC=$?
34
+ [ "$RC" -eq 1 ] && ok "L7 되돌림: EXPOSURE 제거 → serve.go 가 NOT 로 떨어진다 (레인이 실물을 잰다)" || no "L7 되돌림" "rc=$RC (제거해도 초록이면 앵커가 장식)"
35
+ /bin/rm -rf "$D"
36
+ echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ] && { echo "FAILED=0"; exit 0; } || { echo "FAILED=1"; exit 1; }
@@ -7,8 +7,14 @@
7
7
  # louder symptom. Nothing was watching the watcher: measured 2026-08-20, `scripts/test_*.sh` files
8
8
  # testing this classifier = **0**, while 12 test files touch the hook for other reasons.
9
9
  #
10
- # Absorbed from a sibling harness (pmh-dev, 2026-08-08) rather than invented here. The load-bearing
11
- # idea is the extraction below, not the fixture list.
10
+ # Provenance arc (sibling harness pmh-dev → FH, recorded both ways per the shared-layer canon rule):
11
+ # this lane started TWO-WAY in pmh-dev (2026-08-08), was absorbed into FH, and FH extended the
12
+ # classifier to FOUR-WAY (HEAVY · CARVEOUT · LIGHT · uncovered). The load-bearing idea is the
13
+ # extraction below, not the fixture list. pmh-dev's own «#32 seam» rows were NOT re-imported
14
+ # (2026-09-08, sibling-harvest review): each maps onto a class already pinned here — seam 1 (scripts/*.sh)
15
+ # → `scripts/selfcheck.sh` · seam 2 (SKILL_detail.md) → goal-quench row · seam 3 (agent .md) →
16
+ # challenger/local rows · seam 4 (sibling md in a skill dir) → `assets/note.md` row. Re-pinning the
17
+ # same class under a sibling's issue number would be a decorative row, not a new anchor.
12
18
  #
13
19
  # 🟥 FH's classifier is FOUR-WAY, not two-way like the sibling's. The port is not a copy:
14
20
  # HEAVY → full gate
@@ -105,13 +111,17 @@ check carveout "README.zh-CN.md" "🟥 5-char locale forms too — c
105
111
  check uncovered ".claude/registry/README.md" "🟥 STILL UNCOVERED — in files[] but not root; named, not closed"
106
112
  check uncovered "package.json" "files[] / version changes are unclassified" # portability-noqa: "package.json" is a string literal fed to classify()'s regex test, never opened from disk — a ported repo without this exact file still exercises the same classifier logic
107
113
  check uncovered ".github/workflows/validate.yml" "🟥 CI definition — changing what CI runs is unclassified"
108
- check uncovered "scripts/memory_link_check.py" "🟥 .py is not .sh — shipped python is unclassified"
114
+ check heavy "scripts/memory_link_check.py" "🟢 .py joined .sh on 2026-09-07 was pinned «uncovered» here (7 shipped python files outside the gate)"
115
+ check heavy "scripts/doc_claim_triad_scan.py" ".py — the file whose gate-less commit exposed the blind spot"
116
+ check heavy "scripts/adapters/peer_resolve.py" ".py in a subdirectory — the regex is ^scripts/.*, not ^scripts/[^/]*"
109
117
 
110
118
  echo " ── controls (the classifier discriminates, it does not just fire) ──"
111
119
  # known-negative: a path that resembles a HEAVY one but must not match. This is the arm that
112
120
  # separates "the classifier works" from "the classifier fires on everything" — without it, a
113
121
  # regex of `.` would pass every HEAVY row above.
114
122
  check uncovered "notes/scripts/foo.sh.txt" "control — .sh.txt is not a shell script (\$-anchored)"
123
+ check uncovered "scripts/notes.txt" "control — a non-executable under scripts/ must NOT become heavy with the .py widening"
124
+ check uncovered "scripts/data.pyc" "control — .pyc is not .py (\$-anchored, alternation closed)"
115
125
  check uncovered "notes/skills-overview.md" "control — 'skills' in a path is not a skill spec"
116
126
  # ⚠️ this control was first written as `docs/design/skills-overview.md`, which the lane
117
127
  # correctly classified `carveout` (`^docs/.*\.md$`). The FIXTURE was wrong, not the