@chrono-meta/fh-gate 1.4.96 → 1.4.97

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.
@@ -21,28 +21,6 @@ set -uo pipefail
21
21
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
22
  cd "$REPO_ROOT" || exit 1
23
23
 
24
- # Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
25
- # so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
26
- # silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
27
- # reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
28
- # was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
29
- # (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
30
- # skips. Anchored by scripts/test_package_coverage_lanes.sh.
31
- if [ ! -e .git ]; then
32
- echo "SKIP package-coverage (not a source checkout)"
33
- exit 0
34
- fi
35
- # PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
36
- # together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
37
- # and cannot read what ships" reported as "nothing to check here". Absence of the input is not
38
- # absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
39
- # case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
40
- if [ ! -f package.json ]; then
41
- echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
42
- echo " unreadable, so coverage is UNMEASURED, not clean"
43
- exit 1
44
- fi
45
-
46
24
  # ── Accepted-absent, with the reason each one is NOT a defect ────────────────────────────────
47
25
  # Adding a line here is a decision, not a silencer: each entry states why shipping it would be
48
26
  # wrong. If you cannot write that sentence, the file probably belongs in files[].
@@ -79,6 +57,45 @@ ACCEPTED_ABSENT=(
79
57
  # anchor but guards on the subject's presence, so package mode SKIPs rather than falling through.
80
58
  "scripts/sync-from-be.sh"
81
59
  "scripts/sync_from_be_lanes.sh"
60
+ # ── The three lane suites selfcheck.sh's DEBT-12 pair-loop names but does not ship ────────────
61
+ # Added 2026-08-13, and the way they got here is the point: this check CAUGHT them. Before that
62
+ # loop existed, these names lived in lane_runner_check.sh's DEBT array as bare basenames
63
+ # ("test_chamber_run_lanes.sh"), which the extractor's `scripts/…\.(sh|py)` pattern does not
64
+ # match. Writing the same names as full paths in a SHIPPED file (selfcheck.sh) is what turned
65
+ # them into phantoms — a shipped document pointing at a file the package omits. The source-tree
66
+ # selfcheck went red on the first full run after the wiring, exactly as an adversarial review had
67
+ # predicted from static reading alone. Recorded here rather than "fixed" by dropping the prefix,
68
+ # because the prefix is what makes lane_runner_check.sh's direct-invocation detector see the
69
+ # wiring at all; removing it would trade a loud failure for a silent blind spot.
70
+ #
71
+ # Why each is legitimately unshipped — and one of them is a NAMED RESIDUAL, not a clean answer:
72
+ # · test_frontier_digest_retry.sh — its subject, scripts/frontier_digest_daily.sh, is itself
73
+ # ACCEPTED_ABSENT (a launchd cadence runner for this operator's machine). Anchor follows
74
+ # subject; a consumer has nothing for it to measure.
75
+ # · test_residency_closure_lanes.sh — same shape: residency_closure_scan.py does not ship, so
76
+ # its calibration has no subject on a consumer machine.
77
+ # · test_chamber_run_lanes.sh — 🟥 NOT the same shape. Its subject scripts/chamber_run.sh DOES
78
+ # ship. So a consumer receives the chamber runner and no calibration for it: a shipped gate
79
+ # whose known-pair cannot execute on the machine that runs the gate. That is the defect class
80
+ # this whole campaign is closing, one layer over. It is entered here rather than shipped
81
+ # because adding an anchor to files[] is a consumer-facing change that needs its own
82
+ # tarball-mode run to prove it does not false-FAIL, and this delta is a WIRING delta. The
83
+ # honest state is "declared, and the declaration records a debt" — carried to the card.
84
+ "scripts/test_chamber_run_lanes.sh"
85
+ "scripts/test_frontier_digest_retry.sh"
86
+ "scripts/residency_closure_scan.py"
87
+ "scripts/test_residency_closure_lanes.sh"
88
+ # ── The two settings destinations, surfaced 2026-08-13 by fixing this file's own extractor ────
89
+ # These were invisible until the `js|json` alternation below was corrected: every `.json`
90
+ # reference in every shipped doc was being truncated to `.js`, a path that exists nowhere, so
91
+ # `os.path.exists()` dropped it in silence. `.claude/settings.json` alone is named by TWENTY-SIX
92
+ # shipped documents and had never once been examined by the check that exists to examine exactly
93
+ # this. Both are INSTALL DESTINATIONS the consumer owns — the whole point of the docs that name
94
+ # them is "put this in YOUR settings" — so shipping either would overwrite the reader's own
95
+ # configuration with this harness's. Same reasoning as .claude/rules/local_fh_context.md above:
96
+ # the template is what ships, the destination is what the user creates.
97
+ ".claude/settings.json"
98
+ ".claude/settings.local.json"
82
99
  # Its only input is `.claude/regression/probes.md`, itself ACCEPTED_ABSENT above (a consumer's
83
100
  # regression run must not compare against this harness's probe set). Shipping the reader without
84
101
  # its corpus would put a script in the package that can only ever report "instrument error".
@@ -99,10 +116,13 @@ ACCEPTED_ABSENT=(
99
116
  # selfcheck goes red on a subject they do not have.
100
117
  "scripts/test_probe_scope_lanes.sh"
101
118
  # Measures what the LIVE `claude` CLI does with several SessionStart hooks on one matcher —
102
- # so every run needs the CLI, auth, and spends tokens on the consumer's account. Same reason
103
- # `ablation_calibrate.sh` is absent: shipping a script whose only effect on a consumer's
104
- # machine is a bill is worse than omitting it. selfcheck reports it NOT EXERCISED (exit 2)
105
- # where the CLI is missing, so the package stays green without pretending the lanes ran.
119
+ # so every run needs the CLI, auth, and spends tokens on the consumer's account. This anchor DOES
120
+ # get a live run attempt when its subject ships and the file itself is present (selfcheck reports
121
+ # NOT EXERCISED, exit 2, when that run finds no CLI). This entry covers the OTHER case: the anchor
122
+ # FILE ITSELF is not shipped, so selfcheck never gets far enough to attempt the run at all — that
123
+ # case renders SKIP, not NOT EXERCISED (the two are for missing-file vs. present-file-no-CLI,
124
+ # not interchangeable; corrected 2026-08-12, cross-family review — the two exit paths were
125
+ # conflated in an earlier revision of this comment).
106
126
  "scripts/test_sessionstart_multihook_lanes.sh"
107
127
  # The launchd-driven daily cadence runner. Two independent reasons it must not ship: it is half of
108
128
  # a pair whose other half is a machine-local plist (`scripts/com.forge-harness.frontier-digest.plist`),
@@ -114,28 +134,142 @@ ACCEPTED_ABSENT=(
114
134
  "scripts/frontier_digest_daily.sh"
115
135
  )
116
136
 
117
- out=$(python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
118
- import re, os, json, sys
137
+ # `--list-accepted`: print the ACCEPTED_ABSENT paths, one per line, and exit — no git/package.json
138
+ # dependency, deliberately callable from a context this script's own coverage scan cannot run in
139
+ # (package mode, or a vendored tree with `.git` present but no `package.json` at this root). This is
140
+ # the single declared source of "known-legitimately-unshipped" for OTHER checks to consult instead of
141
+ # re-deriving the same judgment from an environment predicate (`.git` presence, directory existence)
142
+ # that answers a different question and can diverge from this list's actual coverage. Added
143
+ # 2026-08-12 (reship axis, cross-family review of card §🔱⑮ G/D) after selfcheck.sh's ref-path block
144
+ # was found re-deriving "is this legitimately absent" from `[ -e .git ]` — which reproduces the
145
+ # original bug in any git-tracked tree that vendors this package (a monorepo committing
146
+ # node_modules, or a consumer who runs `git init` after install): `.git` exists there, so the old
147
+ # predicate ran the check, found these exact paths missing, and FAILed — the same false-FAIL this
148
+ # array already declares correct to omit.
149
+ if [ "${1:-}" = "--list-accepted" ]; then
150
+ printf '%s\n' "${ACCEPTED_ABSENT[@]}"
151
+ exit 0
152
+ fi
153
+
154
+ # `--vs-tarball` swaps the coverage oracle from package.json files[] (a DECLARATION about the
155
+ # tarball) to `npm pack --dry-run --json` (the tarball). See the two-oracle comment in the python
156
+ # block. Kept as a flag rather than made the default for one reason: the default runs anywhere,
157
+ # offline, in ~1s, while this one shells out to npm and takes seconds — so the strict oracle belongs
158
+ # on the publish path, where the question "what does the consumer actually receive" is the one being
159
+ # asked, and the cheap one stays on every commit.
160
+ if [ "${1:-}" = "--vs-tarball" ]; then
161
+ export FH_PKG_ORACLE=tarball
162
+ shift
163
+ fi
164
+
165
+ # Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
166
+ # so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
167
+ # silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
168
+ # reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
169
+ # was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
170
+ # (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
171
+ # skips. Anchored by scripts/test_package_coverage_lanes.sh.
172
+ if [ ! -e .git ]; then
173
+ echo "SKIP package-coverage (not a source checkout)"
174
+ exit 0
175
+ fi
176
+ # PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
177
+ # together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
178
+ # and cannot read what ships" reported as "nothing to check here". Absence of the input is not
179
+ # absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
180
+ # case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
181
+ if [ ! -f package.json ]; then
182
+ echo "FAIL package-coverage: source checkout with no package.json — the shipped file list is"
183
+ echo " unreadable, so coverage is UNMEASURED, not clean"
184
+ exit 1
185
+ fi
186
+
187
+ out=$(FH_PKG_ORACLE="${FH_PKG_ORACLE:-declaration}" python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
188
+ import re, os, json, sys, subprocess
119
189
  accepted = set(sys.argv[1:])
120
190
  files = json.load(open('package.json'))['files']
121
191
 
192
+ # ── TWO ORACLES, and the difference between them is the whole reason the second one exists ────
193
+ # `declaration` (default): a path is covered if package.json files[] says so. That is a CLAIM about
194
+ # the tarball, checkable without npm, and it is what every caller before 2026-08-13 used.
195
+ # `tarball`: a path is covered if it is actually in `npm pack --dry-run --json`. That is the tarball
196
+ # ITSELF.
197
+ # They come apart, and this repo has measured them coming apart (card §🔱⑮ G/C: repo ✅ / files[] ❌
198
+ # for two paths). A files[] entry can name a file that does not pack — .npmignore precedence, a
199
+ # pattern that no longer matches, a file deleted while its declaration stayed. In every one of those
200
+ # the declaration oracle says PASS and the consumer gets a broken reference, because the consumer
201
+ # receives the tarball and not the manifest. This is the general solution the campaign card has
202
+ # carried as open under "참조 ↔ 실제 출하 파일셋": the earlier repairs (A–D) routed AROUND it by
203
+ # consulting declarations, which was correct for those cases and is not the same thing as building
204
+ # it.
205
+ # FAIL-CLOSED, and deliberately not "fall back to the declaration": npm missing, a non-zero exit, or
206
+ # JSON that does not parse means the tarball is UNKNOWN, and a lenient fallback would silently
207
+ # convert the stricter oracle back into the weaker one — while still printing the stricter one's
208
+ # name. That is the "미측정을 통과로 렌더" class this whole file is an instrument against.
209
+ ORACLE = os.environ.get('FH_PKG_ORACLE', 'declaration')
210
+ packed = None
211
+ if ORACLE == 'tarball':
212
+ try:
213
+ r = subprocess.run(['npm', 'pack', '--dry-run', '--json'],
214
+ capture_output=True, text=True, timeout=180)
215
+ if r.returncode != 0:
216
+ print(f"ORACLE_UNAVAILABLE\tnpm pack exited {r.returncode}")
217
+ raise SystemExit(2)
218
+ packed = {f['path'] for f in json.loads(r.stdout)[0]['files']}
219
+ except FileNotFoundError:
220
+ print("ORACLE_UNAVAILABLE\tnpm is not on PATH — the tarball cannot be read")
221
+ raise SystemExit(2)
222
+ except (json.JSONDecodeError, KeyError, IndexError) as e:
223
+ print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__})")
224
+ raise SystemExit(2)
225
+ except subprocess.TimeoutExpired:
226
+ print("ORACLE_UNAVAILABLE\tnpm pack timed out")
227
+ raise SystemExit(2)
228
+ # An empty or absurdly small packed set means the instrument broke, not that the package is
229
+ # empty — same impossible-zero rule the shipped-doc guard below applies to its own extractor.
230
+ if not packed or len(packed) < 10:
231
+ print(f"ORACLE_UNAVAILABLE\tnpm pack reported {len(packed or [])} files — implausible")
232
+ raise SystemExit(2)
233
+
122
234
  def covered(p):
235
+ if packed is not None:
236
+ return p in packed
123
237
  return any(p == f or p.startswith(f.rstrip('/') + '/') for f in files)
124
238
 
125
239
  shipped = []
126
- for f in files:
127
- if os.path.isfile(f):
128
- shipped.append(f)
129
- elif os.path.isdir(f):
130
- for root, _, names in os.walk(f):
131
- shipped.extend(os.path.join(root, n) for n in names)
240
+ if packed is not None:
241
+ # In tarball mode the set of SCANNED documents is the tarball too, not the declaration. Both
242
+ # halves have to move together: scanning a doc that does not actually ship would report a
243
+ # phantom no consumer can encounter, and that false positive is what would get the stricter
244
+ # oracle switched back off.
245
+ shipped = sorted(packed)
246
+ else:
247
+ for f in files:
248
+ if os.path.isfile(f):
249
+ shipped.append(f)
250
+ elif os.path.isdir(f):
251
+ for root, _, names in os.walk(f):
252
+ shipped.extend(os.path.join(root, n) for n in names)
132
253
 
133
254
  # Only text surfaces can carry a reference a human or agent would follow.
134
255
  shipped = [s for s in shipped if s.endswith(('.md', '.sh', '.js', '.json', '.yaml', '.yml'))]
135
256
 
136
257
  pat = re.compile(
137
258
  r'(?<![\w/.-])((?:scripts|templates|bin|docs|knowledge|plugins|\.claude)'
138
- r'/[A-Za-z0-9_./-]+\.(?:sh|py|js|md|yaml|yml|json|defaults))'
259
+ # LONGEST-FIRST, and the order is the whole fix. Python's `|` is leftmost-first, not
260
+ # longest-match, so the previous order `sh|py|js|md|yaml|yml|json|defaults` matched `.js`
261
+ # inside `.json` and truncated every JSON reference: `templates/settings.json` was extracted as
262
+ # `templates/settings.js`, a path that exists nowhere, so `os.path.exists()` said False and the
263
+ # reference was dropped in silence. This check has therefore NEVER examined a shipped doc's
264
+ # JSON references. Same trap in `yml|yaml` (harmless by luck: neither is a prefix of the other)
265
+ # and it would bite again for any future pair like `md`/`mdx`.
266
+ # Known-pair, run before the fix: `templates/settings.json` → `templates/settings.js` (broken)
267
+ # while `scripts/foo.sh` → `scripts/foo.sh` (control, intact). After: both intact.
268
+ # The `(?![A-Za-z0-9])` tail is the belt to the longest-first braces: it stops a correct
269
+ # alternative from matching a PREFIX of a longer real extension that nobody listed yet, so the
270
+ # next person who adds an extension cannot silently reintroduce this by putting it in the wrong
271
+ # place. Ordering alone is a convention; the lookahead is the mechanism.
272
+ r'/[A-Za-z0-9_./-]+\.(?:defaults|json|yaml|yml|sh|py|js|md)(?![A-Za-z0-9]))'
139
273
  )
140
274
 
141
275
  phantom = {}
@@ -192,6 +326,19 @@ PY
192
326
  )
193
327
  rc=$?
194
328
 
329
+ case "$out" in
330
+ ORACLE_UNAVAILABLE*)
331
+ # Only reachable with --vs-tarball. The stricter oracle could not be read, and the ONLY wrong
332
+ # answer here is to quietly re-run with the weaker one and print a pass — the caller asked
333
+ # "what does the consumer actually receive", and "I could not look" is not an answer to that.
334
+ echo "FAIL package-coverage (--vs-tarball): the tarball oracle is UNAVAILABLE, so coverage"
335
+ echo " against the real packed file set is UNMEASURED — not clean."
336
+ printf '%s\n' "$out" | sed 's/^ORACLE_UNAVAILABLE\t/ reason: /'
337
+ echo " Re-run without --vs-tarball to check against package.json files[] instead, but note"
338
+ echo " that is a DIFFERENT and weaker question (a declaration, not the tarball)."
339
+ exit 2 ;;
340
+ esac
341
+
195
342
  if [ "$rc" -eq 2 ] || [ "$out" = "EXTRACTOR_BROKE" ]; then
