@chrono-meta/fh-gate 1.4.98 → 2.0.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.
@@ -66,9 +66,94 @@
66
66
  #
67
67
  # "N/A" is printed as N/A, never as PASS — an unmeasured surface is not a clean one.
68
68
  #
69
- # Usage: bash scripts/consent_registry_check.sh [registry.yaml] [uap.md-or-yaml]
69
+ # Usage: bash scripts/consent_registry_check.sh [--require-class NAME] [registry.yaml] [uap.md-or-yaml]
70
+ #
71
+ # `--require-class NAME` narrows the verdict from FILE-WIDE to ONE CLASS, and a caller acting on
72
+ # behalf of a single class MUST use it. Without it, exit 0 means "the registry and the grants are
73
+ # well-formed and the floor join holds" — a property of the FILE. A caller that reads that 0 as
74
+ # "my class is granted" is wrong whenever any OTHER class is validly granted.
75
+ #
76
+ # That is not hypothetical. Measured 2026-08-15 with a live control: fh_node_check.sh gated its
77
+ # auto-fast-forward on this script's file-wide 0 AND a raw `grep` for the class name anywhere in the
78
+ # UAP. With one unrelated class validly granted and `repo-freshness-autopull` appearing only as a
79
+ # prose line saying it had been REVOKED, both conditions passed and the merge ran — while the banner
80
+ # told the operator it was acting on a standing consent that did not exist. The control (a real
81
+ # grant for the class) also returned 0, so the two states were indistinguishable through that channel.
82
+ # With `--require-class` the same pair separates: 0 for the real grant, 3 for the revoked one.
70
83
  set -uo pipefail
