@chrono-meta/fh-gate 1.4.99 → 2.0.1

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/capabilities/adapters/gstack-content-safety.cap +110 -0
  2. package/.claude/capabilities/adapters/mate-agent-boundary.cap +110 -0
  3. package/.claude/capabilities/adapters/qasp-new-code-anchor.cap +116 -0
  4. package/.claude/capabilities/degrade-direction-scan.cap +24 -0
  5. package/.claude/capabilities/public-surface-scan.cap +24 -0
  6. package/.claude-plugin/marketplace.json +3 -3
  7. package/AGENTS.md +9 -3
  8. package/CHEATSHEET.md +17 -1
  9. package/CLAUDE.md +2 -2
  10. package/README.ja.md +55 -20
  11. package/README.ko.md +86 -20
  12. package/README.md +57 -20
  13. package/README.zh.md +49 -19
  14. package/knowledge/shared/harness-core/fh_three_layer_canon.md +96 -5
  15. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +36 -0
  16. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +1 -1
  17. package/knowledge/shared/harness-core/harness_terminal_correlation_and_recommendations.md +214 -0
  18. package/knowledge/shared/harness-core/harness_verification_core_extended.md +1 -1
  19. package/knowledge/shared/harness-core/ship_readiness_gate.md +29 -10
  20. package/knowledge/shared/learnings/subagent_invocations_log.yaml +73 -0
  21. package/package.json +13 -3
  22. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  23. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  24. package/plugins/fh-meta/CHANGELOG.md +45 -0
  25. package/scripts/adapters/gstack_content_safety.sh +186 -0
  26. package/scripts/adapters/mate_agent_boundary.sh +120 -0
  27. package/scripts/adapters/peer_resolve.sh +99 -0
  28. package/scripts/adapters/qasp_new_code_anchor.sh +159 -0
  29. package/scripts/branch_claim.sh +14 -0
  30. package/scripts/capability_effect_probe.sh +701 -0
  31. package/scripts/capability_registry_check.sh +313 -16
  32. package/scripts/cluster_capability_scan.sh +629 -0
  33. package/scripts/fh_session_load.sh +70 -0
  34. package/scripts/portability_lint.sh +257 -0
  35. package/scripts/publish_freshness_check.sh +204 -0
  36. package/scripts/relay_channel.sh +70 -7
  37. package/scripts/selfcheck.sh +166 -1
  38. package/scripts/test_adapter_lanes.sh +296 -0
  39. package/scripts/test_capability_entrypoint_shipping.sh +83 -6
  40. package/scripts/test_relay_channel_lanes.sh +76 -0
  41. package/templates/.git-hooks/pre-commit +14 -2
@@ -200,6 +200,76 @@ if [ -d "$_AUDIT_DIR" ] && fh_cadence_due "$_FH_HOOK_SRC"; then
200
200
  fi
201
201
  fi
202
202
 
203
+ # 1-c) harness-doctor cadence — 위 1-b 와 같은 이유로, 같은 훅에서, 한 블록 더.
204
+ # MEASURED 2026-08-16 (weekly_audit_2026-08-16.md 🟥-3): 직전 감사(07-31)가 «산문 캐던스 미이행»을
205
+ # N=2 로 집계하면서 **주간감사(50일)와 harness-doctor(당시 ~49일) 둘을 함께** 지목했다. 그 뒤 16일
206
+ # 동안 주간감사는 50→16일로 움직였고 — 그건 1-b 가 기계화돼 있기 때문이다 — harness-doctor 는
207
+ # **한 번도 돌지 않아 65일**이 됐다(캐던스 30). 즉 같은 세션이 같은 날 같은 문서에서 지목한 두
208
+ # 캐던스가, 하나는 훅이고 하나는 산문이라는 차이만으로 정반대로 갔다. 그것이 N=3 이고,
209
+ # `operations.md §Recurrence escalation` 은 그때 «새 습관 규칙이 아니라 계기»를 만들라고 한다.
210
+ # 1-b 와 동일하게 **advisory** 다: 밀린 진단은 비가역 표면이 아니므로 표면화하고 절대 막지 않는다.
211
+ #
212
+ # 🟥 not-found ≠ 0 — 이 블록이 1-b 와 다른 유일한 지점이다. 1-b 는 파일이 없으면 조용하고, 그건
213
+ # 갓 클론한 설치를 나그하지 않기 위해 옳다. 그러나 harness-doctor 는 **한 번도 안 돌린 상태가
214
+ # 실재하는 결함**이고(이 레포가 2026-06-12 이후 정확히 그 상태였다), 파일 부재를 침묵으로 렌더하면
215
+ # 「없음」과 「0일 경과」가 같은 출력이 된다 — 이 레포가 반복해 배운 그 클래스다. 그래서 부재를
216
+ # **다른 문장으로** 말하되, «이미 굴러가는 설치인가»를 `fh_completed_*.md` 존재로 판별해 갓
217
+ # 클론한 설치에는 여전히 침묵한다. 그 판별은 1-b 의 audit-dir 존재 검사와 같은 역할이다.
218
+ #
219
+ # 🟥 스코프와 정렬, 둘 다 위 1-b 를 그대로 베끼면 틀린다 — 적대검증 2026-08-16 이 둘 다 잡았다.
220
+ #
221
+ # (1) **노드-로컬만 보면 안 된다.** `tracks/` 는 gitignored 라 노드 간에 안 따라오고, 진단은
222
+ # `sync-to-be.sh` 로 `$BE/$TM` 에 실린다. 프로에서 돌리고 에어에서 세션을 열면 에어엔
223
+ # `fh_completed_*.md` 는 있고 진단 리포트만 없어서 **「기록 0건」이라는 거짓 단언**이 난다.
224
+ # 그리고 이건 1-b 보다 나쁘다: 1-b 는 부재를 침묵으로 렌더해 «틀린 말을 안 하는» 쪽으로
225
+ # degrade 하는데, 이 블록은 부재를 **큰 소리 단언으로 승격**시켰기 때문이다. 스코프가 틀린
226
+ # 채로 소리를 키우면 오보 비용도 같이 커진다. 바로 위 `_fd_ready()`(:129)가 이미 같은 버그를
227
+ # 한 번 겪고 합집합으로 고쳤다 — **같은 형태를 쓴다**(divergent-leniency 금지).
228
+ #
229
+ # (2) **`sort | tail -1` 은 사전순이지 시간순이 아니다.** 1-b 의 글롭 `weekly_audit_*.md` 는
230
+ # 접두어가 고정이라 사전순≈시간순이지만, 여기 글롭은 `*harness_doctor*.md` 로 **양쪽
231
+ # 와일드카드**다. `reference_harness_doctor_notes.md` 가 `harness_doctor_2026-08-16.md` 를
232
+ # 사전순으로 이기고, 그 파일의 mtime 이 나이가 된다 → 거짓 나그 또는 거짓 침묵. **mtime
233
+ # 최대값으로 뽑는다.** (`ls -t` 는 이식성 때문에 안 쓴다 — 이 커밋이 짓는 린트의 P-클래스다.)
234
+ _hd_latest() { # stdout: 가장 최근 리포트의 mtime (없으면 빈 문자열)
235
+ _best=""; _bestm=0
236
+ for _d in "$FH/tracks/_meta" ${BE:+"$BE/$TM"}; do
237
+ [ -d "$_d" ] || continue
238
+ while IFS= read -r _f; do
239
+ [ -n "$_f" ] || continue
240
+ _m=$(_mtime "$_f")
241
+ [ "$_m" -gt "$_bestm" ] && { _bestm="$_m"; _best="$_f"; }
242
+ done < <(find "$_d" -maxdepth 1 -name '*harness_doctor*.md' -print 2>/dev/null)
243
+ done
244
+ [ -n "$_best" ] && printf '%s\t%s\n' "$_bestm" "$_best"
245
+ }
246
+ _hd_has_history() { # «굴러가는 설치인가» — 이것도 합집합으로 본다
247
+ for _d in "$FH/tracks/_meta" ${BE:+"$BE/$TM"}; do
248
+ [ -d "$_d" ] || continue
249
+ find "$_d" -maxdepth 1 -name 'fh_completed_*.md' -print 2>/dev/null | grep -q . && return 0
250
+ done
251
+ return 1
252
+ }
253
+ _HD_DIR="$FH/tracks/_meta"
254
+ if [ -d "$_HD_DIR" ] && fh_cadence_due "$_FH_HOOK_SRC"; then
255
+ _HD_ROW="$(_hd_latest)"
256
+ _LATEST_HD="${_HD_ROW#* }"; _HD_MTIME="${_HD_ROW%% *}"
257
+ if [ -n "$_HD_ROW" ]; then
258
+ _HD_AGE_D=$(( ( $(date +%s) - _HD_MTIME ) / 86400 ))
259
+ if [ "$_HD_AGE_D" -ge 30 ]; then
260
+ echo "🩺 [harness-doctor] 마지막 구조 진단이 ${_HD_AGE_D}일 전이다($(basename "$_LATEST_HD")) — 캐던스는 30일."
261
+ echo " → /harness-doctor (구조·드리프트·끊긴 참조). --lint 는 언어 패턴까지."
262
+ fi
263
+ # `-print | grep -q` 이지 `-quit` 이 아니다: `-quit` 은 GNU/BSD 양쪽에 있으나 버전 편차가 있고,
264
+ # 이 파일이 고치는 결함군에 BSD/GNU 이식성이 들어 있다(감사 🟧-3). 최적화 한 톨을 위해 그 클래스를
265
+ # 새로 들이지 않는다 — 여기 모집단은 디렉터리 하나이므로 얻을 것도 없다.
266
+ elif _hd_has_history; then
267
+ # 세션 이력은 있는데 진단 기록이 0건 = «한 번도 안 돌렸다». 부재를 0 으로 읽지 않는다.
268
+ echo "🩺 [harness-doctor] 구조 진단 기록이 **0건**이다 — 캐던스 30일이 한 번도 발화한 적 없다."
269
+ echo " → /harness-doctor (첫 베이스라인). 부재는 «최신»이 아니다."
270
+ fi
271
+ fi
272
+
203
273
  # ── branch-claim 자동 기록 (2026-08-09) ────────────────────────────────────────