196
343
  echo "FAIL package-coverage: extractor scanned 0 shipped docs — the check broke, it did not pass"
197
344
  exit 1
@@ -239,3 +239,167 @@ $PSA_STREAM
239
239
  PSA_PAT
240
240
  return $hit
241
241
  }
242
+
243
+ # _psa_can_assign <name> <value> — 0 = "this exact assignment will succeed", 1 = it will not.
244
+ #
245
+ # Rounds 4→7 walked this from a declaration parse to a proxy probe to the real thing:
246
+ # R4 parsed `readonly -p` for `NAME=` — missed `readonly FOO` (prints `declare -r FOO`, no `=`)
247
+ # and false-positived on an unrelated readonly whose VALUE contained the string.
248
+ # R5 replaced it with a behavioural probe — but the probe assigned the variable's OWN CURRENT
249
+ # VALUE, while the caller then assigns something else.
250
+ # R7 found the gap that leaves: `declare -i PSA_ALLOWLIST` makes the self-assignment succeed and
251
+ # `PSA_ALLOWLIST=/dev/null` fail. Measured — probe=writable, real=FAIL. The caller then died
252
+ # mid-run, and on the psa_scan_file path a hit that psa_scan_tagged WOULD have reported was
253
+ # lost with it.
254
+ # So the probe now takes the value: it tests the operation that is actually about to happen, in a
255
+ # subshell where failure cannot reach the caller. Every earlier version tested a stand-in for it.
256
+ _psa_can_assign() {
257
+ case "$1" in
258
+ ''|*[!A-Za-z0-9_]*|[0-9]*)
259
+ echo " ❌ _psa_can_assign: refusing non-identifier argument" >&2
260
+ return 1 ;; # cannot-assign → the caller refuses to mutate. Degrades away from mutating.
261
+ esac
262
+ ( eval "$1=\"\$2\"" ) 2>/dev/null
263
+ }
264
+
265
+ # psa_require_live — prove the scanner actually RUNS before a 0 from it is allowed to mean "clean".
266
+ # Returns 0 = alive, 1 = dead (and says so on stderr).
267
+ #
268
+ # Why this exists (measured 2026-08-12): a caller hand-built the path-tagged stream and piped it into
269
+ # psa_scan_tagged from a shell whose PATH lacked `cat`. The function died on its first line, printed
270
+ # `command not found` into the captured output, and returned 0 — so the caller's known-positive
271
+ # (a file dense with operator literals) reported **0 hits**, indistinguishable from clean. The control
272
+ # is what caught it: the run was only recognisable as dead because a file that MUST hit did not.
273
+ # This function makes that control intrinsic instead of remembered — it does not check that binaries
274
+ # exist (a presence check is the weaker instrument this repo keeps re-learning), it checks that a
275
+ # synthetic token which MUST be reported IS reported, end to end through the real matcher.
276
+ #
277
+ # Cross-family round 1 (2026-08-12) refuted three things about the first draft; all three are fixed
278
+ # here and named so the next reader does not re-introduce them:
279
+ # · it exercised only the stdin path, so a missing `awk` — the binary the file path actually needs —
280
+ # was outside what "alive" certified. The self-test now goes through the SAME tagging path
281
+ # `psa_scan_file` uses, from a real temp file.
282
+ # · the canary was allowlistable: a `psa/selftest<TAB>PSA_SELFTEST_CANARY` row would mute it and a
283
+ # healthy scanner would report itself dead. The self-test now runs with the allowlist disabled.
284
+ # · it was not `errexit`-safe: under `set -e` the nonzero return from the hit-reporting path exited
285
+ # the caller's shell before the restore line. Status capture is now inside an `if`.
286
+ psa_require_live() {
287
+ local saved_stream saved_allow saved_stream_set saved_allow_set tmpd out rc _canary
288
+ saved_stream="${PSA_STREAM-}"; saved_allow="${PSA_ALLOWLIST-}"
289
+ saved_stream_set=""; saved_allow_set=""
290
+ [ "${PSA_STREAM+x}" = "x" ] && saved_stream_set=1
291
+ [ "${PSA_ALLOWLIST+x}" = "x" ] && saved_allow_set=1
292
+ tmpd=$(mktemp -d 2>/dev/null) || { echo " ❌ INSTRUMENT DEAD — mktemp unavailable." >&2; return 1; }
293
+ # Round 4 refuted the round-3 fix: `|| :` does NOT rescue an assignment to a readonly variable —
294
+ # bash aborts the shell before the `||` is ever considered. So the only safe move is to REFUSE
295
+ # before mutating anything. Detected, reported as DEAD, and the caller survives.
296
+ _canary=$(printf 'HIGH\tPSA_SELFTEST_CANARY')
297
+ if ! _psa_can_assign PSA_STREAM "$_canary" || ! _psa_can_assign PSA_ALLOWLIST /dev/null; then
298
+ echo " ❌ INSTRUMENT DEAD — PSA_STREAM/PSA_ALLOWLIST cannot take the values the self-test needs." >&2
299
+ echo " (readonly, or an attribute such as \`declare -i\` that rejects the value)" >&2
300
+ echo " Not attempting it: a failed assignment aborts an errexit caller outright." >&2
301
+ rm -rf "$tmpd" 2>/dev/null || :
302
+ return 1
303
+ fi
304
+ PSA_STREAM="$_canary"
305
+ PSA_ALLOWLIST=/dev/null
306
+ printf 'PSA_SELFTEST_CANARY\n' > "$tmpd/canary" 2>/dev/null
307
+ # Same pipeline shape as psa_scan_file: awk tags the file, psa_scan_tagged matches it.
308
+ if out=$(awk -v P="psa/selftest" '{print P "\t" $0}' "$tmpd/canary" 2>&1 | psa_scan_tagged 2>&1); then
309
+ rc=0
310
+ else
311
+ rc=$?
312
+ fi
313
+ rm -rf "$tmpd" 2>/dev/null || :
314
+ # Restore EXACTLY, including the difference between unset and set-empty (round 2): the first draft
315
+ # always re-set PSA_STREAM, turning an unset variable into an empty one — which the very guard in
316
+ # psa_scan_file below keys on. A restore that changes state is not a restore.
317
+ if [ -n "${saved_stream_set:-}" ]; then PSA_STREAM="$saved_stream"; else unset PSA_STREAM; fi
318
+ if [ -n "${saved_allow_set:-}" ]; then PSA_ALLOWLIST="$saved_allow"; else unset PSA_ALLOWLIST; fi
319
+ case "$out" in
320
+ *PSA_SELFTEST_CANARY*) [ "$rc" -eq 1 ] && return 0 ;;
321
+ esac
322
+ echo " ❌ INSTRUMENT DEAD — the scanner did not report a synthetic known-positive." >&2
323
+ echo " self-test rc=$rc out=[$out]" >&2
324
+ echo " A 0 from this scanner is UNMEASURED, not clean — do not report or publish a count from it." >&2
325
+ return 1
326
+ }
327
+
328
+ # psa_scan_file <path> — scan ONE file through the shared matcher.
329
+ #
330
+ # Three-valued on purpose: 0 = scanned, nothing reportable · 1 = scanned, hit(s) reported ·
331
+ # 3 = NOT SCANNED (instrument dead, bad usage, or missing file). Folding 3 into 0 is the exact defect
332
+ # this repo keeps meeting — `not found` is not `0`, and a file that does not exist is not an empty file.
333
+ # Callers that only branch on `if psa_scan_file f; then clean; fi` would read 3 as dirty (safe) and
334
+ # never as clean; callers that check `-eq 0` must treat 3 as unmeasured.
335
+ #
336
+ # ⚠️ The caller still owns the DISPLAY. A hit line (❌) and an allowlisted line (⚪) are different
337
+ # outcomes, and a display filter that greps only ❌ renders "allowlisted" as "no match" — measured
338
+ # on 2026-08-12, which is how an existing operator allowlist decision was misread as an absent
339
+ # pattern. If you filter this function's output, keep both markers or state which you dropped.
340
+ psa_scan_file() {
341
+ local out rc
342
+ if [ "$#" -ne 1 ]; then
343
+ echo " ⚠️ USAGE — psa_scan_file <path> (got $# argument(s)); NOT SCANNED" >&2
344
+ return 3
345
+ fi
346
+ # Patterns-not-loaded is the highest-value guard here, and it was missing from the first draft.
347
+ # Cross-family round 1 found it by simply following THIS FILE'S OWN advertised usage line, which
348
+ # said `. psa_scan_lib.sh && psa_scan_file <path>` and omitted psa_load — reproduced: a file dense
349
+ # with a known-positive token returned rc=0 with empty output. An empty pattern stream scans
350
+ # everything and reports nothing, which is the false-clean this whole change exists to remove.
351
+ if [ -z "${PSA_STREAM:-}" ]; then
352
+ echo " ⚠️ PATTERNS NOT LOADED — call psa_load <defaults> <override> first; NOT SCANNED" >&2
353
+ echo " (an empty pattern stream reports nothing, which is indistinguishable from clean)" >&2
354
+ return 3
355
+ fi
356
+ # A NON-EMPTY stream is not a COMPLETE one (round 2). If the committed defaults failed to load, or
357
+ # rows were dropped as unusable, the stream still matches *something* and a clean verdict from it
358
+ # is a verdict about a partial instrument.
359
+ #
360
+ # `case`, not `[ -ne ]` (round 3): a non-numeric value made `[` return 2, the `if` read that as
361
+ # false, and the guard fell through — reproduced with PSA_DEFAULTS_OK=x, which scanned a file to
362
+ # rc=0 CLEAN using a stream that did not contain the token. A guard that a garbage value walks
363
+ # straight past is not a guard, and the shell's own error line scrolled by unnoticed.
364
+ local _incomplete=""
365
+ case "${PSA_DEFAULTS_OK:-}" in 1) ;; *) _incomplete="defaults_ok=${PSA_DEFAULTS_OK:-unset}" ;; esac
366
+ case "${PSA_BAD_ROWS:-0}" in 0) ;; *) _incomplete="${_incomplete:+$_incomplete }bad_rows=${PSA_BAD_ROWS}" ;; esac
367
+ # The operator override carries the HIGH company/companion literals. Its absence is survivable for a
368
+ # single-file query (the committed defaults still apply) but the caller must never read the result as
369
+ # a full clean — so it is announced, not silently folded in.
370
+ # Round 4: this used to WARN and still return 0. That is the exact false-clean this change exists to
371
+ # remove — the override carries the HIGH company/companion literals, so without it the scan did not
372
+ # look at the highest-severity class at all, and "clean" is a claim the run cannot support. It now
373
+ # joins _incomplete. (No existing caller breaks: psa_scan_file is new in this change.)
374
+ case "${PSA_OVERRIDE_PRESENT:-}" in
375
+ 1) ;;
376
+ *) _incomplete="${_incomplete:+$_incomplete }override_absent(HIGH operator literals unscanned)" ;;
377
+ esac
378
+ psa_require_live || return 3
379
+ if [ ! -f "$1" ]; then
380
+ echo " ⚠️ MISSING — $1 : NOT SCANNED (unmeasured, not 0 hits)" >&2
381
+ return 3
382
+ fi
383
+ # `awk | psa_scan_tagged` hides an awk read failure when the caller has no pipefail: awk fails,
384
+ # psa_scan_tagged sees an empty stream and returns 0 = clean. Materialise the tagged stream first
385
+ # so the read is a checkable step of its own (cross-family round 1).
386
+ local tmpf
387
+ tmpf=$(mktemp 2>/dev/null) || { echo " ⚠️ mktemp failed; NOT SCANNED" >&2; return 3; }
388
+ if ! awk -v P="$1" '{print P "\t" $0}' "$1" > "$tmpf" 2>/dev/null; then
389
+ rm -f "$tmpf" || :
390
+ echo " ⚠️ READ FAILED — $1 could not be tagged for scanning; NOT SCANNED" >&2
391
+ return 3
392
+ fi
393
+ if out=$(psa_scan_tagged < "$tmpf"); then rc=0; else rc=$?; fi
394
+ rm -f "$tmpf" || :
395
+ # Print findings FIRST, verdict second — round 3 caught the previous order suppressing real hits:
396
+ # an incomplete instrument returned 3 and emitted nothing, so a token the loaded patterns DID match
397
+ # was lost. "I could not certify this" and "I saw nothing" are different, and the fix for the second
398
+ # must not create the first. Partial evidence is reported; the verdict still refuses to say clean.
399
+ [ -n "$out" ] && printf '%s\n' "$out"
400
+ if [ -n "$_incomplete" ]; then
401
+ echo " ⚠️ INCOMPLETE PATTERN INSTRUMENT — $_incomplete : verdict is NOT SCANNED (any hits above are partial)" >&2
402
+ return 3
403
+ fi
404
+ return "$rc"
405
+ }
@@ -24,6 +24,36 @@
24
24
 