71
84
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
85
+ # Flag parsing, hardened by a cross-family round on the first draft. Three defects it found, all
86
+ # reproduced, all fail-OPEN — the flag would appear to be in force while the verdict stayed file-wide:
87
+ # · `--require-class ' '` passed the `-z` test (non-empty), then Python's `.strip()` reduced it
88
+ # to "" and the require-class branch silently switched off.
89
+ # · the flag was read only at argv position 1, so `... reg.yaml uap.md --require-class NAME` was
90
+ # ignored without a word and returned the old 0.
91
+ # · an unknown flag was consumed as a path.
92
+ # A gate whose ON switch can be silently OFF is worse than no gate, so all three now fail closed.
93
+ FH_REQUIRE_CLASS=""
94
+ FH_REQUIRE_CLASS_SET=""
95
+ _pos=()
96
+ # `_norm_class` TRIMS the ends. It does NOT delete interior whitespace, and the difference is the
97
+ # whole point: a third round measured `--require-class 'repo-freshness-auto pull'` being squeezed to
98
+ # `repo-freshness-autopull` and returning 0 — i.e. asking about one class and being told about a
99
+ # DIFFERENT one. A name that still contains whitespace after trimming is refused rather than
100
+ # repaired, because silently repairing an identity is how the wrong class gets authorized.
101
+ _norm_class() {
102
+ local v="$1"
103
+ v="${v#"${v%%[![:space:]]*}"}" # strip leading
104
+ v="${v%"${v##*[![:space:]]}"}" # strip trailing
105
+ printf '%s' "$v"
106
+ }
107
+ _set_require_class() {
108
+ FH_REQUIRE_CLASS_SET=1
109
+ FH_REQUIRE_CLASS="$(_norm_class "$1")"
110
+ if [ -z "$FH_REQUIRE_CLASS" ]; then
111
+ echo "consent-registry: FAIL — --require-class given with an empty or whitespace-only class name; fail-closed" >&2
112
+ exit 1
113
+ fi
114
+ case "$FH_REQUIRE_CLASS" in
115
+ *[[:space:]]*)
116
+ echo "consent-registry: FAIL — class name '$FH_REQUIRE_CLASS' contains whitespace; a class identity is not normalised for you (fail-closed)" >&2
117
+ exit 1 ;;
118
+ esac
119
+ # 🟥 NAMED RESIDUAL — invisible and normalisation-confusable identities are still ACCEPTED.
120
+ # Round 4 measured it: a class whose name carries a zero-width joiner is admitted as an identity,
121
+ # and `unicodedata` is applied to the effect vocabulary but never to class names or grant keys.
122
+ # Why it is carried rather than closed here, stated so the next reader does not have to re-derive
123
+ # it: the same round's controls showed the failure direction is SAFE — `ok` vs `o<ZWJ>k` returns 3,
124
+ # and an NFC/NFD mismatch returns 3, so no confusable name FALSELY joins a real grant; the residue
125
+ # is that a weird name can be its own consistent identity. Exploiting that requires write access to
126
+ # BOTH the registry and the UAP, which are the consent source of truth — anyone holding those has
127
+ # already granted themselves whatever they wanted, so this is hygiene, not escalation.
128
+ # The right fix is NFC-normalising both sides and refusing Unicode format/control characters, NOT
129
+ # a blanket non-ASCII refusal: this harness is language-agnostic and a Korean class name is
130
+ # legitimate. That is a design decision with its own known-pair, not a line to add during a release.
131
+ }
132
+ # Positionals go into a bash ARRAY. The first draft accumulated them into a newline-delimited string
133
+ # and rebuilt "$@" with `read` — measured not to be argv-preserving: a path containing a newline
134
+ # split into two, and an empty positional was dropped, which silently promoted the UAP path into the
135
+ # registry slot. This file is `#!/usr/bin/env bash`, so an array is available and exact.
136
+ while [ "$#" -gt 0 ]; do
137
+ case "$1" in
138
+ --require-class)
139
+ if [ "$#" -lt 2 ]; then
140
+ echo "consent-registry: FAIL — --require-class given with no class name; fail-closed" >&2
141
+ exit 1
142
+ fi
143
+ _set_require_class "$2"; shift 2 ;;
144
+ --require-class=*)
145
+ _set_require_class "${1#--require-class=}"; shift ;;
146
+ --)
147
+ shift; while [ "$#" -gt 0 ]; do _pos+=("$1"); shift; done ;;
148
+ --*)
149
+ echo "consent-registry: FAIL — unknown option '$1'; fail-closed rather than treating it as a path" >&2
150
+ exit 1 ;;
151
+ *)
152
+ _pos+=("$1"); shift ;;
153
+ esac
154
+ done
155
+ set -- ${_pos[@]+"${_pos[@]}"}
156
+ export FH_REQUIRE_CLASS FH_REQUIRE_CLASS_SET
72
157
  REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
73
158
  UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
74
159
 
@@ -474,6 +559,16 @@ else:
474
559
  fails += 1; grants = None
475
560
 
476
561
  no_active_grant = False
562
+ # Names that survived EVERY per-grant check. `--require-class` joins against this set, never against
563
+ # `validated` (a count that includes names which then failed) and never against the file-wide verdict.
564
+ clean_grants = set()
565
+ REQUIRE_CLASS = os.environ.get("FH_REQUIRE_CLASS", "").strip()
566
+ # "was the flag given" is tracked separately from "is the name non-empty". Collapsing them is how
567
+ # the first draft turned a whitespace-only name into a silent fall-back to the file-wide verdict.
568
+ REQUIRE_SET = os.environ.get("FH_REQUIRE_CLASS_SET", "") == "1"
569
+ if REQUIRE_SET and not REQUIRE_CLASS:
570
+ print("consent-registry: FAIL — --require-class resolved to an empty class name; fail-closed")
571
+ sys.exit(1)
477
572
  if grants is None:
478
573
  pass
479
574
  elif not grants:
@@ -522,6 +617,12 @@ else:
522
617
  if st.strip().lower() in NON_GRANT:
523
618
  continue
524
619
  validated += 1
