@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.
@@ -485,7 +485,11 @@ done
485
485
  # or trim of that one test name would flip a real PASS into "dispatcher missing?" here (cross-family
486
486
  # review caught this, 2026-08-15). chamber_witness/digest_landing_check both print a genuine
487
487
  # terminal "캘리브레이션 통과/실패" line, so they stay.
488
- for _subj in compaction_probe judgment_circuit_lint novelty_claim_check chamber_witness digest_landing_check; do
488
+ # publish_freshness_check joined 2026-08-16. It qualifies for the same reason chamber_witness does
489
+ # and capability_registry_check does not: its terminal line is a genuine VERDICT
490
+ # ("publish_freshness 캘리브레이션 통과/실패"), not a Korean test-case title, so a future rename of
491
+ # any single lane cannot flip a real PASS into a false "dispatcher missing?" here.
492
+ for _subj in compaction_probe judgment_circuit_lint novelty_claim_check chamber_witness digest_landing_check publish_freshness_check; do
489
493
  if [ ! -f "scripts/$_subj.sh" ]; then
490
494
  _absent_subject_verdict "$_subj --self-test" "scripts/$_subj.sh" || fail=1
491
495
  else
@@ -553,6 +557,30 @@ else
553
557
  fail=1
554
558
  fi
555
559
 
560
+ # capability_effect_probe — wired 2026-08-16. `lane_runner_check.sh` had been flagging it as an
561
+ # embedded --self-test subject with ZERO dispatchers, and that warning was load-bearing: the same
562
+ # day, two SECURITY regression anchors were added to this suite (L8 args injection · L9 the git
563
+ # metadata channel that let a `read-only` declaration plant a persistent `core.pager` hook in the
564
+ # real repo while the probe printed VERIFIED). Unwired, those anchors would never have run anywhere
565
+ # but on the author's machine — the exact "built but not wired" shape this repo keeps reproducing.
566
+ # Not in the `_subj` loop above: its terminal line is `N PASS / M FAIL`, never 캘리브레이션, so that
567
+ # loop's substring gate would go red for a reason unrelated to its lanes (capability_registry_check
568
+ # was added there and reverted for precisely this). Direct dispatch instead.
569
+ # 🟥 Exit code ALONE is not enough — a suite whose lanes were all deleted still exits 0 (measured
570
+ # 2026-08-15 on directional_diff_gate). Require the terminal verdict AS A WHOLE LINE with a
571
+ # non-zero PASS count, so an emptied suite cannot certify itself.
572
+ if [ ! -f scripts/capability_effect_probe.sh ]; then
573
+ echo "FAIL capability_effect_probe.sh: missing — it ships via package.json files[], so absence is deletion, not package mode"
574
+ fail=1
575
+ elif _out=$(bash scripts/capability_effect_probe.sh --self-test < /dev/null 2>&1) \
576
+ && printf '%s\n' "$_out" | grep -qE '^── capability_effect_probe lanes: [1-9][0-9]* PASS / 0 FAIL ──$'; then
577
+ echo "PASS capability_effect_probe.sh --self-test ($(printf '%s\n' "$_out" | grep -oE '[0-9]+ PASS / [0-9]+ FAIL' | tail -1))"
578
+ else
579
+ echo "FAIL capability_effect_probe.sh: --self-test failed or produced no terminal verdict line"
580
+ _show_failure "$_out"
581
+ fail=1
582
+ fi
583
+
556
584
  # relay_channel — the other embedded --self-test subject kept out of the loop above: its
557
585
  # `--self-test` just `exec`s the sibling scripts/test_relay_channel_lanes.sh, whose own exit code
558
586
  # is already disciplined (`[ "$fail" -eq 0 ] || exit 1`), so gating on exit code — same shape as
@@ -582,6 +610,78 @@ else
582
610
  fail=1
583
611
  fi
584
612
 