25
25
  set -uo pipefail
26
26
 
27
+ # ── Misuse fails CLOSED, before the banner ──────────────────────────────────────────────────────
28
+ # This script scans the npm-published file set (derived from `npm pack --dry-run`). It takes NO
29
+ # positional arguments, and until 2026-08-12 it did not parse any either — it silently ignored them
30
+ # and printed its normal green. That turned a misuse into a false certification: a caller who ran
31
+ # bash scripts/public_surface_scan_files.sh <some/path>
32
+ # to ask "is THIS file clean?" got `✅ PASS`, about a file set that never contained <some/path>.
33
+ # Measured known-pair: no args → rc=0 PASS; `/nonexistent/definitely_not_a_file_zzz.md` → byte-identical
34
+ # rc=0 PASS; `PSA_PATTERNS=/nonexistent/nope` → rc=1 (so the green was a live green, not a dead one).
35
+ # The first person to hit it was not the author but the first real user, who nearly recorded
36
+ # "paper/forge_harness_v1.0.html is clean" from a scan that never opened that file.
37
+ # On an irreversible surface (publish) an instrument that cannot answer the question it was asked
38
+ # must refuse, not answer a different question in the affirmative.
39
+ # rc=2 is deliberately distinct from rc=1 (a real leak/incomplete instrument) and rc=0 (clean):
40
+ # "you used it wrong" and "your content is dirty" are different states and must not be collapsed.
41
+ if [ "$#" -gt 0 ]; then
42
+ echo "[Pre-Publish] public-surface scan — ✋ USAGE ERROR: this script takes no arguments (got $#: $*)" >&2
43
+ echo " It scans the npm-published file set only (npm pack --dry-run), never a path you pass in." >&2
44
+ # The psa_load call is NOT optional and is spelled out here on purpose: the first draft of this
45
+ # message omitted it, and cross-family review found that following it verbatim scans a
46
+ # known-positive file to rc=0/clean (empty pattern stream). An instruction that produces a false
47
+ # clean is the same defect as the guard above, one layer out.
48
+ echo " To scan ONE file:" >&2
49
+ echo " . scripts/psa_scan_lib.sh \\" >&2
50
+ echo " && psa_load .claude/rules/.public-surface-patterns.defaults .claude/rules/.public-surface-patterns \\" >&2
51
+ echo " && psa_scan_file <path> # rc: 0 clean · 1 hit(s) · 3 NOT SCANNED" >&2
52
+ echo " To scan tracked files: use the /public-surface-audit skill." >&2
53
+ echo " Refusing rather than reporting a PASS about a different file set (fail-closed)." >&2
54
+ exit 2
55
+ fi
56
+
27
57
  REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
28
58
  PSA_DEFAULTS="$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults"
29
59
  PSA_OVERRIDE="${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"