620
+ # Failure count at the START of this grant's checks. A name joins `clean_grants` at the end
621
+ # of the body only if nothing was recorded against it in between — `validated` cannot serve
622
+ # that purpose, because it is incremented HERE and every failing branch below still counted.
623
+ # Each of those branches also `continue`s after `fails += 1`, so a failing name never reaches
624
+ # the add; this counter covers the non-continuing ones.
625
+ _f0 = fails
525
626
  c = by_name.get(name)
526
627
  if c is None:
527
628
  out("❌", f"R3 `{name}` granted but NOT in the registry (unregistered == unknown)"); fails += 1; continue
@@ -669,6 +770,8 @@ else:
669
770
  out("❌", f"R7 `{name}` grant target {tgt!r} does not match the registered target "
670
771
  f"{c['target']!r} — scope drift between grant and class")
671
772
  fails += 1
773
+ if fails == _f0:
774
+ clean_grants.add(name)
672
775
  skipped = len(grants) - validated
673
776
  if validated == 0:
674
777
  # Same state as the empty-grants branch above, reached differently: every key present was
@@ -682,6 +785,11 @@ else:
682
785
  out("✅", f"R3-R6 all {validated} active grant(s) registered, eligible, unexpired, "
683
786
  f"scope-recorded{note}")
684
787
 
788
+ # The class-scoped verdict is decided BEFORE the summary, so the summary can agree with it. The
789
+ # first draft printed it after, which produced `consent-registry: PASS` on a run that then exited 3
790
+ # for the requested class — the exact contradiction this file's own comment below calls a false
791
+ # green with extra steps (cross-family, 2026-08-16).
792
+ _class_missing = bool(REQUIRE_CLASS) and not fails and REQUIRE_CLASS not in clean_grants
685
793
  print("----")
686
794
  # The human-facing summary must agree with the typed exit. It previously printed PASS on a run whose
687
795
  # own line above said "nothing granted, keep asking (not a PASS)" and whose exit code was 3 — so an
@@ -690,8 +798,15 @@ print("----")
690
798
  # false green with extra steps. (Caught by hand 2026-08-02 while verifying the exit-3 fix.)
691
799
  if fails:
692
800
  print(f"consent-registry: {fails} violation(s)")
801
+ elif _class_missing:
802
+ seen = "none" if not clean_grants else ", ".join(sorted(clean_grants))
803
+ print(f"consent-registry: UNMEASURED for class `{REQUIRE_CLASS}` — nothing granted for it, "
804
+ f"keep asking (exit 3). Active grants that DID join: {seen}. A grant for another class, "
805
+ f"or the name merely appearing in the file, is not consent for this one.")
693
806
  elif no_active_grant:
694
807
  print("consent-registry: UNMEASURED — nothing granted, keep asking (exit 3)")
808
+ elif REQUIRE_CLASS:
809
+ print(f"consent-registry: PASS for class `{REQUIRE_CLASS}` (active, registered, unexpired)")
695
810
  else:
696
811
  print("consent-registry: PASS")
697
812
  # BROKEN outranks UNMEASURED: a violation is a decided negative, "nothing granted" is merely nothing
@@ -703,5 +818,13 @@ if ZERO_CLASSES and not fails:
703
818
  print("consent-registry: FAIL — a standing grant exists but the registry declares zero "
704
819
  "classes; every such grant is UNREGISTERED (R3), which is BROKEN, not unmeasured")
705
820
  sys.exit(1)
821
+ # ── --require-class: narrow the verdict to ONE class ───────────────────────────────────────────
822
+ # Ordering is deliberate and matches the existing precedence: BROKEN (1) outranks everything, so a
823
+ # violation anywhere still exits 1 even when the required class itself looks fine — an unparseable
824
+ # neighbour means the file could not be decided, and "cannot decide == not allowed" is this script's
825
+ # own rule. Below that, a required class that did not join is UNMEASURED (3), the same code as
826
+ # "nothing granted", because to the caller they are the same instruction: KEEP ASKING.
827
+ if _class_missing:
828
+ sys.exit(3)
706
829
  sys.exit(1 if fails else (3 if no_active_grant else 0))