613
+ # directional_diff_gate — the LAST embedded --self-test subject with no caller, and it stayed
614
+ # invisible for a reason worth keeping written down: lane_runner_check.sh reported it WIRED off the
615
+ # subject's OWN usage comment (`# bash scripts/directional_diff_gate.sh --self-test`), so the
616
+ # report read `self-test: 10/10 wired` while `grep -rn directional_diff_gate` across every runner
617
+ # surface returned zero real callers. The detector was repaired in the same delta as this wiring
618
+ # (has_selftest_runner now skips the subject's own file, selftest_dispatched now drops comment
619
+ # lines — the two guards has_runner/runner_dispatches always had), which is why the two halves ship
620
+ # together: fixing the detector alone would have left a real debt item newly VISIBLE and still
621
+ # unrun, and wiring alone would have left the detector able to certify the next such subject.
622
+ # ⚠️ The cost of shipping both halves at once, named rather than left for the next reader to
623
+ # discover: the report reads `10/10` before the change and `10/10` after it, so the detector repair
624
+ # is NOT observable from this repo's own run — reverting the repair alone leaves the count
625
+ # unchanged, because the wiring that now exists is real. Its only evidence is the fixture lanes
626
+ # L14–L16 in scripts/test_lane_runner_lanes.sh, which is why L16 in particular is load-bearing:
627
+ # delete it and the self-exclusion guard has nothing anywhere that would go red.
628
+ #
629
+ # NOT in the `for _subj in ...` loop above, and that is the same call made for
630
+ # capability_registry_check: that loop gates on the substring 캘리브레이션, and this subject's
631
+ # terminal verdict is English ("✅ calibration passed (N pairs)"). Adding it there would produce
632
+ # "dispatcher missing?" — a confident and wrong cause — for a suite that passes. Gate on exit code
633
+ # instead, same shape as the two blocks above. It carries genuine HARNESS-ERROR paths (`exit 10` on
634
+ # mktemp failure, `return 10` on unreadable input), so a setup failure must not read as a lane
635
+ # failure. Ships via package.json files[] with no ACCEPTED_ABSENT declaration, so absence is
636
+ # deletion, not package mode. `< /dev/null` for the same stdin-wait reason the loop above documents.
637
+ if [ ! -f scripts/directional_diff_gate.sh ]; then
638
+ echo "FAIL directional_diff_gate.sh: missing — it ships via package.json files[], so absence is deletion, not package mode"
639
+ fail=1
640
+ else
641
+ _out=$($_LANE_TO bash scripts/directional_diff_gate.sh --self-test < /dev/null 2>&1); _dd_rc=$?
642
+ case "$_dd_rc" in
643
+ 10|2|124|126|127)
644
+ # 124 is `timeout`'s own kill code, so it belongs in this arm rather than the generic FAIL
645
+ # one below: a suite that was killed did not measure, and calling that "the lane failed"
646
+ # sends the reader to the wrong file. Reachable only because this dispatch is wrapped in
647
+ # `$_LANE_TO` — which is REUSED from the pair-suite loop above, not installed here, so do
648
+ # not delete it from this line believing the timeout is local to this block.
649
+ echo "HARNESS ERROR directional_diff_gate.sh --self-test: exited $_dd_rc — it could not measure,"
650
+ echo " so its verdicts prove nothing. Not a lane failure, and not a pass either."
651
+ fail=1 ;;
652
+ 0)
653
+ # rc=0 is NOT sufficient, and this is not a hypothetical: the `for _subj in ...` loop above
654
+ # records the measured case where a subject fell into an unrecognized mode, printed its usage
655
+ # banner and exited 0, and the checker read that as a pass while 25 lanes had vanished. This
656
+ # subject happens to exit 10 from that path TODAY — a revert probe (2026-08-15: dispatcher
657
+ # line deleted) confirmed the HARNESS ERROR arm fires — but that is incidental to how its
658
+ # usage function is written, not a property this block should depend on. Require the
659
+ # terminal verdict itself, so a future subject that exits 0 without running anything goes red
660
+ # here rather than certifying an empty run.
661
+ # Whole-LINE match, not a substring, and the pair count must be non-zero. Both halves were
662
+ # named by cross-family review (2026-08-15) and both are reachable in the real subject, not
663
+ # hypothetical: directional_diff_gate.sh's verdict line is
664
+ # `[ "$f" -eq 0 ] && echo "✅ calibration passed ($n pairs)"` — delete every `t` lane and it
665
+ # prints `calibration passed (0 pairs)` and exits 0, i.e. a suite with nothing left in it
666
+ # certifies itself. That is the quietest face of [[feedback_not_found_is_not_zero_family]]
667
+ # (deletion read as a pass), and a substring match would also let a usage banner or a prose
668
+ # line carrying the same words satisfy the gate.
669
+ _dd_verdict=$(printf '%s\n' "$_out" | grep -oE '^✅ calibration passed \([0-9]+ pairs\)$' | tail -1)
670
+ _dd_pairs=$(printf '%s' "$_dd_verdict" | grep -oE '[0-9]+' | tail -1)
671
+ if [ -n "$_dd_verdict" ] && [ "${_dd_pairs:-0}" -gt 0 ]; then
672
+ echo "PASS directional_diff_gate.sh --self-test ($_dd_verdict)"
673
+ else
674
+ echo "FAIL directional_diff_gate.sh: --self-test exited 0 without a non-zero calibration verdict (dispatcher missing, or every lane deleted?)"
675
+ _show_failure "$_out"
676
+ fail=1
677
+ fi ;;
678
+ *)
679
+ echo "FAIL directional_diff_gate.sh: --self-test failed (exit $_dd_rc)"
680
+ _show_failure "$_out"
681
+ fail=1 ;;
682
+ esac
683
+ fi
684
+
585
685
  # memory-link-check — the memory store is a GRAPH (memory_intent_recall.md: nodes=files,
586
686
  # edges=[[links]], recall walks one hop). Measured 2026-07-28: 50 of 872 edges pointed at a note
587
687
  # that existed under a different separator and 22 at nothing — a dead edge returns nothing and is
@@ -53,6 +53,16 @@ fi
53
53
  FILES_JSON=$(node -e 'process.stdout.write(JSON.stringify(require("./package.json").files||[]))' 2>/dev/null) || {
54
54
  echo " INSTRUMENT ERROR: could not read package.json files[]"; exit 1; }
55
55
 
56
+ # ★`mktemp` — 초판은 `${TMPDIR:-/tmp}/cap_entry_$$.txt` 였다. TMPDIR 없는 환경(리눅스 CI·
57
+ # 컨테이너가 흔하다)에서 공용 `/tmp` + PID 기반 **예측 가능한 이름**이라, 미리 심어둔 심링크를
58
+ # `>` 리다이렉트가 따라가 대상 파일을 덮어쓴다(CWE-377, 보안 패스 [B]). 이 파일은 selfcheck 에
59
+ # 배선돼 **소비자 머신에서 `npm test` 로 돈다**.
60
+ # ⚠️ `mktemp -t <prefix>` 는 **BSD 전용**이다 — GNU 는 템플릿에 X 를 요구하고
61
+ # `mktemp: too few X's in template` 로 죽는다(2026-08-16 CI 실측: 로컬 macOS 초록 · ubuntu 적색).
62
+ # 이 레포가 이미 기록한 `stat -f` BSD-first 와 같은 얼굴이다. 양쪽에서 도는 형태는
63
+ # **명시 템플릿 + X 다수**뿐이다. (계기가 fail-closed 로 막아 준 덕에 조용히 안 지나갔다.)
64
+ _CAP_TMP=$(mktemp "${TMPDIR:-/tmp}/cap_entry.XXXXXXXX") || { echo " INSTRUMENT ERROR: mktemp 실패"; exit 1; }
65
+ trap 'rm -f "$_CAP_TMP"' EXIT INT TERM
56
66
  printf '%s\n' "$ENTRIES" | while IFS= read -r e; do
57
67
  [ -n "$e" ] || continue
58
68
  if node -e 'const f=JSON.parse(process.argv[1]);process.exit(f.includes(process.argv[2])?0:1)' "$FILES_JSON" "$e"; then
@@ -60,14 +70,14 @@ printf '%s\n' "$ENTRIES" | while IFS= read -r e; do
60
70
  else
61
71
  echo " MISSING $e"
62
72
  fi
63
- done > "${TMPDIR:-/tmp}/cap_entry_$$.txt"
73
+ done > "$_CAP_TMP"
64
74
 
65
- missing=$(grep -c '^ MISSING ' "${TMPDIR:-/tmp}/cap_entry_$$.txt" || true)
75
+ missing=$(grep -c '^ MISSING ' "$_CAP_TMP" || true)
66
76
  missing=$(( ${missing:-0} + 0 ))
