@chrono-meta/fh-gate 3.1.3 → 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 -2
  6. package/README.ko.md +3 -2
  7. package/README.md +3 -2
  8. package/README.zh.md +3 -2
  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 +91 -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,106 @@
1
+ #!/usr/bin/env bash
2
+ # gate_shape_scan.sh — is this file «gate-shaped»? The mechanical half of the Field-Harness
3
+ # Load-Bearing Change Gate trigger for files whose project is NOT mapped (CLAUDE.md
4
+ # §Field-Harness Load-Bearing Change Gate · field_verdict_crossfamily_gate.md
5
+ # §Registration is not a precondition, 2026-09-08).
6
+ #
7
+ # IT IS A CLASSIFIER OF THE FILE'S SHAPE, NOT A VERDICT ON THE FILE. A hit means «this file is
8
+ # in the gate's scope — run degrade-lint + cross-family before a merge verdict». A miss means
9
+ # «not in scope by identifier»; the task's own naming of the file as gate/auth/exposure code is a
10
+ # separate MANUAL escalation and is not read here (a prompt is not a property of the file).
11
+ #
12
+ # What counts (three classes — each hit is printed with the class and the matched line):
13
+ # VERDICT word-bounded, case-insensitive: allow(ed) deny denied permit(ted) approve/approved/approval
14
+ # verdict permission(s) auth authn authz authorize(d) authorization authenticat* auth_<x>
15
+ # (🟥 `author`/`authored`/`allowance` do NOT match — the auth branch is a closed list, not `auth.*`)
16
+ # (🟥 `reject` is deliberately absent — it collides with the Promise API `reject(err)`,
17
+ # measured on a real TS mock server: 2 hits, both Promise callbacks. Named residual:
18
+ # a gate that spells its refusal `reject(` is missed by this class; `deny` is not.)
19
+ # permission auth authn authz · plus the UPPERCASE enum literals PASS FAIL BLOCK
20
+ # (uppercase only — lowercase `pass`/`fail`/`block` are ordinary words/keywords)
21
+ # EXPOSURE a socket/server bind or listen: listen( bind( ListenAndServe Serve( 0.0.0.0 INADDR_ANY
22
+ # IRREV an irreversible-op path: publish( delete( unlink( rm -rf force-push --force
23
+ # history-rewrite filter-branch reset --hard
24
+ # Comment/string handling (named residual, crude on purpose): a line whose first non-blank
25
+ # characters are `#`, `//`, `* ` (star+space/end: block-comment body), `/*`, `-- ` (dash-dash+space/end:
26
+ # SQL/Lua), `;`, `"""`, `'''` is skipped; `*allow = 1` and a continued `--force` line are CODE and scanned. Inline trailing comments
27
+ # and string literals are NOT stripped — a false positive here costs one review, a false negative
28
+ # costs a missed gate, so the scan leans toward firing.
29
+ # Supported: any text file. Binary or unreadable → UNSCANNABLE (exit 3), never a silent miss.
30
+ #
31
+ # Usage: bash scripts/gate_shape_scan.sh <file> [file ...]
32
+ # bash scripts/gate_shape_scan.sh --selftest # known-pair calibration
33
+ # Exit: 0 = GATE-SHAPED (≥1 hit, all files scannable) · 1 = NOT-GATE-SHAPED · 3 = UNSCANNABLE/usage
34
+ # (3 dominates 0: a hit next to an unscannable file still exits 3 — that file is unresolved)
35
+ set -uo pipefail
36
+ export LC_ALL=C
37
+ VERDICT_RE='\b(allow(ed)?|deny|denied|permit(ted)?|approv(e|ed|al)|verdict|permissions?|auth(n|z|orize|orized|orization|entic[a-z]*|_[a-z_]+)?)\b'
38
+ ENUM_RE='\b(PASS|FAIL|BLOCK)\b'
39
+ EXPOSURE_RE='(\blisten\(|\bbind\(|ListenAndServe|\bServe\(|0\.0\.0\.0|INADDR_ANY)'
40
+ IRREV_RE='(\bpublish\(|\bdelete\(|\bunlink\(|rm -rf|force-push|--force\b|history-rewrite|filter-branch|reset --hard)'
41
+ COMMENT_RE='^[[:space:]]*(#|//|\*([[:space:]]|$)|/\*|--([[:space:]]|$)|;|"""|'"'''"')'
42
+
43
+ scan_one() { # $1=file → prints hits, returns 0 hit / 1 none / 3 unscannable
44
+ local f="$1" body v e r hits
45
+ [ -r "$f" ] || { echo "UNSCANNABLE $f (unreadable)"; return 3; }
46
+ if /usr/bin/file -b --mime-encoding "$f" 2>/dev/null | /usr/bin/grep -q '^binary$'; then
47
+ echo "UNSCANNABLE $f (binary)"; return 3; fi
48
+ # one pass: drop comment-led lines, then classify (VERDICT wins over EXPOSURE over IRREV per line)
49
+ body=$(/usr/bin/grep -nEv "$COMMENT_RE" "$f" 2>/dev/null || true)
50
+ v=$(printf '%s\n' "$body" | /usr/bin/grep -Ei "$VERDICT_RE" || true)
51
+ v2=$(printf '%s\n' "$body" | /usr/bin/grep -E "$ENUM_RE" || true)
52
+ e=$(printf '%s\n' "$body" | /usr/bin/grep -E "$EXPOSURE_RE" || true)
53
+ r=$(printf '%s\n' "$body" | /usr/bin/grep -E "$IRREV_RE" || true)
54
+ hits=0
55
+ # 🟥 파일명을 sed 프로그램에 넣지 않는다. `s|^|... $f:|` 는 파일명 안의 `|` 로 구분자를 벗어나고,
56
+ # 거기에 `r <path>` 를 이어 붙이면 임의 파일 내용이 스캔 출력에 실리고 `w` 는 덮어쓴다
57
+ # (cross-family security review 2026-09-09, 무해한 프로브로 추가 파일 읽기 실증).
58
+ # awk 의 ENVIRON 은 값을 «프로그램»이 아니라 «데이터»로 받으므로 이 통로가 닫힌다.
59
+ [ -n "$v" ] && { hits=1; printf '%s\n' "$v" | /usr/bin/cut -c1-140 | FH_LABEL="VERDICT $f:" /usr/bin/awk '{print ENVIRON["FH_LABEL"] $0}'; }
60
+ [ -n "$v2" ] && { hits=1; printf '%s\n' "$v2" | /usr/bin/cut -c1-140 | FH_LABEL="VERDICT $f:" /usr/bin/awk '{print ENVIRON["FH_LABEL"] $0}'; }
61
+ [ -n "$e" ] && { hits=1; printf '%s\n' "$e" | /usr/bin/cut -c1-140 | FH_LABEL="EXPOSURE $f:" /usr/bin/awk '{print ENVIRON["FH_LABEL"] $0}'; }
62
+ [ -n "$r" ] && { hits=1; printf '%s\n' "$r" | /usr/bin/cut -c1-140 | FH_LABEL="IRREV $f:" /usr/bin/awk '{print ENVIRON["FH_LABEL"] $0}'; }
63
+ [ "$hits" -gt 0 ] && return 0 || return 1
64
+ }
65
+
66
+ selftest() { # known pair — positive must hit, negative must not, comment-only must not
67
+ local d rc_pos rc_pos2 rc_neg rc_cmt rc_bin rc_irr rc_bnd rc_star fails=0
68
+ d="$(mktemp -d 2>/dev/null)" || d=""
69
+ [ -n "$d" ] && [ -w "$d" ] || { echo "SELFTEST: ENV-BLOCKED (mktemp -d failed) — result unmeasured, not a pass"; return 3; }
70
+ printf 'export class S {\n start() {\n this.app.listen(this.config.port, () => {});\n }\n}\n' > "$d/pos_exposure.ts"
71
+ printf 'def check(user):\n if user.role == "admin":\n return Verdict.ALLOW\n return Verdict.DENY\n' > "$d/pos_verdict.py"
72
+ printf 'def add(a, b):\n """Return the sum. Pass through ints."""\n return a + b\n\ndef fmt(x):\n return f"{x:.2f}"\n' > "$d/neg_util.py"
73
+ printf '# we allow anything here\n// auth is elsewhere\n/* listen( is not called */\n * allow: block-comment body\n-- deny in a SQL comment\nx = 1\n' > "$d/neg_comments.py"
74
+ printf '\x00\x01\x02binary\x00' > "$d/bin.dat"
75
+ printf 'set -e\ngit push origin main \\\n --force\n' > "$d/pos_irrev.sh"
76
+ printf 'authored_by = "x"\nallowance = 3\nauthor = "y"\n' > "$d/neg_boundary.py"
77
+ printf 'int f(int *allow) {\n *allow = 1;\n return 0;\n}\n' > "$d/pos_star.c"
78
+ scan_one "$d/pos_exposure.ts" >/dev/null; rc_pos=$?
79
+ scan_one "$d/pos_verdict.py" >/dev/null; rc_pos2=$?
80
+ scan_one "$d/neg_util.py" >/dev/null; rc_neg=$?
81
+ scan_one "$d/neg_comments.py" >/dev/null; rc_cmt=$?
82
+ scan_one "$d/bin.dat" >/dev/null; rc_bin=$?
83
+ scan_one "$d/pos_irrev.sh" >/dev/null; rc_irr=$?
84
+ scan_one "$d/neg_boundary.py" >/dev/null; rc_bnd=$?
85
+ scan_one "$d/pos_star.c" >/dev/null; rc_star=$?
86
+ [ "$rc_pos" -eq 0 ] && echo " ✅ known-positive exposure (listen()) → GATE-SHAPED" || { echo " ❌ known-positive exposure rc=$rc_pos"; fails=1; }
87
+ [ "$rc_pos2" -eq 0 ] && echo " ✅ known-positive verdict (ALLOW/DENY) → GATE-SHAPED" || { echo " ❌ known-positive verdict rc=$rc_pos2"; fails=1; }
88
+ [ "$rc_neg" -eq 1 ] && echo " ✅ known-negative util (docstring 'Pass') → NOT" || { echo " ❌ known-negative util rc=$rc_neg"; fails=1; }
89
+ [ "$rc_cmt" -eq 1 ] && echo " ✅ comment-only mentions (#, //, /*, ' * ', '-- ') → NOT" || { echo " ❌ comment-only rc=$rc_cmt"; fails=1; }
90
+ [ "$rc_bin" -eq 3 ] && echo " ✅ binary → UNSCANNABLE (not a silent miss)" || { echo " ❌ binary rc=$rc_bin"; fails=1; }
91
+ [ "$rc_irr" -eq 0 ] && echo " ✅ known-positive irreversible (continued --force line) → GATE-SHAPED" || { echo " ❌ known-positive irrev rc=$rc_irr"; fails=1; }
92
+ [ "$rc_bnd" -eq 1 ] && echo " ✅ boundary: author/authored/allowance → NOT" || { echo " ❌ boundary rc=$rc_bnd"; fails=1; }
93
+ [ "$rc_star" -eq 0 ] && echo " ✅ code line starting with *allow (not a comment) → GATE-SHAPED" || { echo " ❌ star-code rc=$rc_star"; fails=1; }
94
+ /bin/rm -rf "$d"
95
+ [ "$fails" -eq 0 ] && { echo "SELFTEST: PASS"; return 0; } || { echo "SELFTEST: FAIL"; return 3; }
96
+ }
97
+
98
+ [ $# -ge 1 ] || { echo "usage: $0 <file> [file ...] | --selftest" >&2; exit 3; }
99
+ [ "$1" = "--selftest" ] && { selftest; exit $?; }
100
+ ANY=0; UNS=0
101
+ for f in "$@"; do scan_one "$f"; rc=$?; [ "$rc" -eq 0 ] && ANY=1; [ "$rc" -eq 3 ] && UNS=1; done
102
+ if [ "$UNS" -eq 1 ]; then
103
+ [ "$ANY" -eq 1 ] && echo "GATE-SHAPED in the scannable files —"
104
+ echo "UNSCANNABLE — ≥1 file could not be classified; decide those by hand. Exit 3 dominates any hit (never a silent miss)"; exit 3
105
+ elif [ "$ANY" -eq 1 ]; then echo "GATE-SHAPED — run degrade-lint + cross-family before a merge verdict"; exit 0
106
+ else echo "NOT-GATE-SHAPED (by identifier; the task's own naming is a separate manual escalation)"; exit 1; fi
@@ -418,6 +418,55 @@ else
418
418
  fail=1
419
419
  fi
420
420
 
421
+ # doc-claim triad (bridge ①) — a doc that says «A uses B» where A never executes B.
422
+ # Subject = scripts/doc_claim_triad_scan.py (review surface, not a verdict). Wired in the same
423
+ # commit that created its lane, because lane_runner_check.sh rejects an undeclared unwired suite —
424
+ # and adding it to DEBT instead would be the regrowth that file exists to stop.
425
+ if [ ! -f scripts/doc_claim_triad_scan.py ]; then
426
+ _absent_subject_verdict "doc-claim triad lanes" "scripts/doc_claim_triad_scan.py" || fail=1
427
+ elif [ -f scripts/test_doc_claim_triad_lanes.sh ]; then
428
+ if ! bash scripts/test_doc_claim_triad_lanes.sh; then
429
+ fail=1
430
+ fi
431
+ else
432
+ echo "FAIL doc-claim triad lanes: doc_claim_triad_scan.py present but its anchor is missing"
433
+ fail=1
434
+ fi
435
+
436
+ # typed-finding pipeline — fleet (multi-family, parallel) + reject stage (cross-family verdict, code
437
+ # drops false positives). Subject = scripts/finding_fleet.sh + scripts/finding_verify.py.
438
+ if [ ! -f scripts/finding_verify.py ]; then
439
+ _absent_subject_verdict "finding pipeline lanes" "scripts/finding_verify.py" || fail=1
440
+ elif [ -f scripts/test_finding_pipeline_lanes.sh ]; then
441
+ if ! bash scripts/finding_fleet.sh --selftest >/dev/null 2>&1; then
442
+ echo "FAIL finding fleet selftest: known-pair calibration failed"; fail=1
443
+ fi
444
+ if ! bash scripts/test_finding_pipeline_lanes.sh; then
445
+ fail=1
446
+ fi
447
+ else
448
+ echo "FAIL finding pipeline lanes: scripts present but their anchor is missing"
449
+ fail=1
450
+ fi
451
+
452
+ # gate-shape classifier — the mechanical half of the unmapped-file trigger of the Field-Harness
453
+ # Load-Bearing Change Gate (2026-09-08). A classifier of scope, not a verdict; its lane holds the
454
+ # known-pair (exposure/verdict positives · util/comment negatives · Promise reject( FP anchor).
455
+ if [ ! -f scripts/gate_shape_scan.sh ]; then
456
+ _absent_subject_verdict "gate-shape lanes" "scripts/gate_shape_scan.sh" || fail=1
457
+ elif [ -f scripts/test_gate_shape_scan_lanes.sh ]; then
458
+ # direct dispatch of the subject's own selftest (caller surface for the ratchet), then the lane
459
+ if ! bash scripts/gate_shape_scan.sh --selftest >/dev/null 2>&1; then
460
+ echo "FAIL gate-shape selftest: known-pair calibration failed"; fail=1
461
+ fi
462
+ if ! bash scripts/test_gate_shape_scan_lanes.sh; then
463
+ fail=1
464
+ fi
465
+ else
466
+ echo "FAIL gate-shape lanes: gate_shape_scan.sh present but its anchor is missing"
467
+ fail=1
468
+ fi
469
+
421
470
  # package-coverage — a shipped doc must not point at a file the tarball omits. Distinct from the
422
471
  # ref-path check below: that one asks "does this path exist at all", this one asks "does the
423
472
  # CONSUMER get it". Measured 2026-07-28: 35 paths existed, were named by a shipped doc, and were
@@ -663,6 +712,7 @@ for _pair in \
663
712
  "plugins/fh-commons/skills/preprep/lane_adjacent_dup.py|scripts/test_preprep_adjacent_dup_lanes.sh" \
664
713
  "plugins/fh-commons/skills/preprep/lane_promise.py|scripts/test_preprep_promise_lanes.sh" \
665
714
  "plugins/fh-commons/skills/preprep/lane_slide_refs.py|scripts/test_preprep_slide_refs_lanes.sh" \
715
+ "plugins/fh-commons/skills/preprep/lane_font.py|scripts/test_preprep_font_lanes.sh" \
666
716
  "plugins/fh-commons/skills/preprep/SKILL.md|scripts/test_preprep_drift_anchor.sh" \
667
717
  "scripts/test_preprep_drift_anchor.sh|scripts/test_preprep_drift_anchor_lanes.sh" \
668
718
  "scripts/field_canon_preload.sh|scripts/test_skill_canon_preload_lanes.sh" \
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env bash
2
+ # test_doc_claim_triad_lanes.sh — scripts/doc_claim_triad_scan.py 회귀 앵커.
3
+ #
4
+ # 🟥 이 레인이 지키는 것은 «숫자»가 아니라 «판별력»이다. 그 계기는 세션 중 세 번 뚫렸고
5
+ # (공존≠관계 · 방향 미해결 · 나열 통과) 세 번 다 손검증이 잡았다. 레인은 그 셋이
6
+ # 되돌아오는지를 본다 — 픽스처는 실제로 뚫렸던 표기를 그대로 쓴다.
7
+ set -uo pipefail
8
+ HERE="$(cd "$(dirname "$0")" && pwd)"; ROOT="$(dirname "$HERE")"
9
+ SCAN="$HERE/doc_claim_triad_scan.py"
10
+ PASS=0; FAIL=0
11
+ ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
12
+ no(){ FAIL=$((FAIL+1)); printf ' ❌ %s — %s\n' "$1" "${2:-}"; }
13
+
14
+ echo "── test_doc_claim_triad_lanes ──"
15
+
16
+ # L1 — 파일 실재 + 실행 가능
17
+ [ -f "$SCAN" ] || { no "L1 파일 실재" "$SCAN 없음"; echo "FAILED=1"; exit 1; }
18
+ python3 -c "import ast,sys;ast.parse(open(sys.argv[1],encoding='utf-8').read())" "$SCAN" >/dev/null 2>&1 \
19
+ && ok "L1 구문" || no "L1 구문" "ast.parse 실패"
20
+
21
+ # L2 — 계기 보정이 통과한다 (양쪽 절반)
22
+ OUT=$(python3 "$SCAN" --selftest "$ROOT" 2>&1); RC=$?
23
+ [ "$RC" -eq 0 ] && ok "L2 selftest rc=0" || no "L2 selftest" "rc=$RC"
24
+ printf '%s' "$OUT" | grep -q "주장 추출기" && ok "L2b 추출기 보정 실행됨" || no "L2b 추출기 보정" "절이 없다"
25
+ printf '%s' "$OUT" | grep -q "실행 판별기" && ok "L2c 실행판별 보정 실행됨" || no "L2c 실행판별 보정" "절이 없다"
26
+
27
+ # L3 — 🟥 나열을 주장으로 세지 않는다 (v1 이 뚫린 자리, 실제 표기)
28
+ python3 - "$SCAN" <<'PY' && ok "L3 나열 거부" || no "L3 나열 거부" "나열이 주장으로 셰짐"
29
+ import importlib.util,sys
30
+ spec=importlib.util.spec_from_file_location("m",sys.argv[1]); m=importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(m)
32
+ A,B="scripts/foo.sh","scripts/bar.sh"
33
+ # 🟥 cross-family codex [B]: 가운뎃점·백틱·쉼표만 막으면 «and/plus/인용부호» 로 뚫린다.
34
+ # 그 표기를 픽스처에 넣는다 — 실제로 CATALOG.md:137 이 `X (pre-commit) and Y (pre-push)` 였다.
35
+ lists=[f"**File:** {A} · {B} · templates/.git-hooks/pre-commit",
36
+ f"Fixtures: `{A}` · `{B}`.", f"Anchors: `{A}`, `{B}`",
37
+ f"Anchors: `{A}` and `{B}`", f"see `{A}` plus `{B}`",
38
+ f'"{A}" "{B}"']
39
+ sys.exit(0 if not any(m.asserts_relation(l,A,B) for l in lists) else 1)
40
+ PY
41
+
42
+ # L4 — 관계 주장은 잡는다 (한/영 둘 다 — 언어 중립 요구)
43
+ python3 - "$SCAN" <<'PY' && ok "L4 관계 포착(한·영)" || no "L4 관계 포착" "관계를 놓쳤다"
44
+ import importlib.util,sys
45
+ spec=importlib.util.spec_from_file_location("m",sys.argv[1]); m=importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(m)
47
+ A,B="scripts/foo.sh","scripts/bar.sh"
48
+ rel=[f"{A} 는 {B} 를 호출한다", f"`{A}` is wired into `{B}`", f"the hook {A} blocks by running {B}"]
49
+ sys.exit(0 if all(m.asserts_relation(l,A,B) for l in rel) else 1)
50
+ PY
51
+
52
+ # L5 — 🟥 실행 vs 언급. 실제로 뚫렸던 표기(grep 대안식 안의 `name)\.sh`)를 픽스처로 쓴다
53
+ TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
54
+ cat > "$TMP/hook_exec.sh" <<'EOF'
55
+ #!/usr/bin/env bash
56
+ bash "$REPO_ROOT/scripts/target_thing.sh" "$REPO_ROOT"
57
+ EOF
58
+ cat > "$TMP/hook_mention.sh" <<'EOF'
59
+ #!/usr/bin/env bash
60
+ # target_thing.sh is only named here, never run
61
+ git diff --name-only | grep -E '(scripts/(other|target_thing)\.sh)' || true
62
+ EOF
63
+ # 🟥 cross-family codex [A]: 출력 구문 안의 인터프리터 호출은 «실행처럼 보이지만» 아무것도 안 돌린다
64
+ cat > "$TMP/hook_echo.sh" <<'EOF'
65
+ #!/usr/bin/env bash
66
+ echo "run it yourself: bash scripts/target_thing.sh"
67
+ EOF
68
+ python3 - "$SCAN" "$TMP" <<'PY' && ok "L5 실행/언급 판별" || no "L5 실행/언급 판별" "둘을 못 가른다"
69
+ import importlib.util,sys,os
70
+ spec=importlib.util.spec_from_file_location("m",sys.argv[1]); m=importlib.util.module_from_spec(spec)
71
+ spec.loader.exec_module(m)
72
+ t=sys.argv[2]
73
+ v1,_=m.executes(os.path.join(t,"hook_exec.sh"),"target_thing.sh")
74
+ v2,_=m.executes(os.path.join(t,"hook_mention.sh"),"target_thing.sh")
75
+ v3,_=m.executes(os.path.join(t,"hook_echo.sh"),"target_thing.sh")
76
+ sys.exit(0 if (v1=="EXECUTES" and v2=="MENTIONS-ONLY" and v3=="MENTIONS-ONLY") else 1)
77
+ PY
78
+
79
+ # L6 — 🟥 보정 실패 시 «스캔을 거부»한다 (fail-closed). 뮤턴트로 확인하고 되돌린다
80
+ CP="$TMP/mutant.py"; cp "$SCAN" "$CP"
81
+ python3 - "$CP" <<'PY'
82
+ import sys
83
+ p=sys.argv[1]; L=open(p,encoding="utf-8").read().split("\n")
84
+ i=[k for k,l in enumerate(L) if l.startswith("def asserts_relation(")][0]
85
+ L.insert(i+1," return True # MUTANT: 모든 것을 주장이라 한다")
86
+ open(p,"w",encoding="utf-8").write("\n".join(L))
87
+ PY
88
+ MOUT=$(python3 "$CP" --selftest "$ROOT" 2>&1); MRC=$?
89
+ [ "$MRC" -eq 2 ] && ok "L6 뮤턴트 → rc=2 거부" || no "L6 뮤턴트 거부" "rc=$MRC (기대 2)"
90
+ MS=$(python3 "$CP" "$ROOT" 2>&1); MSRC=$?
91
+ [ "$MSRC" -eq 2 ] && ok "L6b 뮤턴트는 스캔도 거부" || no "L6b 뮤턴트 스캔거부" "rc=$MSRC (기대 2)"
92
+
93
+ # L8 — 🟥 known-pair 가 «낡으면» 보정이 실패한다 (cross-family codex [S]).
94
+ # B 가 삭제·이동돼도 A 가 이름만 언급하면 통과하던 자리.
95
+ cat > "$TMP/fakeroot_probe.py" <<'PY8'
96
+ import importlib.util,sys,os
97
+ spec=importlib.util.spec_from_file_location("m",sys.argv[1]); m=importlib.util.module_from_spec(spec)
98
+ spec.loader.exec_module(m)
99
+ sys.exit(0 if m.resolve(sys.argv[2], "scripts/zzq_nonexistent_target.sh") is None else 1)
100
+ PY8
101
+ python3 "$TMP/fakeroot_probe.py" "$SCAN" "$ROOT" && ok "L8 부재 대상은 resolve=None" || no "L8 resolve" "없는 파일을 찾았다고 했다"
102
+
103
+ # L7 — 🟥 «진짜» 되돌림: 원본 자리에 뮤턴트를 넣었다 뺀다. 빨강→초록 전이를 본다
104
+ cp "$SCAN" "$TMP/orig_backup.py"
105
+ python3 - "$SCAN" <<'PY7'
106
+ import sys
107
+ p=sys.argv[1]; L=open(p,encoding="utf-8").read().split("\n")
108
+ i=[k for k,l in enumerate(L) if l.startswith("def asserts_relation(")][0]
109
+ L.insert(i+1," return True # MUTANT-INPLACE")
110
+ open(p,"w",encoding="utf-8").write("\n".join(L))
111
+ PY7
112
+ python3 "$SCAN" --selftest "$ROOT" >/dev/null 2>&1; INJ=$?
113
+ cp "$TMP/orig_backup.py" "$SCAN"
114
+ python3 "$SCAN" --selftest "$ROOT" >/dev/null 2>&1; RST=$?
115
+ LEFT=$(grep -c "MUTANT-INPLACE" "$SCAN" || true)
116
+ if [ "$INJ" -eq 2 ] && [ "$RST" -eq 0 ] && [ "$LEFT" -eq 0 ]; then
117
+ ok "L7 되돌림 전이 (주입 rc=2 → 복원 rc=0, 잔존 0)"
118
+ else
119
+ no "L7 되돌림 전이" "주입rc=$INJ 복원rc=$RST 잔존=$LEFT"
120
+ fi
121
+
122
+ echo "── PASS=$PASS FAIL=$FAIL ──"
123
+ [ "$FAIL" -eq 0 ] || { echo "FAILED=1"; exit 1; }
124
+ echo "FAILED=0"