707
830
  PY
@@ -199,7 +199,75 @@ if git -C "$FH" rev-parse --git-dir >/dev/null 2>&1 && git -C "$FH" remote get-u
199
199
  case "$_BEHIND" in
200
200
  ''|*[!0-9]*) : ;; # not measurable — silent, not a claim
201
201
  0) : ;;
202
- *) GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH is ${_BEHIND} commit(s) behind origin/$_DEFAULT_BRANCH — run: git checkout $_DEFAULT_BRANCH && git pull --ff-only (or: git merge --ff-only origin/$_DEFAULT_BRANCH)" ;;
202
+ *)
203
+ # ── auto-apply, consent-gated, inside a deliberately narrow envelope ───────────────
204
+ # Operator request 2026-08-15, verbatim: "사람이 일일이 수동으로 깃풀해서 최신화해야하는지를
205
+ # 판단하지않고 … 세션 시작 시 레포체크를 클로드가 알아서 하고 최신화 제안하는 기능이 있으면
206
+ # 좋을것같아. 그리고 앞으로도 자동으로 이렇게 동기화할지 물어보는 것도."
207
+ #
208
+ # 🟥 IT NEVER SWITCHES BRANCHES, and that is the whole safety envelope — not a nicety.
209
+ # The recommendation this line used to print told the reader to `git checkout
210
+ # $_DEFAULT_BRANCH && git pull`. In a SHARED CHECKOUT a checkout yanks the ground out from
211
+ # under a peer session: measured on this repo 2026-08-09 (two sessions, one worktree, one
212
+ # committed onto the other's branch), which is why scripts/branch_claim.sh exists at all.
213
+ # As prose advice a human weighed that; automated, nobody would. So the apply arm fires
214
+ # ONLY when the default branch is ALREADY checked out, and `--ff-only` means it can
215
+ # neither rewrite history nor absorb a divergence — it refuses instead.
216
+ #
217
+ # Consent is a LEASE, joined mechanically, never inferred: the class must be registered
218
+ # promotion_eligible in tracks/_meta/consent_classes.yaml AND granted unexpired in the
219
+ # UAP frontmatter. scripts/consent_registry_check.sh is the single decider (exit 0 = a
220
+ # real grant was joined; 3 = nothing granted; 1 = broken). Absent, expired, unreadable, or
221
+ # unknown all take the same branch as "no": surface, do not apply. absent ≠ granted.
222
+ # 🟥 The two conditions this replaced did NOT ask whether THIS class was granted, and a
223
+ # security pass before the 1.4.99 publish caught it with a live control (2026-08-15).
224
+ # · a bare run of consent_registry_check.sh returns 0 for a FILE-WIDE property — "the
225
+ # registry and the grants are well-formed and the floor join holds". One validly
226
+ # granted UNRELATED class produces that 0.
227
+ # · the second condition was `grep -q '^\s*repo-freshness-autopull:'` over the WHOLE UAP,
228
+ # which does not distinguish `granted` from `revoked` and does not care whether the
229
+ # hit is inside the machine-read frontmatter or in a prose paragraph.
230
+ # Reproduced: one unrelated class granted + the line ` repo-freshness-autopull: 안 쓰기로
231
+ # 했다` in prose → both conditions passed, the merge ran, and the banner told the operator
232
+ # it was acting on a standing consent that had never existed. The revoke path was the one
233
+ # that broke, which is the exact floor `absent ≠ granted` exists to hold.
234
+ # `--require-class` joins the ONE class: 0 only if it is an active, registered, unexpired
235
+ # grant; 3 otherwise. Same known pair now separates 0 from 3.
236
+ _AUTOPULL=""
237
+ if [ -x "$FH/scripts/consent_registry_check.sh" ] \
238
+ && bash "$FH/scripts/consent_registry_check.sh" --require-class repo-freshness-autopull >/dev/null 2>&1; then
239
+ _AUTOPULL=1
240
+ fi
241
+ _ON_DEFAULT=""
242
+ [ "$(git -C "$FH" symbolic-ref --short -q HEAD 2>/dev/null)" = "$_DEFAULT_BRANCH" ] && _ON_DEFAULT=1
243
+ # 🟥 NO DEADLINE HERE, AND THAT IS A DECISION — read before adding one back.
244
+ # A deadline was added here and then REMOVED the same session, because a cross-family
245
+ # review measured that the watchdog does not do what its name says: wrapping
246
+ # `git merge --ff-only` in `perl -e 'alarm N; exec @ARGV'` with N=2, against an upstream
247
+ # adding 20 files behind a slow smudge filter, took ~7.9s and returned 0. The alarm did
248
+ # not bound git. Shipping it would have added the appearance of a bound with none of the
249
+ # behaviour — the same false-green shape this release exists to fix.
250
+ # ⚠️ The consequence reaches further than this line: `_fh_gitcheck_deadline` guards the
251
+ # FETCH above too, and that guard predates this change. Whether it actually bounds a
252
+ # stalled fetch is now UNVERIFIED rather than assumed — a network stall may differ from a
253
+ # CPU-bound checkout, and neither was measured. Recorded as a residual instead of being
254
+ # quietly relied on.
255
+ # The exposure that motivated the attempt is real but unmeasured: the fetch may spend its
256
+ # full 8s inside a SessionStart hook budgeted at 10s, leaving ~2s for a merge whose true
257
+ # duration nobody has timed. Fixing it properly means measuring that duration and then
258
+ # bounding with something that actually bounds — not re-adding this line.
259
+ if [ -n "$_AUTOPULL" ] && [ -n "$_ON_DEFAULT" ] \
260
+ && git -C "$FH" merge --ff-only "refs/remotes/origin/$_DEFAULT_BRANCH" >/dev/null 2>&1; then
261
+ # Announce every unprompted run — §Operational Adaptation Loop requires it of a standing
262
+ # grant, and a sync the reader never saw is indistinguishable from one that never ran.
263
+ GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH was ${_BEHIND} commit(s) behind — fast-forwarded automatically (standing consent: repo-freshness-autopull). Nothing else was touched; your gitignored state is out of git's reach by construction."
264
+ else
265
+ # Every not-applied path lands here and says the same thing: what to run. It does NOT
266
+ # say why it did not apply, on purpose — "no grant" and "wrong branch" and "ff refused"
267
+ # would each need their own true sentence, and a wrong reason printed confidently is
268
+ # worse than none (this file's own §absent-subject rule).
269
+ GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH is ${_BEHIND} commit(s) behind origin/$_DEFAULT_BRANCH — while ON that branch run: git merge --ff-only origin/$_DEFAULT_BRANCH"
270
+ fi ;;
203
271
  esac