204
274
  # WHY HERE, ABOVE THE COMPANION-STORE EARLY EXIT: 공유 체크아웃 사고는 Mode D 와 무관하다.
205
275
  # companion store 가 없는 설치에서도 병렬 세션은 돌고, 그때도 `.git/HEAD` 는 트리당 하나다.
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env bash
2
+ # portability_lint.sh — 셸 이식성 · 조용한 즉사 · 레포전제 픽스처 린트 (advisory)
3
+ #
4
+ # 왜 이게 있나 (weekly_audit_2026-08-16 🟧-3):
5
+ # 이 레포의 재발 클래스 중 BSD/GNU · zsh/bash 이식성이 윈도우 16일에 ~12건인데
6
+ # **전용 계기가 없었고**, 잡힌 경로는 **전부 CI ubuntu 에서 사후**였다. 즉 저자 머신
7
+ # (macOS)에서는 구조적으로 초록이고, 로컬 초록 / CI 적색이 2026-08-16 하루에 2회 났다.
8
+ # 이 린트는 그 사각을 보라고 지어졌다.
9
+ #
10
+ # 🟥 배선 상태를 헤더가 정직하게 말한다 (적대검증 2026-08-16 이 초판 헤더를 «거짓말»로 지목):
11
+ # 초판 헤더는 "저자 머신에서 **커밋 전에** 한 번 본다" 고 적었는데, 실제 호출부는
12
+ # `selfcheck.sh` 의 `--self-test` **하나뿐**이다. 즉 **계기의 계기는 도는데 계기 자체는 안 돈다.**
13
+ # 제자리는 pre-commit 의 staged `*.sh` 한정 advisory 인데 아직 안 붙였다(별도 커밋 · 인계됨).
14
+ # 그때까지 본 실행은 **손으로 돌려야 한다**: `bash scripts/portability_lint.sh`.
15
+ # 이 문장을 «커밋 전에 본다»로 되돌리려면 **먼저 pre-commit 에 붙여라.** 헤더가 배선보다
16
+ # 앞서 나가는 것이 이 커밋이 고치고 있는 바로 그 결함이다.
17
+ #
18
+ # 무엇이 아닌가:
19
+ # · shellcheck 대체가 아니다. shellcheck 는 「성공하는 첫 명령」 패턴(P1)을 못 잡는다 —
20
+ # 문법이 아니라 **플랫폼별 의미 차이**라서다. 둘은 상보적이다.
21
+ # · 게이트가 아니다. **advisory** 다(exit 0 유지, `--strict` 로만 1). 이유는
22
+ # degrade_direction_scan 과 같다 — FP 를 감수하는 사전 선별기이고, 과차단은
23
+ # `--no-verify` 를 근육기억으로 만들어 같은 훅의 비가역 게이트까지 무장해제시킨다.
24
+ #
25
+ # Exit: 0 = 소견 없음 또는 advisory 모드 · 1 = --strict 에서 소견 有 · 10 = 하네스 오류
26
+ # Usage: bash scripts/portability_lint.sh [PATHS...] [--strict] [--quiet]
27
+ # bash scripts/portability_lint.sh --self-test
28
+
29
+ set -uo pipefail
30
+
31
+ STRICT=0; QUIET=0; SELFTEST=0; TARGETS=()
32
+ for a in "$@"; do
33
+ case "$a" in
34
+ --strict) STRICT=1 ;;
35
+ --quiet) QUIET=1 ;;
36
+ --self-test) SELFTEST=1 ;;
37
+ -*) echo "unknown flag: $a" >&2; exit 10 ;;
38
+ *) TARGETS+=("$a") ;;
39
+ esac
40
+ done
41
+
42
+ # ── 규칙표 ────────────────────────────────────────────────────────────────────
43
+ # 각 규칙 = ID§정규식§한 줄 사유§처방
44
+ #
45
+ # 🟥 구분자가 `|` 가 아니라 `§` 인 이유 — 이 린트의 self-test 가 초판에서 잡은 결함이다:
46
+ # `|` 를 필드 구분자로 쓰면 정규식 안의 **교대(alternation) `|`** 에서 잘린다.
47
+ # P1 이 `…=\$\((cat` 까지만 남아 known-positive 를 놓쳤다. 계기가 자기 문법에 걸린 것이고,
48
+ # known-pair 가 없었으면 «소견 0건»이 그냥 초록으로 보였을 것이다.
49
+ # 전부 **오늘 실물에서 나온 것**이거나 이 레포의 기록된 재발이다. 추측 규칙은 넣지 않는다 —
50
+ # FP 가 늘면 이 린트가 무시되고, 무시되는 린트는 없는 린트보다 나쁘다(경고 하나가 0건보다
51
+ # 나쁠 수 있다: 0건은 «아무도 안 봤다»로 남지만 1건은 «봤고 이것만 나왔다»로 접힌다).
52
+ RULES=(
53
+ 'P1§^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*=\$\((cat|sed|awk|grep|head|tail|python3?|node|jq)[^)]*\)§[후보] set -e 아래 명령치환 ASSIGNMENT — 파일 부재 시 그 줄에서 즉사. set -e 판별이 파일 스코프라 확정 아님§`|| true` 를 붙여라. 2>/dev/null 도 ${VAR:-기본값} 도 이걸 못 막는다(할당문 자체가 죽으므로). [ -f ] 가드도 present-but-unreadable arm 을 못 막는다 — 2026-08-16 4방 매트릭스가 반증' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
54
+ 'P2§stat -f §`stat -f` 가 먼저 오면 GNU 에서 **성공한다** — 파일시스템 정보를 내고 폴백이 영영 안 돈다§stat -c %Y … 2>/dev/null || stat -f %m … 2>/dev/null || echo 0 순서로. GNU-first 가 맞다' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
55
+ 'P3§sed -i[[:space:]]+[^-.]§`sed -i` 는 BSD 가 백업 접미사를 요구한다 — GNU 폼은 macOS 에서 다음 토큰을 파일명으로 먹는다§sed -i.bak 후 삭제, 또는 sed … > tmp && mv tmp f' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
56
+ 'P4§grep -P§`grep -P` 는 BSD/macOS 기본 grep 에 없다§grep -E 로 낮추거나 perl -ne 로' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
57
+ 'P5§\$\{PIPESTATUS§`${PIPESTATUS[…]}` 는 zsh 에서 빈 문자열이다 — 판정이 "" 로 접힌다§bash 전용이면 shebang 확인. 더 나은 형태: 파이프를 빼고 out=$(cmd); rc=$?' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
58
+ 'P6§readlink -f§`readlink -f` 는 구형 BSD 에 없다§cd "$(dirname "$f")" && pwd -P 조합으로' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
59
+ 'P7§date -d §`date -d` 는 GNU 전용 — BSD 는 date -v§epoch 로 계산하거나 두 폼을 || 로 잇되 GNU-first' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
60
+ 'P9§"(package\.json|bin/)§테스트가 **레포 고유 파일**을 픽스처로 참조한다 — 이식된 자산에서 전량 FAIL 한다§자기 픽스처를 mktemp 로 만들어 써라. 2026-08-16 PMH 이식에서 selfcheck 35 FAIL 의 원인' # portability-noqa: 규칙표 데이터이지 실행 코드가 아니다
61
+ )
62
+
63
+ _hits=0
64
+ _emit() { [ "$QUIET" = 1 ] || printf '%s\n' "$*"; }
65
+
66
+ lint_file() {
67
+ local f="$1" rule id re why fix line n
68
+ # 🟥 끊어진 심링크는 «소견 없음»이 아니라 **미측정**이다 (cross-family 2026-08-16).
69
+ if [ -L "$f" ] && [ ! -e "$f" ]; then
70
+ _emit " [UNMEASURED] $f — 끊어진 심링크. 읽지 못했다(초록이 아니다)."
71
+ return 0
72
+ fi
73
+ # 🟥 **로케일 fail-open — cross-family 가 양쪽 로케일을 실제로 돌려서 잡았다.**
74
+ # UTF-8 로케일에서 대상 줄에 비-UTF8 바이트가 있으면 `grep -nE` 가 **매치하지 않고**, 그
75
+ # 실패가 삼켜져 최종 「소견 없음」으로 접힌다. 같은 입력이 `LC_ALL=C` 에서는 검출됐다.
76
+ # 즉 «무매치»와 «해석 실패»가 구분되지 않는 fail-open 이고, 이 린트가 잡으라고 만들어진
77
+ # 클래스 그 자체다. 이 함수의 모든 매칭을 **바이트 기반으로 고정**한다.
78
+ local LC_ALL=C LANG=C
79
+ # P1 은 «set -e 계열이 켜져 있는 파일»에서만 의미가 있다. 아니면 FP 폭발.
80
+ # ⚠️ 이 판별은 **파일 스코프**라 정확하지 않다 (cross-family 실측): 호출되지 않는 함수 안의
81
+ # `set -e` 로도 켜지고, 뒤따르는 `set +e` 로 꺼지지 않으며, 부모가 `set -e` 상태로 source 하는
82
+ # 파일은 **놓친다**. 그래서 P1 은 «확정 결함»이 아니라 **후보**다 — 출력 문구도 그렇게 낮춘다.
83
+ local has_sete=0
84
+ grep -qE '^[[:space:]]*set -[a-z]*e' "$f" 2>/dev/null && has_sete=1
85
+ for rule in "${RULES[@]}"; do
86
+ id="${rule%%§*}"; rest="${rule#*§}"
87
+ re="${rest%%§*}"; rest="${rest#*§}"
88
+ why="${rest%%§*}"; fix="${rest#*§}"
89
+ case "$id" in P1) [ "$has_sete" = 1 ] || continue ;; esac
90
+ while IFS= read -r line; do
91
+ n="${line%%:*}"; body="${line#*:}"
92
+ # noqa 이탈구: 알고 쓴 경우를 위한 문서화된 억제(전부 차단하면 우회를 훈련시킨다)
93
+ # noqa 는 **사유가 있어야** 억제한다. 초판은 문자열 존재만 봐서 사유 없이도, 심지어
94
+ # 문자열 리터럴 안에서도 억제됐다 — advisory 인 지금은 경미하나 `--strict` 를 게이트로
95
+ # 승격하는 순간 무료 우회로가 된다.
96
+ printf '%s' "$body" | grep -qE '#[[:space:]]*portability-noqa:[[:space:]]*[^[:space:]]' && continue
97
+ # 주석 전용 줄은 소견이 아니다. 이 레포는 결함을 **주석으로 길게 설명하는** 관습이 있어서
98
+ # (그 설명이 문제 패턴을 그대로 인용한다) 이걸 안 빼면 FP 가 구조적으로 쌓인다.
99
+ # 손검증 2026-08-16: 초판 50건 중 8건이 정확히 이 형태였다.
100
+ case "$(printf '%s' "$body" | sed 's/^[[:space:]]*//')" in '#'*) continue ;; esac
101
+ # P2 는 **순서**가 결함이지 `stat -f` 의 존재가 아니다. GNU-first(`stat -c … || stat -f …`)는
102
+ # 이 레포의 올바른 관용구이고, 초판은 그걸 12건 중 대다수로 지적했다 — 즉 규칙이 처방을
103
+ # 위반으로 렌더했다. 손검증 한 건이 이걸 잡았다(`_mtime()` 두 곳).
104
+ case "$id" in
105
+ P2) case "$body" in *'stat -c'*'stat -f'*) continue ;; esac ;;
106
+ # P7 도 같은 형태다 — `date -d … || date -v…` 는 GNU-first 폴백이라 **올바른 관용구**다.
107
+ # P2 에서 한 번 겪고도 새 규칙에 같은 오류를 심었다(손검증이 두 번째로 잡음).
108
+ P7) case "$body" in *'date -d'*'date -v'*) continue ;; esac ;;
109
+ esac
110
+ # P1 의 처방이 이미 적용된 줄은 소견이 아니다. 이 이탈구가 없으면 린트가 **자기 처방을
111
+ # 따른 코드를 계속 지적**하고, 그러면 사람이 린트를 끄게 된다 — self-test 가 잡았다.
112
+ # ⚠️ `2>/dev/null` 이나 `${VAR:-기본값}` 은 이탈 사유가 **아니다**: 둘 다 할당문의
113
+ # 즉사를 막지 못한다(2026-08-16 4방 매트릭스). 여기서 인정하는 건 `|| true` 뿐이다.
114
+ case "$id" in
115
+ P1) case "$body" in *'|| true'*|*'||true'*) continue ;; esac ;;
116
+ esac
117
+ _emit " [$id] $f:$n"
118
+ _emit " $why"
119
+ _emit " → $fix"
120
+ _hits=$((_hits+1))
121
+ done < <(grep -nE "$re" "$f" 2>/dev/null)
122
+ done
123
+
124
+ # P8 — 가드 없는 glob 루프. 규칙표에 못 넣는다: **판정이 다음 줄에 있기 때문**이다.
125
+ # 줄 단위 정규식은 `for f in *.sh; do` 만 보고 바로 아래의 `[ -e "$f" ] || continue` 를
126
+ # 볼 수 없어서, 초판 self-test 가 known-negative 에서 오탐을 냈다(hits=1, expected 0).
127
+ # 계기의 시야가 한 줄이면 두 줄짜리 관용구를 영영 오판한다 — 그래서 여기만 창을 넓힌다.
128
+ while IFS= read -r line; do
129
+ n="${line%%:*}"; body="${line#*:}"
130
+ printf '%s' "$body" | grep -qE '#[[:space:]]*portability-noqa:[[:space:]]*[^[:space:]]' && continue
131
+ # 주석 전용 줄 제외 — RULES 루프엔 넣고 여기엔 안 넣어서, 이 블록이 **자기 자신을 설명하는
132
+ # 주석**을 소견으로 냈다(손검증이 잡음). 같은 이탈구는 두 루프에 **같이** 있어야 한다.
133
+ case "$(printf '%s' "$body" | sed 's/^[[:space:]]*//')" in '#'*) continue ;; esac
134
+ # 다음 2줄 안에 가드가 있으면 관용구를 지킨 것이다
135
+ if sed -n "$((n+1)),$((n+2))p" "$f" 2>/dev/null \
136
+ | grep -qE '\[ *-[efd] |\|\| *continue|nullglob'; then continue; fi
137
+ # 파일 어딘가에 nullglob 이 켜져 있으면 전역적으로 해결된 것이다
138
+ grep -qE '^[[:space:]]*shopt -s [a-z ]*nullglob' "$f" 2>/dev/null && continue
139
+ _emit " [P8] $f:$n"
140
+ _emit " glob 이 하나도 안 맞으면 **리터럴 문자열 자체**가 루프에 들어온다"
141
+ _emit " → 루프 첫 줄에 \`[ -e \"\$f\" ] || continue\`, 또는 shopt -s nullglob"
142
+ _hits=$((_hits+1))
143
+ done < <(grep -nE 'for [A-Za-z_][A-Za-z0-9_]* in [^;]*\*[^;)]*; *do' "$f" 2>/dev/null)
144
+ }
145
+
146
+ # ── self-test (known-pair) ────────────────────────────────────────────────────
147
+ # 이 린트가 «무엇을 못 잡는지»까지 고정한다. 컨트롤이 없으면 초록의 이유를 모른다.
148
+ if [ "$SELFTEST" = 1 ]; then
149
+ t=$(mktemp -d) || { echo "harness-error: mktemp"; exit 10; }
150
+ trap 'rm -rf "$t"' EXIT
151
+ pass=0; failn=0
152
+ _ck(){ # _ck <label> <expect-hit:0|1> <file>
153
+ _hits=0; QUIET=1; lint_file "$3"; QUIET=0
154
+ if { [ "$2" = 1 ] && [ "$_hits" -gt 0 ]; } || { [ "$2" = 0 ] && [ "$_hits" -eq 0 ]; }; then
155
+ echo " ✅ $1"; pass=$((pass+1))
156
+ else echo " ❌ $1 (hits=$_hits, expected-hit=$2)"; failn=$((failn+1)); fi
157
+ }
158
+ # P1 known-positive — 오늘 `scripts/fh-gate.sh` 의 VERSION 할당이 정확히 이 형태였다.
159
+ # ⚠️ 행번호를 적지 않는다: 초판은 `:37` 이라 적었는데 **같은 세션의 수리가 그걸 :52 로 밀었고**,
160
+ # Axis 3 이 그 stale 참조를 잡았다. 이 커밋이 지적하는 결함 클래스의 자기재현이라 이름으로만 건다.
161
+ printf 'set -euo pipefail\nV=$(sed -n s/x/y/p package.json 2>/dev/null | head -1)\n' > "$t/p1_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
162
+ _ck "P1 known-positive (set -e + 명령치환 할당)" 1 "$t/p1_pos.sh"
163
+ # P1 known-negative — || true 가 붙으면 안 잡아야 한다
164
+ printf 'set -euo pipefail\nV=$(sed -n s/x/y/p f 2>/dev/null | head -1 || true)\n' > "$t/p1_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
165
+ _ck "P1 known-negative (|| true 있음)" 0 "$t/p1_neg.sh"
166
+ # P1 컨트롤 — set -e 가 없으면 애초에 해당 없음
167
+ printf 'V=$(cat package.json)\n' > "$t/p1_nosete.sh"
168
+ _ck "P1 컨트롤 (set -e 없음 → 해당없음)" 0 "$t/p1_nosete.sh"
169
+ # P2 stat BSD-first
170
+ printf 'm=$(stat -f %%m "$1" || stat -c %%Y "$1")\n' > "$t/p2_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
171
+ _ck "P2 known-positive (stat -f 선행)" 1 "$t/p2_pos.sh"
172
+ printf 'm=$(stat -c %%Y "$1" 2>/dev/null || echo 0)\n' > "$t/p2_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
173
+ _ck "P2 known-negative (GNU-first)" 0 "$t/p2_neg.sh"
174
+ # P5 PIPESTATUS
175
+ printf 'cmd | tail -1\nrc=${PIPESTATUS[0]}\n' > "$t/p5_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
176
+ _ck "P5 known-positive (PIPESTATUS)" 1 "$t/p5_pos.sh"
177
+ # P8 unguarded glob
178
+ printf 'for f in scripts/*.sh; do\n echo "$f"\ndone\n' > "$t/p8_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
179
+ _ck "P8 known-positive (가드 없는 glob 루프)" 1 "$t/p8_pos.sh"
180
+ printf 'for f in scripts/*.sh; do\n [ -e "$f" ] || continue\ndone\n' > "$t/p8_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
181
+ _ck "P8 known-negative (continue 가드)" 0 "$t/p8_neg.sh"
182
+ # noqa 이탈구가 실제로 억제하나
183
+ printf 'set -e\nV=$(cat package.json) # portability-noqa: 이 레포 전용 도구다\n' > "$t/noqa.sh"
184
+ _ck "noqa 억제 동작" 0 "$t/noqa.sh"
185
+ # 🟥 P3·P4·P6·P7·P9 는 초판에 레인이 **하나도 없었다** — 적대검증이 잡았다. 레인 없는 규칙은
186
+ # 정규식에 오타를 내도 self-test 가 `10 pass / 0 fail` 그대로라, 판별력이 미측정이다.
187
+ printf 'sed -i s/a/b/ f\n' > "$t/p3_pos.sh"; _ck "P3 known-positive (sed -i GNU폼)" 1 "$t/p3_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
188
+ printf 'sed -i.bak s/a/b/ f\n' > "$t/p3_neg.sh"; _ck "P3 known-negative (접미사 있음)" 0 "$t/p3_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
189
+ printf 'grep -P "\\d+" f\n' > "$t/p4_pos.sh"; _ck "P4 known-positive (grep -P)" 1 "$t/p4_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
190
+ printf 'grep -E "[0-9]+" f\n' > "$t/p4_neg.sh"; _ck "P4 known-negative (grep -E)" 0 "$t/p4_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
191
+ printf 'p=$(readlink -f "$1")\n' > "$t/p6_pos.sh"; _ck "P6 known-positive (readlink -f)" 1 "$t/p6_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
192
+ printf 'p=$(cd "$(dirname "$1")" && pwd -P)\n' > "$t/p6_neg.sh"; _ck "P6 known-negative (pwd -P)" 0 "$t/p6_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
193
+ printf 'd=$(date -d @123 +%%s)\n' > "$t/p7_pos.sh"; _ck "P7 known-positive (date -d)" 1 "$t/p7_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
194
+ printf 'd=$(date +%%s)\n' > "$t/p7_neg.sh"; _ck "P7 known-negative (date +%s)" 0 "$t/p7_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
195
+ printf 'run_review "package.json"\n' > "$t/p9_pos.sh"; _ck "P9 known-positive (레포고유 픽스처)" 1 "$t/p9_pos.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
196
+ printf 'run_review "$t/fixture.json"\n' > "$t/p9_neg.sh"; _ck "P9 known-negative (자기 픽스처)" 0 "$t/p9_neg.sh" # portability-noqa: self-test 픽스처 — 일부러 위반형을 쓴다
197
+
198
+ # 🟥 로케일 fail-open — cross-family 가 실측으로 잡은 것. 비-UTF8 바이트가 섞인 줄을
199
+ # UTF-8 로케일에서 돌려도 검출돼야 한다(lint_file 이 LC_ALL=C 로 고정하므로).
200
+ printf 'set -e\nV=$(cat package.json)\n\xff\n' > "$t/nonutf.sh"
201
+ ( export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8; _ck "비-UTF8 바이트 (UTF-8 로케일에서도 검출)" 1 "$t/nonutf.sh" )
202
+
203
+ # 끊어진 심링크 — 조용한 «소견 없음» 이 아니라 UNMEASURED 로 나와야 한다
204
+ ln -s "$t/does_not_exist.sh" "$t/broken.sh" 2>/dev/null
205
+ _bo=$(QUIET=0; _hits=0; lint_file "$t/broken.sh"); case "$_bo" in
206
+ *UNMEASURED*) echo " ✅ 끊어진 심링크 → UNMEASURED"; pass=$((pass+1)) ;;
207
+ *) echo " ❌ 끊어진 심링크가 조용히 통과했다"; failn=$((failn+1)) ;;
208
+ esac
209
+
210
+ # 빈 파일 — 계기가 아무 데나 울리지 않는가
211
+ : > "$t/empty.sh"
212
+ _ck "빈 파일 (오탐 없음)" 0 "$t/empty.sh"
213
+ # 종결 verdict 한 줄 — selfcheck.sh 의 --self-test 루프가 이 문자열로 «실제로 돌았다»를 판정한다.
214
+ # 규약(그 루프의 주석): 이 줄은 **진짜 종결 판정**이어야 하고 한국어 테스트케이스 *제목*이면
215
+ # 안 된다. 제목이면 그 레인 이름 하나만 바꿔도 진짜 PASS 가 거짓 "dispatcher missing?" 으로
216
+ # 뒤집힌다(capability_registry_check 가 그 이유로 루프에서 빠졌다). 그래서 여기 딱 한 번,
217
+ # 요약 자리에서만 쓴다.
218
+ if [ "$failn" -eq 0 ]; then
219
+ echo "portability-lint 캘리브레이션 통과: $pass pass / 0 fail"
220
+ exit 0
221
+ fi
222
+ echo "portability-lint 캘리브레이션 실패: $pass pass / $failn fail"
223
+ exit 1
224
+ fi
225
+
226
+ # ── 본 실행 ───────────────────────────────────────────────────────────────────
227
+ if [ "${#TARGETS[@]}" -eq 0 ]; then
228
+ while IFS= read -r f; do TARGETS+=("$f"); done < <(
229
+ { git ls-files '*.sh' 'templates/.git-hooks/*' 'bin/*' 2>/dev/null \
230
+ || find . \( -name '*.sh' -o -path './templates/.git-hooks/*' -o -path './bin/*' \) \
231
+ -not -path './.git/*' 2>/dev/null; } )
232
+ fi
233
+ [ "${#TARGETS[@]}" -gt 0 ] || { _emit "portability-lint: 대상 파일 0 — 잴 것이 없다(초록이 아니라 미측정)"; exit 0; }
234
+
235
+ _emit "── portability-lint (${#TARGETS[@]} files) ──"
236
+ for f in "${TARGETS[@]}"; do
237
+ [ -f "$f" ] || { [ -L "$f" ] && lint_file "$f"; continue; }
238
+ # 🟥 확장자 없는 셸 파일을 넣었으므로(templates/.git-hooks/pre-commit·pre-push, bin/fh-*)
239
+ # 비-셸 파일(.js 등)을 걸러야 한다. 판별은 확장자가 아니라 **shebang** 이다.
240
+ # 초판은 `git ls-files '*.sh'` 뿐이라 **이 레포에서 BSD/GNU 결함이 가장 비싼 두 파일**
241
+ # (pre-commit·pre-push)을 구조적으로 못 봤다 — 그리고 같은 커밋의 selfcheck 주석이
242
+ # «첫 스캔이 확장자 없는 훅을 놓쳤다»고 적어 놓고서 새 계기에 같은 스코프를 다시 심었다.
243
+ case "$f" in
244
+ *.sh) : ;;
245
+ *) head -1 "$f" 2>/dev/null | grep -qE '^#!.*\b(ba)?sh' || continue ;;
246
+ esac
247
+ lint_file "$f"
248
+ done
249
+
250
+ if [ "$_hits" -eq 0 ]; then
251
+ _emit " 소견 없음 (${#TARGETS[@]} files 스캔)"
252
+ exit 0
253
+ fi
254
+ _emit "── 소견 $_hits 건 ──"
255
+ _emit " advisory 다 — 이 린트만으로 막지 않는다. 알고 쓰는 줄엔 \`# portability-noqa: <사유>\`."
256
+ [ "$STRICT" = 1 ] && exit 1
257
+ exit 0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bash
2
+ # publish_freshness_check.sh — «지금 팩되는 트리가 커밋된 통합 브랜치인가».
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────
5
+ # 왜 지어졌나 — 같은 표면에서 두 번 터졌고, 둘 다 «출하 후»에 알았다
6
+ # ─────────────────────────────────────────────────────────────────────────────
7
+ # `npm publish` 는 **커밋이 아니라 워킹트리를 팩한다.** 그 한 줄이 두 사고를 만들었다:
8
+ #
9
+ # 2026-08-13 6세션 공유 체크아웃에서 publish → **남의 미커밋 초안이 출하됐다.**
10
+ # 2026-08-16 main 에 머지된 M6 가 tarball 에 없었다 — 출하한 트리가 그 커밋 이전이었다.
11
+ # 소비자가 받은 registry_check 에는 `M6` 가 **0회** 등장한다.
12
+ #
13
+ # 둘 다 «출하물이 main 보다 뒤처지거나 앞서는» 한 클래스다. 기존 publish 게이트
14
+ # (`version_lockstep_check` · `selfcheck` · `package_coverage_check --vs-tarball` ·
15
+ # `public_surface_scan_files`)는 전부 **파일 내용**을 본다 — 그 내용이 **어느 커밋의 것인가**를
16
+ # 묻는 검사는 하나도 없었다. 그래서 이 파일은 기존 검사의 중복이 아니다.
17
+ #
18
+ # ─────────────────────────────────────────────────────────────────────────────
19
+ # 무엇을 보증하지 않는가 (과잉주장 금지)
20
+ # ─────────────────────────────────────────────────────────────────────────────
21
+ # · **내용이 옳다는 보증이 아니다.** «커밋됐고 origin 과 같다» 만 본다.
22
+ # · **gitignored 파일은 못 본다.** `git status` 가 안 보므로 dirty 로 안 잡힌다.
23
+ # 다만 gitignored 파일은 `files[]` 로 출하되지 않는 것이 정상이고, 그 축은
24
+ # `package_coverage_check --vs-tarball` 이 본다.
25
+ # · **원격이 그 사이 움직이는 것은 못 막는다.** fetch 시점의 스냅샷이다.
26
+ #
27
+ # 비가역 표면(publish)이므로 **fail-CLOSED**: 판정할 수 없으면 막는다.
28
+ # 명시적 우회는 `PUBLISH_FRESHNESS_OK=1` — 로그를 남긴다.
29
+ #
30
+ # Usage: bash scripts/publish_freshness_check.sh [--self-test]
31
+ # Exit: 0 = 출하해도 되는 상태 · 1 = 막힘 · 2 = 판정 불가(역시 막힘)
32
+ set -uo pipefail
33
+
34
+ INTEGRATION_BRANCH="${FH_INTEGRATION_BRANCH:-main}"
35
+ pass=0; fail=0
36
+ ok() { printf ' \342\234\205 %s\n' "$1"; pass=$((pass+1)); }
37
+ bad() { printf ' \342\235\214 %s\n' "$1"; fail=$((fail+1)); }
38
+
39
+ run_checks() {
40
+ echo "publish freshness check (branch=$INTEGRATION_BRANCH)"
41
+
42
+ git rev-parse --git-dir >/dev/null 2>&1 || {
43
+ echo " INSTRUMENT ERROR: not a git repository — freshness cannot be judged. NOT a pass."
44
+ return 2; }
45
+
46
+ # ── ① 워킹트리가 깨끗한가 ────────────────────────────────────────────────
47
+ # `--porcelain` 은 추적 파일의 수정·스테이징·미추적을 전부 낸다. 미추적까지 세는 것은
48
+ # 의도적이다 — 2026-08-13 사고는 **남의 미커밋 파일**이 팩된 것이었다.
49
+ local dirty
50
+ dirty=$(git status --porcelain 2>/dev/null | grep -vE '^\?\? tracks/' || true)
51
+ if [ -n "$dirty" ]; then
52
+ bad "워킹트리가 깨끗하지 않다 — publish 는 커밋이 아니라 이 트리를 팩한다:"
53
+ printf '%s\n' "$dirty" | head -10 | sed 's/^/ /'
54
+ local n; n=$(printf '%s\n' "$dirty" | grep -c . || true)
55
+ [ "${n:-0}" -gt 10 ] && echo " … 외 $(( n - 10 ))건"
56
+ else
57
+ ok "워킹트리 깨끗 (tracks/ 미추적은 gitignored 운영 산출물이라 제외)"
58
+ fi
59
+
60
+ # ── ② 통합 브랜치 위인가 ────────────────────────────────────────────────
61
+ local head_branch
62
+ head_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
63
+ if [ "$head_branch" = "$INTEGRATION_BRANCH" ]; then
64
+ ok "HEAD 가 $INTEGRATION_BRANCH"
65
+ else
66
+ bad "HEAD 가 '$head_branch' — 통합 브랜치('$INTEGRATION_BRANCH')가 아니다. 피처 브랜치의 트리가 출하된다"
67
+ fi
68
+
69
+ # ── ③ origin 과 같은 커밋인가 ────────────────────────────────────────────
70
+ # 앞서든 뒤처지든 둘 다 막는다. **뒤처짐**이 2026-08-16 사고(머지된 M6 미출하)이고,
71
+ # **앞섬**은 «리뷰를 안 거친 로컬 커밋이 출하되는» 반대편 사고다. 방향이 아니라
72
+ # 불일치 자체가 판정 대상이다.
73
+ git fetch -q origin "$INTEGRATION_BRANCH" 2>/dev/null || {
74
+ bad "origin/$INTEGRATION_BRANCH 를 fetch 할 수 없다 — 신선도를 **못 쟀다**. 비가역 표면이므로 막는다"
75
+ return 1; }
76
+ local local_sha remote_sha
77
+ local_sha=$(git rev-parse HEAD 2>/dev/null || echo "")
78
+ remote_sha=$(git rev-parse "origin/$INTEGRATION_BRANCH" 2>/dev/null || echo "")
79
+ if [ -z "$local_sha" ] || [ -z "$remote_sha" ]; then
80
+ bad "커밋 해시를 읽을 수 없다 — 판정 불가"
81
+ elif [ "$local_sha" = "$remote_sha" ]; then
82
+ ok "HEAD == origin/$INTEGRATION_BRANCH (${local_sha:0:7})"
83
+ else
84
+ local ahead behind
85
+ ahead=$(git rev-list --count "origin/$INTEGRATION_BRANCH..HEAD" 2>/dev/null || echo "?")
86
+ behind=$(git rev-list --count "HEAD..origin/$INTEGRATION_BRANCH" 2>/dev/null || echo "?")
87
+ bad "HEAD(${local_sha:0:7}) != origin/$INTEGRATION_BRANCH(${remote_sha:0:7}) — ahead $ahead · behind $behind. behind 면 머지된 변경이 출하에서 빠진다(2026-08-16 사고 형태)"
88
+ fi
89
+
90
+ echo "----"
91
+ echo "publish freshness: $pass passed, $fail failed"
92
+ [ "$fail" -eq 0 ] || return 1
93
+ return 0
94
+ }
95
+
96
+ # ── self-test — known-pair. «막는가» 가 아니라 «옳게 막는가» 를 본다 ────────
97
+ self_test() {
98
+ local T rc sp=0 sf=0
99
+ T=$(mktemp -d)
100
+ # $4(선택) = 출력에 반드시 있어야 할 문자열 = **차단 귀속**. rc 만 보는 레인은 «막혔는가» 만
101
+ # 재고 «옳은 축이 막았는가» 를 못 잰다 — 오늘 이 레포에서 실제로, 사고 재현 픽스처가 엉뚱한
102
+ # 축(M1)에 걸려 통과한 적이 있다. 기대 축의 문자열까지 확인해야 «막았다» 가 증거가 된다.
103
+ _lane() { # $1=이름 $2=기대rc $3=실제rc $4=기대문자열(선택) $5=출력
104
+ local why=""
105
+ if [ -n "${4:-}" ] && ! printf '%s' "${5:-}" | grep -q -- "$4"; then
106
+ why=" — rc 는 맞지만 귀속이 틀렸다: '$4' 가 출력에 없다"
107
+ fi
108
+ if [ "$3" = "$2" ] && [ -z "$why" ]; then sp=$((sp+1)); printf ' ✅ %-40s rc=%s\n' "$1" "$3"
109
+ else sf=$((sf+1)); printf ' ❌ %-40s rc=%s (기대 %s)%s\n' "$1" "$3" "$2" "$why"; fi; }
110
+
111
+ echo "publish_freshness_check --self-test"
112
+
113
+ # 격리된 known-pair 레포를 만든다. 이 레포 자신을 대상으로 삼으면 «오늘 이 트리가 어떤
114
+ # 상태인가» 에 따라 결과가 바뀌어 레인이 계기가 아니라 날씨가 된다.
115
+ local UP="$T/upstream" WK="$T/work"
116
+ git init -q --bare "$UP"
117
+ # HEAD 를 명시적으로 main 에 건다. `-b main` 은 git>=2.28 전용이라 안 쓰고 symbolic-ref 로 건다.
118
+ # 🟥 이 두 줄이 없으면 CI 에서만 깨진다(실측 2026-08-16): 이 머신은 전역
119
+ # `init.defaultBranch=main` 이라 bare 의 HEAD 가 main 이지만, 러너는 보통 설정이 없어
120
+ # HEAD 가 `master` 를 가리킨다. 그러면 아래 `git clone` 이 **존재하지 않는 브랜치를 가리키는
121
+ # HEAD** 를 만나 체크아웃 없이 끝나고, 이어지는 커밋·푸시가 전부 실패하며, origin 이 전진하지
122
+ # 않아 「뒤처짐」 arm 이 **rc=0 으로 조용히 통과**한다. 계기가 환경에 따라 다른 걸 재고 있었다.
123
+ git -C "$UP" symbolic-ref HEAD refs/heads/main
124
+ git init -q "$WK"
125
+ ( cd "$WK"
126
+ git config user.email t@t; git config user.name t
127
+ echo one > a.txt; git add a.txt; git commit -qm one
128
+ git branch -M main; git remote add origin "$UP"; git push -q -u origin main ) >/dev/null 2>&1
129
+ # 셋업이 실제로 섰는지 **단언한다.** 위 블록은 출력을 버리므로 실패해도 조용하고, 그러면
130
+ # 뒤따르는 모든 arm 이 «검사기가 통과시켰다» 가 아니라 «검사할 게 없었다» 로 초록이 된다
131
+ # ([[feedback_absence_measurement_needs_control]] — 부재 측정엔 컨트롤을 동반한다).
132
+ if ! git -C "$UP" rev-parse --verify -q refs/heads/main >/dev/null; then
133
+ sf=$((sf+1)); echo " ❌ SETUP 실패 — upstream 에 main 이 서지 않았다. 아래 레인들은 무의미하다"
134
+ rm -rf "$T"; echo "── publish_freshness 캘리브레이션 실패: $sp PASS / $sf FAIL ──"; return 1
135
+ fi
136
+
137
+ # ── known-negative: 깨끗 · main · origin 과 동일 → 통과해야 한다 ──────────
138
+ o=$( cd "$WK" && bash "$SELF" 2>&1 ); rc=$?
139
+ _lane "깨끗한 main 은 통과" 0 "$rc" "" "$o"
140
+
141
+ # ── known-positive A: 더러운 트리 ────────────────────────────────────────
142
+ o=$( cd "$WK" && echo dirty > b.txt && bash "$SELF" 2>&1 ); rc=$?
143
+ _lane "미추적 파일이 있으면 막는다" 1 "$rc" "워킹트리가 깨끗하지 않다" "$o"
144
+ rm -f "$WK/b.txt"
145
+
146
+ # ── known-positive B: 피처 브랜치 ────────────────────────────────────────
147
+ o=$( cd "$WK" && git switch -q -c feat/x && bash "$SELF" 2>&1 ); rc=$?
148
+ _lane "피처 브랜치에서는 막는다" 1 "$rc" "통합 브랜치" "$o"
149
+
150
+ # ── known-positive C: main 이 origin 보다 뒤처짐 (2026-08-16 사고 형태) ──
151
+ # 다른 클론에서 커밋을 올려 origin 을 전진시킨 뒤, 원래 워킹트리에서 검사한다.
152
+ ( cd "$T" && git clone -q "$UP" other && cd other
153
+ git config user.email t@t; git config user.name t
154
+ git checkout -q -B main origin/main
155
+ echo two > c.txt; git add c.txt; git commit -qm two; git push -q origin main ) >/dev/null 2>&1
156
+ # 같은 이유로 **전진했는지 단언한다**. 이 arm 은 이 파일이 존재하는 이유(08-16 사고 형태)라
157
+ # 조용히 통과하면 정확히 그 사고를 못 잡는 상태로 돌아간다.
158
+ if [ "$(git -C "$UP" rev-list --count refs/heads/main)" -lt 2 ]; then
159
+ sf=$((sf+1)); echo " ❌ SETUP 실패 — origin 이 전진하지 않았다. 「뒤처짐」 arm 을 실행할 수 없다"
160
+ rm -rf "$T"; echo "── publish_freshness 캘리브레이션 실패: $sp PASS / $sf FAIL ──"; return 1
161
+ fi
162
+ o=$( cd "$WK" && git switch -q main && bash "$SELF" 2>&1 ); rc=$?
163
+ _lane "origin 보다 뒤처지면 막는다(08-16 사고형)" 1 "$rc" "behind" "$o"
164
+
165
+ # ── 컨트롤: 따라잡으면 다시 통과해야 한다 (전부 막는 계기가 아님) ────────
166
+ o=$( cd "$WK" && git pull -q --ff-only origin main && bash "$SELF" 2>&1 ); rc=$?
167
+ _lane "따라잡으면 다시 통과 (컨트롤)" 0 "$rc" "" "$o"
168
+
169
+ rm -rf "$T"
170
+ # 이 줄은 `selfcheck.sh` 의 embedded --self-test 루프가 «실행이 실제로 일어났다» 를 판정하는
171
+ # 앵커다(`*캘리브레이션*` 매치). 종단 판정줄이지 테스트 케이스 제목이 아니다 — 그 구분이
172
+ # 중요한 이유는 `capability_registry_check` 가 정확히 그 이유로 이 루프에서 빠져 있기 때문이다
173
+ # (그쪽의 캘리브레이션은 한국어 테스트 제목 안에만 있어서, 제목을 바꾸면 진짜 PASS 가
174
+ # «디스패처 없음» 으로 뒤집힌다).
175
+ echo "── publish_freshness 캘리브레이션 $([ "$sf" -eq 0 ] && echo 통과 || echo 실패): $sp PASS / $sf FAIL ──"
176
+ [ "$sf" -eq 0 ]
177
+ }
178
+
179
+ SELF="$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/$(basename "${BASH_SOURCE[0]:-$0}")"
180
+
181
+ case "${1:-}" in
182
+ --self-test) self_test; exit $?;;
183
+ esac
184
+
185
+ if [ -n "${PUBLISH_FRESHNESS_OK:-}" ]; then
186
+ echo "⚠️ publish-freshness: PUBLISH_FRESHNESS_OK=1 — 명시적 우회. 무엇을 우회했는지 아래에 남긴다:"
187
+ run_checks || true
188
+ echo "⚠️ 우회하고 진행한다. 이 줄이 그 기록이다."
189
+ exit 0
190
+ fi
191
+
192
+ run_checks
193
+ rc=$?
194
+ if [ "$rc" -ne 0 ]; then
195
+ cat <<'EOF'
196
+
197
+ 🚫 publish 중단 — 지금 팩되는 트리가 «커밋된 통합 브랜치» 가 아니다.
198
+ publish 는 커밋이 아니라 워킹트리를 팩한다. 이 상태로 내보내면 출하물과 main 이 갈린다.
199
+
200
+ ▸ 정상 경로 : 변경을 커밋 → PR → 머지 → main 에서 pull → 다시 publish
201
+ ▸ 알고도 강행 : PUBLISH_FRESHNESS_OK=1 npm publish (무엇을 우회했는지 출력에 남는다)
202
+ EOF
203
+ fi
204
+ exit "$rc"