67
- shipped=$(grep -c '^ ok ' "${TMPDIR:-/tmp}/cap_entry_$$.txt" || true)
77
+ shipped=$(grep -c '^ ok ' "$_CAP_TMP" || true)
68
78
  shipped=$(( ${shipped:-0} + 0 ))
69
- cat "${TMPDIR:-/tmp}/cap_entry_$$.txt"
70
- rm -f "${TMPDIR:-/tmp}/cap_entry_$$.txt"
79
+ cat "$_CAP_TMP"
80
+ rm -f "$_CAP_TMP"
71
81
 
72
82
  if [ "$missing" -eq 0 ]; then
73
83
  ok "all $shipped capability entry point(s) are in package.json files[]"
@@ -118,6 +128,66 @@ else
118
128
  bad "$dangling declared capability entry point(s) missing from disk — npm would ship a broken files[] and the disk-side check alone stays green"
119
129
  fi
120
130
 
131
+ # SIBLING DEPENDENCIES (added 2026-08-16 — occurrence #4 of the class this file's header names).
132
+ # The two directions above both key on the ENTRY-POINT convention (`*_capability.sh`). That is
133
+ # structurally blind to the failure that actually happened this time: a shipped script called a
134
+ # NON-entry-point sibling that did not ship. `capability_registry_check.sh` shipped, its new M6 axis
135
+ # ran `${0%/*}/capability_effect_probe.sh`, and that probe was absent from files[] — so on a consumer
136
+ # install M6 would hit its fail-closed branch and reject every registration, blaming a "missing probe"
137
+ # that exists in the repo.
138
+ #
139
+ # WHY `package_coverage_check.sh` IS BLIND TO IT — and why the fix belongs here, not there: that
140
+ # checker's extraction regex requires a reference to START with a shipped top-level directory
141
+ # (`scripts|templates|bin|docs|knowledge|plugins|.claude`). A script naming its own sibling writes
142
+ # `${0%/*}/name.sh` or `$(dirname "$0")/name.sh` — the path is BUILT AT RUNTIME and no literal
143
+ # `scripts/` appears. The reference-follower cannot see a path that does not textually exist.
144
+ #
145
+ # Scope kept narrow on purpose: only SELF-DIRECTORY forms. `$REPO_ROOT/scripts/x.sh` already contains
146
+ # the literal `scripts/x.sh` and is caught upstream; widening past that would duplicate that checker.
147
+ SIB_PAT='(\$\{0%/\*\}|\$\(dirname [^)]*\)|\$\{BASH_SOURCE[^}]*%/\*\})/[A-Za-z0-9_.-]+\.(sh|py|js)'
148
+ SHIPPED_SH=$(node -e '
149
+ const f=JSON.parse(process.argv[1]);
150
+ process.stdout.write(f.filter(p=>/^scripts\/.*\.sh$/.test(p)).join("\n"));
151
+ ' "$FILES_JSON")
152
+
153
+ sib_refs=0; sib_missing=0
154
+ if [ -n "$SHIPPED_SH" ]; then
155
+ while IFS= read -r s; do
156
+ [ -n "$s" ] && [ -f "$s" ] || continue
157
+ # `grep -o` yields the whole matched expression; the sibling NAME is its last path segment.
158
+ # COMMENT LINES ARE STRIPPED FIRST, and that is not cosmetic: the finding's text asserts the
159
+ # callee is "called at runtime". A file whose PROSE names a sibling (this file's own header does)
160
+ # would be reported under a claim that is false about it — the right defect attributed to the
161
+ # wrong caller. Caught during this lane's own known-pair calibration, where the positive arm
162
+ # named two callers and only one of them actually calls anything.
163
+ for ref in $(sed 's/^[[:space:]]*#.*//' "$s" | grep -oE "$SIB_PAT" 2>/dev/null | sed 's|.*/||' | sort -u); do
164
+ sib_refs=$((sib_refs+1))
165
+ # Only assert on siblings that EXIST in the repo. A reference to a name that is absent from
166
+ # disk too is a different defect (a dead call), and it belongs to the dangling check above —
167
+ # claiming it here would report one defect as two.
168
+ [ -f "scripts/$ref" ] || continue
169
+ if node -e 'const f=JSON.parse(process.argv[1]);process.exit(f.includes(process.argv[2])?0:1)' \
170
+ "$FILES_JSON" "scripts/$ref"; then :; else
171
+ echo " SIBLING-MISSING scripts/$ref (called by $s at runtime, absent from files[])"
172
+ sib_missing=$((sib_missing+1))
173
+ fi
174
+ done
175
+ done <<EOF
176
+ $SHIPPED_SH
177
+ EOF
178
+ fi
179
+
180
+ # Discovery-zero is an instrument failure here for the same reason as above: this repo is KNOWN to
181
+ # contain at least one self-directory sibling call (that is why this block exists). Zero means the
182
+ # pattern stopped matching — a silent scanner, not a clean repo.
183
+ if [ "$sib_refs" -eq 0 ]; then
184
+ bad "INSTRUMENT DEAD — zero self-directory sibling references found across $(printf '%s\n' "$SHIPPED_SH" | grep -c .) shipped scripts; the pattern is no longer matching anything"
185
+ elif [ "$sib_missing" -eq 0 ]; then
186
+ ok "all $sib_refs runtime sibling dependenc(ies) of shipped scripts are themselves in files[]"
187
+ else
188
+ bad "$sib_missing runtime sibling dependenc(ies) absent from files[] — the consumer gets a caller whose callee is missing"
189
+ fi
190
+
121
191
  # CONTROL — the check must be able to say NO. A checker that only ever prints ok is indistinguishable
122
192
  # from a checker that is not looking; assert the negative arm on a name that cannot be in files[].
123
193
  if node -e 'const f=JSON.parse(process.argv[1]);process.exit(f.includes(process.argv[2])?0:1)' \
@@ -782,6 +782,105 @@ else
782
782
  echo " ✅ PROV-2 line tracks the resolution when it changes (shadowed → different value)"; pass=$((pass+1))
783
783
  fi
784
784
 
785
+ # ---- RC: --require-class — narrowing the verdict from the FILE to ONE CLASS ---------
786
+ # Added 2026-08-16 after a pre-publish security pass found the caller side broken: fh_node_check.sh
787
+ # gated a consumer-machine `git merge --ff-only` on this script's FILE-WIDE 0 plus a raw grep for
788
+ # the class name anywhere in the UAP. One unrelated validly-granted class, plus the target class
789
+ # appearing only in a prose line saying it was REVOKED, satisfied both — the merge ran and the
790
+ # banner reported a standing consent that did not exist.
791
+ # Every lane below is a REFUSAL that leaves the file-wide verdict at 0. That property is asserted,
792
+ # not assumed: without it these lanes would pass for the same reason the existing "nothing granted"
793
+ # lanes do, and the class-join axis would go unexercised a second time.
794
+ lane_args() { # desc want_rc [args...] — same as lane() but the flag order is the point
795
+ local desc="$1" want_rc="$2"; shift 2
796
+ bash "$CHK" "$@" >"$TD/o" 2>&1
797
+ local rc=$?
798
+ if [ "$rc" -ne "$want_rc" ]; then
799
+ bad "$desc — exit $rc, expected $want_rc"; sed 's/^/ /' "$TD/o"; return
800
+ fi
801
+ ok "$desc"
802
+ }
803
+
804
+ TWOCLASS="$OKCLASS
805
+ - {name: other, owner: o2, mode: m2, target: t2, capabilities: [read], sinks: [], feeds: [], promotion_eligible: true}"
806
+
807
+ # a) the class IS granted → 0
808
+ mkreg "$TWOCLASS"
809
+ mkuap 'standing_consent:
810
+ ok: {owner: o, mode: m, sinks: [], granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
811
+ lane_args "RC-a --require-class on a class that IS granted → 0" 0 --require-class ok "$TD/r.yaml" "$TD/u.md"
812
+
813
+ # b) a DIFFERENT class is granted, the target appears only as prose → 3, file-wide still 0
814
+ mkreg "$TWOCLASS"
815
+ printf -- '---\nstanding_consent:\n other: {owner: o2, mode: m2, sinks: [], granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t2}\n---\n\n# UAP (fixture)\n\n ok: 안 쓰기로 했다 (revoked)\n' > "$TD/u.md"
816
+ bash "$CHK" "$TD/r.yaml" "$TD/u.md" >/dev/null 2>&1; _fw=$?
817
+ lane_args "RC-b another class granted, target only in prose → 3" 3 --require-class ok "$TD/r.yaml" "$TD/u.md"
818
+ [ "$_fw" -eq 0 ] \
819
+ && ok "RC-b DISCRIMINATION: the file-wide verdict is still 0, so RC-b refused on the class join" \
820
+ || bad "RC-b VACUOUS: file-wide verdict was $_fw — this lane re-tests an existing axis, not the class join"
821
+
822
+ # c) the ON switch must not be silently OFF. A whitespace-only name passed a `-z` test in the first
823
+ # draft and then strip()ed to empty, disabling the narrowing and returning the file-wide 0.
824
+ lane_args "RC-c whitespace-only class name → 1 (fail-closed, never a silent fall-back)" 1 --require-class " " "$TD/r.yaml" "$TD/u.md"
825
+ lane_args "RC-d --require-class= empty form → 1" 1 --require-class= "$TD/r.yaml" "$TD/u.md"
826
+
827
+ # e) flag position. The first draft read argv[1] only, so a caller appending the flag got the old
828
+ # file-wide 0 with no warning — the failure mode is silence, which is why this is a lane.
829
+ lane_args "RC-e flag AFTER the paths is still honoured → 3" 3 "$TD/r.yaml" "$TD/u.md" --require-class ok
830
+ lane_args "RC-f --require-class=NAME form is honoured → 3" 3 --require-class=ok "$TD/r.yaml" "$TD/u.md"
831
+
832
+ # g) an unknown option must not be swallowed as a path
833
+ lane_args "RC-g unknown option → 1, not consumed as a path" 1 --bogus "$TD/r.yaml" "$TD/u.md"
834
+
835
+ # h) the human summary must agree with the typed exit — this file's own rule, applied to the new path
836
+ bash "$CHK" --require-class ok "$TD/r.yaml" "$TD/u.md" >"$TD/o" 2>&1
837
+ if grep -qE '^consent-registry: UNMEASURED for class `ok`' "$TD/o" && ! grep -qE '^consent-registry: PASS$' "$TD/o"; then
838
+ ok "RC-h summary agrees with the typed exit (no bare PASS above an exit-3 verdict)"
839
+ else
840
+ bad "RC-h summary contradicts the verdict"; sed 's/^/ /' "$TD/o"
841
+ fi
842
+
843
+ # i) no flag → behaviour unchanged. Backward compatibility is a claim, so it gets a lane.
844
+ lane_args "RC-i no flag → file-wide verdict unchanged (0)" 0 "$TD/r.yaml" "$TD/u.md"
845
+
846
+ # j) IDENTITY IS NOT NORMALISED FOR THE CALLER. A third adversarial round measured the first repair
847
+ # squeezing ALL whitespace out of the name, so `--require-class 'o k'` was answered with the
848
+ # verdict for class `ok` — a different question, silently answered. Asking about a name that is
849
+ # not a name is refused, never repaired.
850
+ mkreg "$TWOCLASS"
851
+ mkuap 'standing_consent:
852
+ ok: {owner: o, mode: m, sinks: [], granted: 2026-07-29, expires: 2026-12-31, effects: [read], target: t}'
853
+ lane_args "RC-j interior whitespace in the class name → 1, never folded onto a real class" 1 --require-class "o k" "$TD/r.yaml" "$TD/u.md"
854
+ lane_args "RC-k surrounding whitespace IS trimmed → 0 (trim, not squeeze)" 0 --require-class " ok " "$TD/r.yaml" "$TD/u.md"
855
+
856
+ # l) argv preservation. The first repair rebuilt "$@" through a newline-delimited string, which split
857
+ # a path containing a newline and DROPPED an empty positional — the drop promoted the UAP path
858
+ # into the registry slot, i.e. the tool silently measured a different pair of files than it was
859
+ # handed. Both are exotic inputs; both are silent, which is why they are lanes.
860
+ _nlreg="$TD/reg
861
+ two.yaml"
862
+ cp "$TD/r.yaml" "$_nlreg" 2>/dev/null && lane_args "RC-l a registry path containing a newline is one argument" 0 --require-class ok "$_nlreg" "$TD/u.md" \
863
+ || ok "RC-l SKIPPED — this filesystem rejected a newline in a filename (not a defect)"
864
+ # RC-m asserts on WHICH registry path the tool reports, not on a success marker. The first version
865
+ # grepped for `R1/R2`, which only appears when the DEFAULT registry parses — and that default is
866
+ # `tracks/_meta/consent_classes.yaml`, which is **gitignored**, so it exists on the author's machine
867
+ # and not in CI. It passed locally and went red on the first CI run: a lane that measured the
868
+ # author's filesystem rather than the behaviour. This form works in both.
869
+ # Calibrated: the control proves the reported path tracks argv[1] at all, so the assertion below is
870
+ # a discrimination result and not a grep that could never match.
871
+ bash "$CHK" "/nonexistent-registry-probe.yaml" "$TD/u.md" >"$TD/oc" 2>&1
872
+ if grep -q "nonexistent-registry-probe.yaml" "$TD/oc"; then
873
+ ok "RC-m CONTROL: the reported registry path tracks argv[1]"
874
+ else
875
+ bad "RC-m CONTROL dead: the tool did not name the registry it was given — the assertion below proves nothing"; sed 's/^/ /' "$TD/oc"
876
+ fi
877
+ bash "$CHK" "" "$TD/u.md" >"$TD/o" 2>&1
878
+ if grep -qE "registry (at|unparseable).*$(basename "$TD")/u\.md" "$TD/o"; then
879
+ bad "RC-m empty positional shifted the argument list — the UAP was read as the registry"; sed 's/^/ /' "$TD/o"
880
+ else
881
+ ok "RC-m an empty first positional does not promote the UAP into the registry slot"
882
+ fi
883
+
785
884
  echo "----"
786
885
  echo "consent-registry anchor: $pass passed, $fail failed"
787
886
  [ "$fail" -eq 0 ] || exit 1
@@ -216,6 +216,80 @@ else
216
216
  printf '%s\n' "$out" | sed 's/^/ │ /'
217
217
  fi
218
218
 
219
+ # ── L14/L15 the SUBJECT'S OWN documentation is not a caller (2026-08-15) ──────────────────────
220
+ # Measured on this repo: `self-test: 10/10 wired` while directional_diff_gate.sh had ZERO callers
221
+ # (`grep -rn directional_diff_gate scripts/*.sh templates/.git-hooks/* .github/workflows/*.yml`,
222
+ # excluding the checker and the subject itself → nothing). The subject's own usage comment
223
+ # (`# bash scripts/directional_diff_gate.sh --self-test`) matched the direct-dispatch regex, so
224
+ # the checker read the file's documentation of itself as evidence that something ran it.
225
+ #
226
+ # This is the SAME class the checker already closes one level up and, until now, only there:
227
+ # `runners` drops lane_runner_check.sh so the tool cannot certify its own DEBT list as done, and
228
+ # has_runner() skips a suite's own file, and runner_dispatches() skips comment lines. None of the
229
+ # three guards reached the --self-test branch — has_selftest_runner() scanned every runner
230
+ # including the subject, over raw text including comments. A guard that exists in one predicate
231
+ # and not in its sibling is not a guard, it is a coincidence.
232
+ #
233
+ # Two lanes because the two halves fail independently: self-exclusion alone still lets a COMMENT
234
+ # in a third file certify a subject, and comment-skipping alone still lets a subject's own
235
+ # non-comment self-reference do it.
236
+
237
+ # L14 known-POSITIVE: the subject's OWN usage comment must not count as a caller.
238
+ D="$T/l14"; mkfixture_clean "$D"
239
+ cat > "$D/scripts/orphan_probe.sh" <<'SUBJ'
240
+ #!/usr/bin/env bash
241
+ # Usage:
242
+ # bash scripts/orphan_probe.sh --self-test # known-pair calibration
243
+ [ "${1:-}" = "--self-test" ] && { echo ok; exit 0; }
244
+ SUBJ
245
+ out=$(run "$D"); rc=$(rcof "$D")
246
+ if [ "$rc" = "0" ] && printf '%s' "$out" | grep -q 'orphan_probe'; then
247
+ ok "L14 known-positive: subject's own usage comment is not a caller — still reported unwired"
248
+ else
249
+ bad "L14 known-positive did not fire (rc=$rc) — self-referential comment read as a dispatcher"
250
+ printf '%s\n' "$out" | sed 's/^/ │ /'
251
+ fi
252
+
253
+ # L15 known-POSITIVE: a comment in a DIFFERENT file must not count either. Same discipline
254
+ # runner_dispatches() already applies to ordinary suites ("a mention is not an invocation").
255
+ D="$T/l15"; mkfixture_clean "$D"
256
+ printf '#!/usr/bin/env bash\n[ "${1:-}" = "--self-test" ] && { echo ok; exit 0; }\n' > "$D/scripts/orphan_probe.sh"
257
+ cat > "$D/scripts/selfcheck.sh" <<'SC'
258
+ #!/usr/bin/env bash
259
+ bash scripts/lane_runner_check.sh
260
+ bash scripts/test_ok_lanes.sh
261
+ # historical note: we used to run `bash scripts/orphan_probe.sh --self-test` here, removed 2026-01-01
262
+ SC
263
+ out=$(run "$D"); rc=$(rcof "$D")
264
+ if [ "$rc" = "0" ] && printf '%s' "$out" | grep -q 'orphan_probe'; then
265
+ ok "L15 known-positive: a commented-out dispatch in another file is not a caller"
266
+ else
267
+ bad "L15 known-positive did not fire (rc=$rc) — a comment read as a live dispatcher"
268
+ printf '%s\n' "$out" | sed 's/^/ │ /'
269
+ fi
270
+
271
+ # L16 known-POSITIVE: a subject that names its own dispatch on a NON-comment line. This is the only
272
+ # lane that actually pins the self-exclusion guard, and it exists because a cross-family round
273
+ # (2026-08-15) showed L14/L15 do not: both of their fixtures put the self-reference in a `#` line,
274
+ # so comment-stripping alone already satisfies them and the guard could be deleted with all lanes
275
+ # still green — a repair with no control, which is the exact failure this file was written to name.
276
+ # The shape is a usage helper, because that is how a real subject names its own flag in live code
277
+ # rather than in a header comment.
278
+ D="$T/l16"; mkfixture_clean "$D"
279
+ cat > "$D/scripts/orphan_probe.sh" <<'SUBJ'
280
+ #!/usr/bin/env bash
281
+ usage() { echo "run: bash scripts/orphan_probe.sh --self-test"; }
282
+ [ "${1:-}" = "--self-test" ] && { echo ok; exit 0; }
283
+ usage
284
+ SUBJ
285
+ out=$(run "$D"); rc=$(rcof "$D")
286
+ if [ "$rc" = "0" ] && printf '%s' "$out" | grep -q 'orphan_probe'; then
287
+ ok "L16 known-positive: a subject's own NON-comment self-reference is not a caller"
288
+ else
289
+ bad "L16 known-positive did not fire (rc=$rc) — self-exclusion guard is unanchored"
290
+ printf '%s\n' "$out" | sed 's/^/ │ /'
291
+ fi
292
+
219
293
  echo "----"
220
294
  echo "lane-runner lanes: $pass passed, $fail failed"
221
295
  [ "$fail" -eq 0 ] || exit 1
@@ -174,6 +174,223 @@ for h in "$FH_REPO"/templates/.git-hooks/pre-commit "$FH_REPO"/templates/.git-ho
174
174
  else bad "lane9 sentinel does NOT match shipped $(basename "$h") — every FH machine would report 'not FH gate'" "$(head -3 "$h")"; fi
175
175
  done
176
176
 
177
+ # ── lane10 — consent-gated auto-pull: the APPLY arm and every REFUSE arm ─────────────────────
178
+ # Added 2026-08-15 with the feature. These lanes exist because the feature's whole safety claim is
179
+ # about what it does NOT do, and "does not" is exactly what a green run cannot demonstrate by
180
+ # itself — every refuse arm below is paired against an apply arm in the same fixture, so a lane
181
+ # that goes silently inert fails the CONTROL rather than passing everything.
182
+ # 🟥 The load-bearing one is lane10-b: in a shared checkout a branch switch yanks the ground out
183
+ # from under a peer session (measured 2026-08-09, two sessions/one worktree). If this feature ever
184
+ # switches branches, that lane is what says so.
185
+ _ap_root="$(mktemp -d)"
186
+ (
187
+ cd "$_ap_root" || exit 1
188
+ # Pin the branch name: `git init` uses init.defaultBranch, which is main on this
189
+ # operator's machine and master on a stock Ubuntu (CI). Hardcoding "main" in the
190
+ # assertions below made lane10-b pass VACUOUSLY there (`rev-parse "$_DEF"` returned
191
+ # empty, and empty==empty compared true) while CONTROL's reset silently no-op'd.
192
+ git -c init.defaultBranch=main init -q up && cd up
193
+ git config user.email t@t; git config user.name t; git config commit.gpgsign false
194
+ mkdir -p scripts tracks/_meta
195
+ cp "$FH_REPO/scripts/fh_node_check.sh" "$FH_REPO/scripts/consent_registry_check.sh" scripts/
196
+ # Build the consent fixture from the SHIPPED TEMPLATE, never from the operator's local
197
+ # tracks/_meta/ — those are gitignored, so on CI (and on any fresh clone) they do not exist and
198
+ # the lanes would report a red FAIL for a file whose absence is correct by design. Measured on
199
+ # CI 2026-08-15: exactly that, "registry/UAP absent — lanes not run". Sourcing the template
200
+ # instead is strictly better than degrading to SKIP: the lanes then actually run everywhere, and
201
+ # they validate the very block a consumer is told to copy.
202
+ cp "$FH_REPO/templates/consent_classes.yaml.example" tracks/_meta/consent_classes.yaml
203
+ # The grant side has no shipped template (a UAP is per-operator by nature), so synthesize the
204
+ # minimum the registry check requires: the full R6 fingerprint, far-future expiry so the fixture
205
+ # never rots into a false refusal, joined to the template's own class name.
206
+ # Dates are computed at fixture-build time, not hardcoded, for two reasons that pull opposite
207
+ # ways: a fixed past date rots into a false REFUSE, and a far-future one is refused outright —
208
+ # R5 caps a lease at 365 days ("an unbounded expiry is a transfer, not a lease"), and the first
209
+ # version of this fixture used 2099-01-01 and was correctly rejected by the very floor the
210
+ # feature depends on. GNU-first with validation, same discipline as every _mtime helper here:
211
+ # `date -d` is GNU, `date -v` is BSD, and neither failing silently is acceptable.
212
+ _g="$(date +%Y-%m-%d)"
213
+ _e="$(date -d '+300 days' +%Y-%m-%d 2>/dev/null || date -v+300d +%Y-%m-%d 2>/dev/null || echo '')"
214
+ case "$_e" in
215
+ [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) : ;;
216
+ *) bad "lane10 fixture: could not compute a lease expiry (neither GNU nor BSD date worked)" "$_e"; _e="$_g" ;;
217
+ esac
218
+ cat > tracks/_meta/user_adaptation_profile.md <<UAPEOF
219
+ ---
220
+ standing_consent:
221
+ repo-freshness-autopull:
222
+ granted: $_g
223
+ expires: $_e
224
+ owner: scripts/fh_node_check.sh
225
+ mode: ff-only-merge-on-default-branch
226
+ target: this repo's own default branch, in the local clone, when it is already checked out
227
+ effects: [network, repo-mutation]
228
+ sinks: []
229
+ ---
230
+ lane fixture — not a real profile
231
+ UAPEOF
232
+ echo base > f.txt; git add -A >/dev/null; git commit -qm base
233
+ cd ..; git clone -q up dn; cd up; echo newer > f.txt; git commit -qam ahead
234
+ ) >/dev/null 2>&1
235
+ _dn="$_ap_root/dn"
236
+ if [ ! -d "$_dn/.git" ] || [ ! -f "$_dn/tracks/_meta/consent_classes.yaml" ]; then
237
+ bad "lane10 fixture: could not build the consent fixture (registry/UAP absent — lanes not run)" "$_ap_root"
238
+ else
239
+ git -C "$_dn" config user.email t@t >/dev/null 2>&1; git -C "$_dn" config user.name t >/dev/null 2>&1
240
+ # Derive rather than assume — belt-and-braces over the pin above, so a future git
241
+ # whose init behaviour changes again cannot make these lanes vacuous a second time.
242
+ _DEF="$(git -C "$_dn" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')"
243
+ [ -n "$_DEF" ] || _DEF=main
244
+ if ! git -C "$_dn" show-ref --verify --quiet "refs/heads/$_DEF"; then
245
+ bad "lane10 fixture: default branch '$_DEF' has no local ref — assertions below would be vacuous" "$(git -C "$_dn" branch -a)"
246
+ fi
247
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
248
+ _h0="$(git -C "$_dn" rev-parse HEAD)"
249
+ run "$_dn" "$_ap_root/s_a" >/dev/null 2>&1
250
+ if [ "$(git -C "$_dn" rev-parse HEAD)" != "$_h0" ]; then
251
+ ok "lane10-a APPLY: consented + on default branch → fast-forwarded"
252
+ else
253
+ bad "lane10-a APPLY: consented + on default branch did NOT fast-forward (feature inert — every refuse lane below is then vacuous)" "$_h0"
254
+ fi
255
+
256
+ # b — the envelope. Never applies off the default branch, and NEVER switches branches.
257
+ (cd "$_ap_root/up" && echo newer2 > f.txt && git commit -qam ahead2) >/dev/null 2>&1
258
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
259
+ git -C "$_dn" switch -c feat-x -q >/dev/null 2>&1
260
+ _m0="$(git -C "$_dn" rev-parse "$_DEF")"
261
+ # ⚠️ CHECKING main AND THE BRANCH NAME IS NOT ENOUGH — measured by a revert probe 2026-08-15.
262
+ # With the envelope deliberately broken, `merge --ff-only origin/main` fast-forwards THE BRANCH
263
+ # YOU ARE ON, so feat-x moved while `main` sat still and the branch name never changed. The first
264
+ # version of this lane asserted only those two and stayed green against a defeated envelope —
265
+ # decorative. The tip that must not move is the CURRENT branch's own.
266
+ _f0="$(git -C "$_dn" rev-parse feat-x)"
267
+ run "$_dn" "$_ap_root/s_b" >/dev/null 2>&1
268
+ if [ "$(git -C "$_dn" rev-parse "$_DEF")" = "$_m0" ] \
269
+ && [ "$(git -C "$_dn" rev-parse feat-x)" = "$_f0" ] \
270
+ && [ "$(git -C "$_dn" branch --show-current)" = "feat-x" ]; then
271
+ ok "lane10-b ENVELOPE: off default branch → no apply on EITHER branch, no switch"
272
+ else
273
+ bad "lane10-b ENVELOPE: a branch tip moved off the default branch — the shared-checkout hazard" "main=$_m0->$(git -C "$_dn" rev-parse "$_DEF") feat-x=$_f0->$(git -C "$_dn" rev-parse feat-x) branch=$(git -C "$_dn" branch --show-current)"
274
+ fi
275
+ git -C "$_dn" switch -q "$_DEF" >/dev/null 2>&1
276
+
277
+ # c — no grant. absent ≠ granted.
278
+ cp "$_dn/tracks/_meta/user_adaptation_profile.md" "$_ap_root/uap.bak"
279
+ python3 - "$_dn/tracks/_meta/user_adaptation_profile.md" <<'PYX'
280
+ import re,sys
281
+ p=sys.argv[1]; s=open(p,encoding='utf-8').read()
282
+ open(p,'w',encoding='utf-8').write(re.sub(r'\nstanding_consent:\n(?: .*\n| .*\n)*', '\n', s, count=1))
283
+ PYX
284
+ _h1="$(git -C "$_dn" rev-parse HEAD)"
285
+ run "$_dn" "$_ap_root/s_c" >/dev/null 2>&1
286
+ [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h1" ] \
287
+ && ok "lane10-c NO-GRANT: absent standing_consent → no apply" \
288
+ || bad "lane10-c NO-GRANT: applied without a grant" "$_h1"
289
+
290
+ # d — expired lease. A lease nobody enforces is not a lease.
291
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
292
+ sed -i.bak2 's/expires: [0-9-]*/expires: 2020-01-01/' "$_dn/tracks/_meta/user_adaptation_profile.md"
293
+ _h2="$(git -C "$_dn" rev-parse HEAD)"
294
+ run "$_dn" "$_ap_root/s_d" >/dev/null 2>&1
295
+ [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h2" ] \
296
+ && ok "lane10-d EXPIRED: past-dated lease → no apply" \
297
+ || bad "lane10-d EXPIRED: applied under an expired lease" "$_h2"
298
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
299
+
300
+ # e — divergence. --ff-only must refuse rather than absorb, and local work must survive.
301
+ (cd "$_dn" && echo local-only > mine.txt && git add -A && git commit -qm diverge) >/dev/null 2>&1
302
+ _h3="$(git -C "$_dn" rev-parse HEAD)"
303
+ run "$_dn" "$_ap_root/s_e" >/dev/null 2>&1
304
+ if [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h3" ] && [ -f "$_dn/mine.txt" ]; then
305
+ ok "lane10-e DIVERGED: ff refused, local commit survives"
306
+ else
307
+ bad "lane10-e DIVERGED: local history was moved or lost" "$_h3 -> $(git -C "$_dn" rev-parse HEAD)"
308
+ fi
309
+
310
+ # f — WRONG CLASS. The refusal that the other three arms structurally cannot produce.
311
+ # Arms c/d/e all refuse by breaking the FILE-WIDE verdict (grant block deleted → exit 3; lease
312
+ # past-dated → exit 1; divergence → git itself refuses). None of them touches the question "is
313
+ # THIS class granted", so a caller keyed on the file-wide 0 passed them all — measured 2026-08-15
314
+ # by a pre-publish security pass, with a live control: one unrelated class validly granted plus
315
+ # the target class named only in prose, and the merge ran while the banner claimed a standing
316
+ # consent that never existed. The revoke path was the broken one.
317
+ # The second assertion below is the load-bearing half: this arm must refuse WHILE the bare
318
+ # file-wide check still returns 0. Without it the lane would pass for the same reason lane10-c
319
+ # does, and the axis would go unexercised again.
320
+ # ORDER MATTERS, and getting it wrong is silent: the UAP is a TRACKED file in this fixture, so
321
+ # `git reset --hard` reverts any edit made to it. The first draft transformed the UAP and then
322
+ # reset — restoring the original grant, so the apply fired legitimately and the lane failed for a
323
+ # reason that looked like the defect. Reset and advance origin FIRST, edit the UAP AFTER.
324
+ git -C "$_dn" reset -q --hard "origin/$_DEF" >/dev/null 2>&1 # noqa: destructive-op (throwaway fixture)
325
+ (cd "$_ap_root/up" && echo newer-f > f.txt && git commit -qam ahead-f) >/dev/null 2>&1
326
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
327
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
328
+ python3 - "$_dn/tracks/_meta/user_adaptation_profile.md" <<'PYX'
329
+ import sys, re as _re
330
+ p = sys.argv[1]; s = open(p, encoding='utf-8').read()
331
+ old = " repo-freshness-autopull:"
332
+ assert old in s, "lane10-f fixture: grant key not found"
333
+ # Reuse the lease dates the fixture already computed, so this arm cannot fail for a date reason and
334
+ # be mistaken for a class-join refusal. (First draft omitted them: R5 fired, the file-wide verdict
335
+ # went to 1, and the arm's own vacuity guard caught it — which is what that guard is for.)
336
+ _g = _re.search(r"granted: (\S+)", s).group(1)
337
+ _e = _re.search(r"expires: (\S+)", s).group(1)
338
+ # A VALID grant for a different registered, promotion_eligible class, scope-matched to its
339
+ # registration so R6/R7 pass — the point is a file that is entirely well-formed.
340
+ other = (" dispatch-readonly-sim-local-artifact:\n"
341
+ f" granted: {_g}\n"
342
+ f" expires: {_e}\n"
343
+ " owner: fh-meta:sim-conductor\n"
344
+ " mode: blind-target-tier-sim\n"
345
+ " target: a single local file under the current repo\n"
346
+ " effects: [read, dispatch]\n"
347
+ " sinks: []\n")
348
+ s = s.replace(old, " __TARGET_GRANT__:", 1)
349
+ head, sep, tail = s.partition("standing_consent:\n")
350
+ s = head + sep + other + tail
351
+ # the target class survives ONLY as prose — the shape the old raw grep could not tell from a grant
352
+ s = s.replace("lane fixture — not a real profile",
353
+ "lane fixture — not a real profile\n\n repo-freshness-autopull: 철회함 (revoked)")
354
+ import re
355
+ s = re.sub(r" __TARGET_GRANT__:\n(?: .*\n)*", "", s, count=1)
356
+ open(p, 'w', encoding='utf-8').write(s)
357
+ PYX
358
+ # PRECONDITION, asserted rather than assumed. lane10-e leaves the clone DIVERGED, and a diverged
359
+ # clone refuses the fast-forward for a reason that has nothing to do with consent — so without the
360
+ # reset above the arm passes whether the class join works or not. Measured: a revert probe against
361
+ # the repaired fh_node_check.sh left this lane green, i.e. it was decorative. Same shape as the
362
+ # self-exclusion lane earlier the same day; the probe is what separates the two cases.
363
+ _h5="$(git -C "$_dn" rev-parse HEAD)"
364
+ if [ "$_h5" = "$(git -C "$_dn" rev-parse "origin/$_DEF")" ]; then
365
+ bad "lane10-f VACUOUS: clone is not behind origin, so 'no apply' proves nothing" "$_h5"
366
+ fi
367
+ run "$_dn" "$_ap_root/s_f" >/dev/null 2>&1
368
+ _filewide=0
369
+ bash "$FH_REPO/scripts/consent_registry_check.sh" \
370
+ "$_dn/tracks/_meta/consent_classes.yaml" \
371
+ "$_dn/tracks/_meta/user_adaptation_profile.md" >/dev/null 2>&1 || _filewide=$?
372
+ if [ "$(git -C "$_dn" rev-parse HEAD)" = "$_h5" ] && [ "$_filewide" = "0" ]; then
373
+ ok "lane10-f WRONG-CLASS: another class granted, this one only in prose → no apply (file-wide check still 0)"
374
+ elif [ "$_filewide" != "0" ]; then
375
+ bad "lane10-f VACUOUS: the fixture broke the file-wide verdict (rc=$_filewide), so it re-tests lane10-c's axis, not the class join" "rebuild the fixture so the bare check returns 0"
376
+ else
377
+ bad "lane10-f WRONG-CLASS: applied on a class that was never granted" "$_h5 -> $(git -C "$_dn" rev-parse HEAD)"
378
+ fi
379
+ cp "$_ap_root/uap.bak" "$_dn/tracks/_meta/user_adaptation_profile.md"
380
+
381
+ # CONTROL — after all the refuse arms, prove the apply arm still fires. Without this, a lane
382
+ # suite where the feature has gone inert reports 4 clean refusals and looks perfect.
383
+ git -C "$_dn" reset -q --hard "origin/$_DEF" >/dev/null 2>&1 # noqa: destructive-op (throwaway fixture)
384
+ (cd "$_ap_root/up" && echo newer3 > f.txt && git commit -qam ahead3) >/dev/null 2>&1
385
+ git -C "$_dn" fetch -q origin >/dev/null 2>&1
386
+ _h4="$(git -C "$_dn" rev-parse HEAD)"
387
+ run "$_dn" "$_ap_root/s_f" >/dev/null 2>&1
388
+ [ "$(git -C "$_dn" rev-parse HEAD)" != "$_h4" ] \
389
+ && ok "lane10-CONTROL: apply arm still fires after the refuse arms (instrument alive)" \
390
+ || bad "lane10-CONTROL: apply arm went inert — the refuse lanes above prove nothing" "$_h4"
391
+ fi
392
+ rm -rf "$_ap_root"
393
+
177
394
  printf '\nnode-check lanes: %d passed, %d failed\n' "$PASS" "$FAIL"
178
395
  [ "$FAIL" -eq 0 ] || exit 1
179
396
  exit 0