204
272
  fi
205
273
  # no local branch named $_DEFAULT_BRANCH at all (e.g. a fork never checked it out) → silent,
@@ -356,10 +356,16 @@ wired = {s for s in suites if has_runner(s)}
356
356
  # ── Embedded --self-test dispatchers — a class the name-pattern `suites` glob cannot see ──────
357
357
  # Measured 2026-08-13, the header of this file, §WHAT DEBT:0 DOES NOT MEAN: a lane suite that
358
358
  # lives INSIDE its subject as a `--self-test` flag — not a separate `test_*.sh`/`*_lanes.sh` file
359
- # — is structurally invisible to the glob above. 4 scripts carry one chamber_witness.sh ·
360
- # capability_registry_check.sh · digest_landing_check.sh · directional_diff_gate.sh and none
361
- # showed up as WIRED or UNWIRED anywhere in this report; the wiring line for the 3 that ARE wired,
362
- # the `for _subj in ...` loop at scripts/selfcheck.sh:478, could be deleted and nothing here would
359
+ # — is structurally invisible to the glob above. When this block was written, 4 such scripts were
360
+ # known and none showed up as WIRED or UNWIRED anywhere in this report.
361
+ # ⚠️ Do not read that 4 as a current figure, and do not re-list the names here. The set is
362
+ # DISCOVERED at run time by `_st_candidates` below and the live figure is printed in this file's
363
+ # own SELFTEST_COUNTS line, so a hand-maintained example list beside a self-discovering scan is a
364
+ # second source of truth that goes stale in silence. That is not hypothetical: the previous
365
+ # version of this comment named a 4th example the code no longer agreed with, and the session that
366
+ # noticed had to trace the discrepancy to ground by hand.
367
+ # The wiring line for the subjects that ARE wired,
368
+ # the `for _subj in ...` loop in scripts/selfcheck.sh, could be deleted and nothing here would
363
369
  # go red. found→extend, not a new file: same idiom as `suites`/`has_runner` above — discover
364
370
  # subjects, detect dispatch, report undeclared — new predicates for the shape this pattern uses.
365
371
  #
@@ -378,7 +384,7 @@ wired = {s for s in suites if has_runner(s)}
378
384
  # literal paren out as an escape sequence instead of a bare character, rather than splitting it
379
385
  # across lines.
380
386
  # A bare substring match on --self-test would also catch prose that only DISCUSSES the flag
381
- # (measured: scripts/selfcheck.sh:482 has a comment naming it as an example of what NOT to grep
387
+ # (measured: scripts/selfcheck.sh's `_subj` loop carries a comment naming it as what NOT to grep
382
388
  # for, which is exactly the false positive this narrower check exists to avoid). Require one of
383
389
  # the two real dispatcher shapes instead: `"--self-test"` in a quoted comparison, or `--self-test)`
384
390
  # as a bare case-pattern. The close-paren is built via chr — see the paren-trap note above; a
@@ -392,6 +398,21 @@ def _read(path):
392
398
  except OSError:
393
399
  return ''
394
400
 
401
+ # 🟥 NAMED RESIDUALS in the self-test branch — two asymmetries the 2026-08-15 repair did NOT close,
402
+ # written here because this file names its other residuals and silence would read as coverage.
403
+ # 1. Comment-stripping is LINE-PREFIX only. A dispatch line living inside a heredoc body or
404
+ # inside an echoed string is not a comment and still counts as a live caller. Symmetric with
405
+ # runner_dispatches, which has the same limit — so "symmetry restored" is true, and the shared
406
+ # blind spot survives in both. In-tree example of the shape: the heredoc fixtures in
407
+ # scripts/test_lane_runner_lanes.sh. Harmless today only because those name a fixture subject.
408
+ # 2. Subject DISCOVERY below still reads comments. `_st_names` matches the raw dispatcher forms
409
+ # anywhere in the file, so a comment that merely mentions the flag can enrol a script as a
410
+ # self-test subject. Real case: chamber_witness.sh carries the flag inside a usage comment.
411
+ # Harmless today because that file also has a real dispatcher — but it means "a mention is not
412
+ # a declaration" is enforced on the RUNNER side and not on the SUBJECT side.
413
+ # Neither is mechanized: closing them means parsing shell rather than scanning lines, which is the
414
+ # Grep-Collision Treadmill this repo has already logged. Fix on the first case that actually bites.
415
+ #
395
416
  # One line on purpose — see the paren-trap note above the SELFTEST_PAT definition.
396
417
  _st_candidates = [f for f in glob.glob('scripts/*.sh') if os.path.basename(f) not in suites and os.path.basename(f) != 'lane_runner_check.sh']
397
418
  _st_names = [os.path.basename(f)[:-3] for f in _st_candidates if any(_form in _read(f) for _form in SELFTEST_DISPATCH_FORMS)]
@@ -399,8 +420,8 @@ selftest_subjects = sorted(set(_st_names))
399
420
 
400
421
  def selftest_dispatched(bare_name, txt):
401
422
  """Two shapes, both real in this repo. Cross-family review (2026-08-14) caught the first draft
402
- shipping only the second — it read scripts/selfcheck.sh:478's `_subj` for-loop but missed
403
- :898/:933's direct `bash scripts/probe_scope_check.sh --self-test` / `bash scripts/
423
+ shipping only the second — it read scripts/selfcheck.sh's `_subj` for-loop but missed that
424
+ file's direct `bash scripts/probe_scope_check.sh --self-test` / `bash scripts/
404
425
  utterance_landing_check.sh --self-test`, so those two subjects were reported UNDECLARED while
405
426
  selfcheck.sh runs them every time. This is the exact failure the header above names by cite —
406
427
  a reader trusting the count over the source would have been told a false thing with confidence.
@@ -410,11 +431,17 @@ def selftest_dispatched(bare_name, txt):
410
431
  Shape 2 (indirect): bare_name sits in a `for VAR in ... bare_name ...; do` loop whose body
411
432
  dispatches $VAR with --self-test — mirrors the indirect-branch reasoning of runner_dispatches:
412
433
  the literal name is in a list construct, the invocation runs through the loop variable, so a
413
- direct-dispatch grep alone structurally cannot see it (scripts/selfcheck.sh:478)."""
414
- if re.search(rf'\bbash\s+scripts/{re.escape(bare_name)}\.sh\b[^\n]*--self-test', txt):
434
+ direct-dispatch grep alone structurally cannot see it (selfcheck.sh's `for _subj in ...` loop).
435
+
436
+ Comment lines are dropped first, exactly as runner_dispatches does for ordinary suites: "a
437
+ mention is not an invocation" applies identically here, and until 2026-08-15 it was enforced in
438
+ only one of the two predicates. Measured: a `# bash scripts/x.sh --self-test` usage line
439
+ matched the direct regex and certified a subject nothing ran."""
440
+ _body = '\n'.join(_l for _l in txt.split('\n') if not _l.strip().startswith('#'))
441
+ if re.search(rf'\bbash\s+scripts/{re.escape(bare_name)}\.sh\b[^\n]*--self-test', _body):
415
442
  return True
416
443
  in_loop = False; loop_var = None; has_name = False
417
- for ln in txt.split('\n'):
444
+ for ln in _body.split('\n'):
418
445
  s = ln.strip()
419
446
  m = re.match(r'for\s+(\w+)\s+in\b(.*)', s)
420
447
  if m:
@@ -430,7 +457,20 @@ def selftest_dispatched(bare_name, txt):
430
457
  return False
431
458
 
432
459
  def has_selftest_runner(bare_name):
433
- return any(selftest_dispatched(bare_name, _read(r)) for r in runners)
460
+ # A subject does not wire itself. has_runner() has skipped a suite's own file since this file
461
+ # was written; this branch did not, and the asymmetry was invisible because nothing exercised
462
+ # it — the only in-file control drove selftest_dispatched with synthetic text, never the
463
+ # runner set.
464
+ # ⚠️ Attribution, kept precise because a cross-family round caught it being loose: the 2026-08-15
465
+ # incident — directional_diff_gate.sh dispatched by nothing while the report said 10/10 — was
466
+ # caused by the COMMENT half, and comment-stripping alone closes it. Every self-reference in
467
+ # this tree at that date was a `#` line; a non-comment one existed nowhere. So this guard is
468
+ # SYMMETRY, not a repair of an observed case: it covers the shape where a subject names its own
469
+ # dispatch in live code, such as a usage helper. That shape is pinned by lane L16, which was
470
+ # added at the same time and for the same reason — without it this line is a repair no control
471
+ # drives, which is the failure mode this file exists to name.
472
+ _own = bare_name + '.sh'
473
+ return any(selftest_dispatched(bare_name, _read(r)) for r in runners if os.path.basename(r) != _own)
434
474
 
435
475
  selftest_wired = {s for s in selftest_subjects if has_selftest_runner(s)}
436
476
  selftest_undeclared = sorted(s for s in selftest_subjects if s not in selftest_wired)
@@ -445,12 +485,18 @@ if not selftest_dispatched('alpha', _ST_POS_FIXTURE):
445
485
  if selftest_dispatched('alpha', _ST_NEG_FIXTURE):
446
486
  print("CONTROL_FAILED\tself-test known-negative fixture with no --self-test flag read as dispatched")
447
487
  raise SystemExit(2)
488
+ # Second known-negative, added 2026-08-15 with the comment-skip repair. Without it the repair is a
489
+ # line of code no control drives — the failure mode this whole file exists to name.
490
+ _ST_CMT_FIXTURE = '# bash scripts/alpha.sh --self-test # usage example, not a caller\n'
491
+ if selftest_dispatched('alpha', _ST_CMT_FIXTURE):
492
+ print("CONTROL_FAILED\tself-test commented usage line read as a live dispatcher")
493
+ raise SystemExit(2)
448
494
 
449
495
  # ── CONTROL: the instrument must be able to see a suite known to be wired, and must NOT see one
450
496
  # known to be dead. Without both arms a broken detector reports "all clean" or "all broken" and
451
497
  # either reads as a verdict. [[feedback_absence_measurement_needs_control]]
452
498
  CTL_POS = 'test_selfcheck_state_lanes.sh' # selfcheck.sh invokes this DIRECTLY
453
- CTL_POS2 = 'test_session_close_lanes.sh' # selfcheck.sh:613 for-list + `bash "$_anchor"` — the
499
+ CTL_POS2 = 'test_session_close_lanes.sh' # selfcheck.sh's `_anchor` for-list + `bash "$_anchor"` — the
454
500
  # INDIRECT arm. Pins the second detection branch: the
455
501
  # strict detector called this UNWIRED and a hand-check
456
502
  # showed it runs. Without this control that branch
@@ -628,8 +674,18 @@ if [ -n "$SELFTEST_UNDECLARED" ]; then
628
674
  echo " dispatcher anywhere (self-test code exists, nothing calls it — see this file's own"
629
675
  echo " §Embedded --self-test comment for why the suites glob above cannot see this class):"
630
676
  printf '%s\n' "$SELFTEST_UNDECLARED" | sed 's/^/ /'
631
- echo " Fix by wiring \`bash scripts/<name>.sh --self-test\` into scripts/selfcheck.sh's"
632
- echo " _subj for-loop (scripts/selfcheck.sh:478), same shape as the 3 already there."
677
+ echo " Fix by wiring \`bash scripts/<name>.sh --self-test\` into scripts/selfcheck.sh, by"
678
+ echo " ONE of its two shapes and the choice is decided by the subject's own output, not"
679
+ echo " by which is shorter. The \`for _subj in ...\` loop gates on the substring 캘리브레이션;"
680
+ echo " a subject whose terminal verdict is worded any other way goes red there for a reason"
681
+ echo " that has nothing to do with its lanes — capability_registry_check was added to that"
682
+ echo " loop and reverted for exactly this. Such a subject takes the second shape: its own"
683
+ echo " direct-dispatch block, as capability_registry_check.sh and relay_channel.sh do there."
684
+ echo " 🟥 In that second shape do NOT gate on the exit code alone. Measured 2026-08-15 on"
685
+ echo " directional_diff_gate: a suite whose lanes were all deleted still printed a PASS"
686
+ echo " verdict and exited 0, and a usage banner carrying the same words satisfied a"
687
+ echo " substring match. Require the terminal verdict AS A WHOLE LINE, with a non-zero count"
688
+ echo " in it, or exit 0 will certify a suite that ran nothing."
633
689
  fi
634
690
 
635
691
  echo "PASS lane-runner: ${TOTAL} suites — ${WIRED} wired · ${N_EXEMPT} exempt · ${N_DEBT} declared debt" \
@@ -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"