@chrono-meta/fh-gate 1.4.89 → 1.4.90
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.
- package/.claude/judgment_circuits.txt +14 -0
- package/.claude/rules/fh_4axis_gate.md +7 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +25 -0
- package/CLAUDE.md +179 -12
- package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +165 -0
- package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
- package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
- package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +528 -0
- package/package.json +16 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
- package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
- package/scripts/chamber_run.sh +64 -2
- package/scripts/chamber_witness.sh +439 -0
- package/scripts/compaction_probe.sh +456 -0
- package/scripts/digest_landing_check.sh +385 -0
- package/scripts/directional_diff_gate.sh +459 -0
- package/scripts/judgment_circuit_lint.sh +239 -0
- package/scripts/novelty_claim_check.sh +193 -0
- package/scripts/relay_channel.sh +645 -0
- package/scripts/reviewer_capability_corpus.tsv +124 -0
- package/scripts/selfcheck.sh +66 -0
- package/scripts/test_marker_crossfamily_lanes.sh +132 -0
- package/scripts/test_marker_floor_lanes.sh +9 -8
- package/scripts/test_relay_channel_lanes.sh +583 -0
- package/scripts/test_reviewer_capability_conformance.sh +173 -0
- package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
- package/scripts/utterance_landing_check.sh +209 -0
- package/templates/.git-hooks/pre-commit +253 -13
- package/templates/settings.Compaction.snippet.json +56 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# directional_diff_gate.sh — PRE-push loss gate for FILE-REPLACEMENT pushes.
|
|
3
|
+
# (It said "post-push detector" while it had no callers. Wired into rest_push.sh on
|
|
4
|
+
# 2026-08-09 it runs BEFORE tree/commit/ref, so the name had to stop lying.)
|
|
5
|
+
#
|
|
6
|
+
# WHY (pmh-dev #42, measured 2026-08-08): a push that REPLACES a file with local bytes
|
|
7
|
+
# (REST Contents API, mirror sync, any upload-the-whole-file path) silently drops lines
|
|
8
|
+
# that existed only on the remote. Three defenses were green while the file was damaged:
|
|
9
|
+
#
|
|
10
|
+
# tool warning "remote differs (remote=X local=Y) — overwriting" → says THAT it differs,
|
|
11
|
+
# never WHAT disappears. Reads as normal if you believe local is canonical.
|
|
12
|
+
# additions/deletions total 1 deletion + 91 additions looks like net growth. The
|
|
13
|
+
# previous round passed 141 = 141 with this method — it cannot see DIRECTION.
|
|
14
|
+
# rc rc=0, correctly: the replace succeeded. The defect was in the request.
|
|
15
|
+
#
|
|
16
|
+
# The lost line was a mirror banner ("do not edit here, next sync overwrites"). Losing it
|
|
17
|
+
# does not cost one line — it costs the only marker telling the next session the file is
|
|
18
|
+
# not canonical. Loss classes are silent by nature; that is why this runs as a gate.
|
|
19
|
+
#
|
|
20
|
+
# WHAT THIS ADDS: direction. It does not replace the total-count check; totals stay useful
|
|
21
|
+
# and blind to sign.
|
|
22
|
+
#
|
|
23
|
+
# ── Which deletions are the DEFECT, and which are just editing (measured 2026-08-09) ──
|
|
24
|
+
# The first version blocked on EVERY deletion. Measured against the real remote that day:
|
|
25
|
+
#
|
|
26
|
+
# local file identical to remote → CLEAN rc=0 (correct)
|
|
27
|
+
# one comment line deliberately removed, two lines added → BLOCK rc=1 (over-block)
|
|
28
|
+
#
|
|
29
|
+
# i.e. every routine refactor push goes red, the operator sets DIRECTIONAL_DIFF_ACK by
|
|
30
|
+
# reflex, and the gate is disarmed. That is the #33 lineage this repo has already paid for:
|
|
31
|
+
# a tool that always blocks teaches the bypass. An always-red gate is not a strict gate.
|
|
32
|
+
#
|
|
33
|
+
# The verdict stays what it was: **any line the remote has and the local upload does not**
|
|
34
|
+
# is a loss, and it blocks. That is `R − L` as a MULTISET (`comm` over sorted files), so a
|
|
35
|
+
# remote process appending a SECOND copy of a line the file already had once is caught too;
|
|
36
|
+
# a set-membership test waves that through.
|
|
37
|
+
#
|
|
38
|
+
# ── Why the over-block is answered by the ACK, not by a smarter verdict ─────────
|
|
39
|
+
# A three-way was built and MEASURED on 2026-08-09 and then withdrawn. It took
|
|
40
|
+
# B = merge-base(HEAD, <base_sha>) and blocked only on `(R − B) − (L − B)`, so your own
|
|
41
|
+
# deletions never blocked. It was withdrawn because a cross-family review produced a
|
|
42
|
+
# counterexample the author had not found:
|
|
43
|
+
#
|
|
44
|
+
# HEAD == remote tip ⇒ B == R ⇒ R − B = ∅ ⇒ NOTHING can ever be reported,
|
|
45
|
+
# including a genuine loss when the local file is stale or foreign bytes rather than
|
|
46
|
+
# an edit of HEAD.
|
|
47
|
+
#
|
|
48
|
+
# merge-base proves a common ANCESTOR; it does not prove "the version this upload was
|
|
49
|
+
# derived from". Git cannot tell an edit of HEAD from foreign bytes pasted over it, and a
|
|
50
|
+
# file-replacement push is exactly the surface where the bytes come from somewhere else.
|
|
51
|
+
# So the precise mode needs the caller to DECLARE its edit base, and until something can
|
|
52
|
+
# supply that declaration the gate does not guess. Trading a measured over-block for an
|
|
53
|
+
# unmeasurable fail-open is the wrong direction on a delete-class surface.
|
|
54
|
+
#
|
|
55
|
+
# What answers the over-block instead: the acknowledgement is bound to the exact loss set
|
|
56
|
+
# (see below). You cannot keep one ACK in your shell history and wave every push through —
|
|
57
|
+
# the token changes with the content, so an ACK always costs a LOOK at what is being lost.
|
|
58
|
+
# That is the property the reflex-ACK failure mode actually needs; a looser verdict was
|
|
59
|
+
# never the only way to get it.
|
|
60
|
+
#
|
|
61
|
+
# ── Verdict / exit codes ────────────────────────────────────────────────────────
|
|
62
|
+
# 0 CLEAN no deleted lines (or every deletion acknowledged, see ack below)
|
|
63
|
+
# 1 DELETIONS lines the remote has are missing from the upload, unacknowledged
|
|
64
|
+
# 10 UNDECIDABLE the comparison could not be MADE (decode/API/instrument failure).
|
|
65
|
+
# Deliberately NOT 0. A file-replacement push is a delete-class surface,
|
|
66
|
+
# so an un-runnable check fails CLOSED (§Irreversibility Surface-Class
|
|
67
|
+
# Degrade Invariant). "Could not compare" is not "nothing was lost".
|
|
68
|
+
#
|
|
69
|
+
# ── Acknowledging an intentional deletion ───────────────────────────────────────
|
|
70
|
+
# Removing a remote-only line is sometimes correct (retiring a bot banner). Blocking
|
|
71
|
+
# unconditionally would make this an always-red gate and teach --no-verify — the #33
|
|
72
|
+
# lineage this repo has already paid for. So deletions are ACK-gated, not forbidden:
|
|
73
|
+
#
|
|
74
|
+
# DIRECTIONAL_DIFF_ACK="LOSS-<token> <what is being deleted and why>" …
|
|
75
|
+
#
|
|
76
|
+
# TWO conditions, and the second is the one that matters:
|
|
77
|
+
# · non-vacuous prose (≥20 chars) — the original defect was a warning that announced a
|
|
78
|
+
# difference without naming it; an approval that also declines to say what is the same
|
|
79
|
+
# failure wearing a different hat. "ok" / "1" / "yes" is rejected.
|
|
80
|
+
# · the LOSS-<token> printed by THIS run. The token hashes the repo, the base sha, and
|
|
81
|
+
# every (path, lost line) pair — so an ack is valid for exactly one loss set, on one
|
|
82
|
+
# repo, at one base. It cannot be parked in shell history and reused: the same line
|
|
83
|
+
# lost from a different file is a different token, and so is the same file at a
|
|
84
|
+
# different base. Every acknowledgement therefore costs one LOOK at the list. That is
|
|
85
|
+
# what actually defuses reflex-ACK; a looser verdict never had to.
|
|
86
|
+
# It also ends the blanket ACK — one file's explanation no longer clears a second
|
|
87
|
+
# file's loss, because the token covers the union of both.
|
|
88
|
+
#
|
|
89
|
+
# ── Usage ───────────────────────────────────────────────────────────────────────
|
|
90
|
+
# bash scripts/directional_diff_gate.sh <owner/repo> <base_sha> <path> [<path>...]
|
|
91
|
+
# bash scripts/directional_diff_gate.sh --self-test # known-pair calibration
|
|
92
|
+
#
|
|
93
|
+
# <base_sha> is the commit the push was based on — the state to compare AGAINST.
|
|
94
|
+
|
|
95
|
+
set -uo pipefail
|
|
96
|
+
|
|
97
|
+
# ── portable base64 decode (BSD `-D` vs GNU `-d`) ───────────────────────────────
|
|
98
|
+
# A BSD-first `-D || -d` chain is how this repo previously shipped a script that was
|
|
99
|
+
# green on macOS and 66/70 red on Linux CI. Probe once, both directions, no assumption.
|
|
100
|
+
b64_decode() {
|
|
101
|
+
if printf 'YQ==' | base64 -d >/dev/null 2>&1; then base64 -d
|
|
102
|
+
elif printf 'YQ==' | base64 -D >/dev/null 2>&1; then base64 -D
|
|
103
|
+
else return 127; fi
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
TMP=$(mktemp -d) || { echo "HARNESS-ERROR: mktemp failed"; exit 10; }
|
|
107
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
108
|
+
|
|
109
|
+
# ── compute_lost: the verdict, as multiset arithmetic ───────────────────────────
|
|
110
|
+
# $TMP/lost = R − L (occurrences the remote has that the upload does not)
|
|
111
|
+
# ONE implementation, called by check_path AND by --self-test — two copies of the same
|
|
112
|
+
# normalisation is how an input passes one and is silently dropped by the other.
|
|
113
|
+
#
|
|
114
|
+
# rc: 0 = computed, 10 = the computation itself failed. `sort`/`comm` failures used to be
|
|
115
|
+
# invisible: a truncated sort leaves an empty file, comm then produces an empty `lost`, and
|
|
116
|
+
# nlost=0 renders as CLEAN. An instrument that failed must not answer "nothing was lost".
|
|
117
|
+
compute_lost() { # $1=R file $2=L file
|
|
118
|
+
sort "$1" > "$TMP/rs" || return 10
|
|
119
|
+
sort "$2" > "$TMP/ls" || return 10
|
|
120
|
+
comm -23 "$TMP/rs" "$TMP/ls" > "$TMP/lost" || return 10
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# ── loss_token: the ack binding ─────────────────────────────────────────────────
|
|
125
|
+
# Material = repo + base + every (path, lost line). One implementation, called by main
|
|
126
|
+
# AND by --self-test: a calibration that hashes a *replica* of the material proves nothing
|
|
127
|
+
# about the token the operator is actually asked to paste.
|
|
128
|
+
loss_token() { # $1=repo $2=base (reads $TMP/lost_all)
|
|
129
|
+
{ printf '%s\n%s\n' "$1" "$2"; sort "$TMP/lost_all"; } 2>/dev/null | shasum 2>/dev/null | cut -c1-12
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# ── one file → verdict on stdout, status in return code ─────────────────────────
|
|
133
|
+
check_path() { # $1=repo $2=base_sha $3=path
|
|
134
|
+
local repo="$1" base="$2" path="$3"
|
|
135
|
+
local prev="$TMP/prev" raw="$TMP/raw"
|
|
136
|
+
|
|
137
|
+
# A missing local file is NOT "nothing to compare". On a delete-class surface it is the
|
|
138
|
+
# signature of a mis-wired caller (wrong cwd, cwd-relative path passed where a repo-root
|
|
139
|
+
# one was meant) — and the previous version returned 0 here, so a wiring mistake read as
|
|
140
|
+
# CLEAN and the whole gate silently measured nothing. Unmeasured is not clean.
|
|
141
|
+
if [ ! -f "$path" ]; then
|
|
142
|
+
echo " 🟥 $path — UNDECIDABLE: not present locally (wrong cwd, or a path that is not"
|
|
143
|
+
echo " repo-root-relative). A file this gate cannot read is not a file it cleared."
|
|
144
|
+
return 10
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
# The path goes into a URL query-bearing request unencoded. A legitimate filename with
|
|
148
|
+
# `?`, `#` or `%` therefore addresses a DIFFERENT resource, and the likely answer is a
|
|
149
|
+
# 404 — which this gate reads as "new file at base → nothing to lose". A wrong request
|
|
150
|
+
# that returns a clean verdict is worse than no request. Refuse instead of guessing.
|
|
151
|
+
# ── the bytes I judged must be the bytes you upload ─────────────────────────
|
|
152
|
+
# A caller that materialises a fixed payload into a scratch tree still hands this gate a
|
|
153
|
+
# MUTABLE file. The verdict then binds to "whatever was in that file when I read it",
|
|
154
|
+
# while the upload binds to the fixed object — swap the scratch copy mid-run and the two
|
|
155
|
+
# diverge (cross-family R5). So the caller may DECLARE the object id per path, and this
|
|
156
|
+
# gate refuses to judge anything whose bytes do not hash to the declared id.
|
|
157
|
+
# Absent declaration = standalone/manual use; no binding is claimed and none is enforced.
|
|
158
|
+
if [ -n "${DIRECTIONAL_DIFF_PINS:-}" ] && [ -f "${DIRECTIONAL_DIFF_PINS}" ]; then
|
|
159
|
+
local want got
|
|
160
|
+
want=$(awk -F'\t' -v p="$path" '$2==p{print $1; exit}' "$DIRECTIONAL_DIFF_PINS")
|
|
161
|
+
if [ -z "$want" ]; then
|
|
162
|
+
echo " 🟥 $path — UNDECIDABLE: pins declared but this path is not among them."
|
|
163
|
+
echo " Judging a file the caller did not pin means the verdict binds to nothing."
|
|
164
|
+
return 10
|
|
165
|
+
fi
|
|
166
|
+
got=$(git hash-object --no-filters "$path" 2>/dev/null)
|
|
167
|
+
if [ "$got" != "$want" ]; then
|
|
168
|
+
echo " 🟥 $path — UNDECIDABLE: bytes do not match the declared object."
|
|
169
|
+
echo " declared ${want:0:12} · read ${got:0:12} — the thing I would judge is not"
|
|
170
|
+
echo " the thing you would upload. Refusing to produce a verdict for it."
|
|
171
|
+
return 10
|
|
172
|
+
fi
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
case "$path" in
|
|
176
|
+
*'?'*|*'#'*|*'%'*)
|
|
177
|
+
echo " 🟥 $path — UNDECIDABLE: path contains a URL-significant character (? # %)."
|
|
178
|
+
echo " The contents request would address a different resource and its 404"
|
|
179
|
+
echo " would read as 'new file'. Not encoding it silently."
|
|
180
|
+
return 10 ;;
|
|
181
|
+
esac
|
|
182
|
+
|
|
183
|
+
# Trap #1 (measured): an unquoted ?ref= is eaten by zsh as a glob → "no matches found",
|
|
184
|
+
# which reads like a repo/path error and is a shell error. The URL is quoted here so a
|
|
185
|
+
# caller cannot reintroduce it.
|
|
186
|
+
local http
|
|
187
|
+
http=$(gh api "repos/${repo}/contents/${path}?ref=${base}" --jq .content > "$raw" 2>"$TMP/err"; echo $?)
|
|
188
|
+
|
|
189
|
+
if [ "$http" -ne 0 ]; then
|
|
190
|
+
# 404 = the file did NOT exist at base → this push creates it → every line is an
|
|
191
|
+
# addition and deletions are genuinely 0. Any OTHER failure is undecidable, and the
|
|
192
|
+
# two must not share an exit: treating an auth/network error as "new file" is exactly
|
|
193
|
+
# the not-found-is-not-zero collapse this gate exists to stop.
|
|
194
|
+
# NOT a loose `not found` grep: `gh: command not found` and `repository not found`
|
|
195
|
+
# both match it, and both would be rendered as "new file at base → del=0" — a
|
|
196
|
+
# fail-open dressed as a 404. Require the HTTP status literal.
|
|
197
|
+
if grep -q 'HTTP 404' "$TMP/err"; then
|
|
198
|
+
echo " ✅ $path — new file at base (404) → del=0"
|
|
199
|
+
return 0
|
|
200
|
+
fi
|
|
201
|
+
echo " 🟥 $path — UNDECIDABLE: API call failed"
|
|
202
|
+
sed 's/^/ /' "$TMP/err" | head -3
|
|
203
|
+
return 10
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# Trap #2 (measured, and it actually fired): `--jq .content` returns base64 WITH
|
|
207
|
+
# embedded newlines. Decoding without stripping them dies and leaves a 0-line file —
|
|
208
|
+
# and then diff reports "all additions", del=0, and the loss check passes while having
|
|
209
|
+
# measured nothing. The line-count assertion below is the whole point: a 0-line decode
|
|
210
|
+
# of a non-empty payload is UNDECIDABLE, never a clean verdict.
|
|
211
|
+
if ! tr -d '\n' < "$raw" | b64_decode > "$prev" 2>/dev/null; then
|
|
212
|
+
echo " 🟥 $path — UNDECIDABLE: base64 decode failed (no usable base64 on PATH?)"
|
|
213
|
+
return 10
|
|
214
|
+
fi
|
|
215
|
+
local raw_bytes prev_lines
|
|
216
|
+
raw_bytes=$(wc -c < "$raw" | tr -d ' ')
|
|
217
|
+
prev_lines=$(wc -l < "$prev" | tr -d ' ')
|
|
218
|
+
if [ "$raw_bytes" -gt 8 ] && [ "$prev_lines" -eq 0 ]; then
|
|
219
|
+
echo " 🟥 $path — UNDECIDABLE: payload was ${raw_bytes}B but decoded to 0 lines"
|
|
220
|
+
echo " (this is the measured trap: 0 lines makes diff report del=0 and the"
|
|
221
|
+
echo " check passes without comparing anything — unmeasured, not clean)"
|
|
222
|
+
return 10
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
# A binary payload has no lines to reason about, and a line-wise verdict over it is
|
|
226
|
+
# noise that reads as a measurement. Refuse rather than produce a number.
|
|
227
|
+
# NOT `grep -q $'\x00'`: bash cannot hold a NUL in a string, so `$'\x00'` collapses to
|
|
228
|
+
# the EMPTY pattern, grep matches every line, and every text file is declared binary —
|
|
229
|
+
# a total over-block that looks like a strict check. Strip-and-compare instead.
|
|
230
|
+
has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
|
|
231
|
+
if has_nul "$prev" || has_nul "$path"; then
|
|
232
|
+
echo " 🟥 $path — UNDECIDABLE: binary content (NUL bytes); a line-wise direction"
|
|
233
|
+
echo " verdict over binary is not a measurement."
|
|
234
|
+
return 10
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
if ! compute_lost "$prev" "$path"; then
|
|
238
|
+
echo " 🟥 $path — UNDECIDABLE: the comparison itself failed (sort/comm)."
|
|
239
|
+
echo " A failed instrument does not get to answer 'nothing was lost'."
|
|
240
|
+
return 10
|
|
241
|
+
fi
|
|
242
|
+
local nlost
|
|
243
|
+
nlost=$(wc -l < "$TMP/lost" | tr -d ' ')
|
|
244
|
+
if [ "$nlost" -eq 0 ]; then
|
|
245
|
+
echo " ✅ $path — 0 lost (compared ${prev_lines} remote lines)"
|
|
246
|
+
return 0
|
|
247
|
+
fi
|
|
248
|
+
echo " ❌ $path — ${nlost} remote line(s) would be LOST:"
|
|
249
|
+
head -10 "$TMP/lost" | sed 's/^/ < /'
|
|
250
|
+
[ "$nlost" -gt 10 ] && echo " … and $((nlost-10)) more"
|
|
251
|
+
# Accumulate WITH the path so the token distinguishes "line X lost from a" from
|
|
252
|
+
# "line X lost from b" — without it, an ack earned on one file clears the other.
|
|
253
|
+
# rc is checked: a partial accumulation would hash to a token that acknowledges less
|
|
254
|
+
# than what is actually being lost.
|
|
255
|
+
if ! sed "s|^|${path}\t|" "$TMP/lost" >> "$TMP/lost_all"; then
|
|
256
|
+
echo " 🟥 $path — UNDECIDABLE: could not record the loss set for the ack token"
|
|
257
|
+
return 10
|
|
258
|
+
fi
|
|
259
|
+
return 1
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# ── known-pair calibration ──────────────────────────────────────────────────────
|
|
263
|
+
# An instrument is a claim about the world only after it separates a case whose answer
|
|
264
|
+
# you already know. This runs offline against fabricated pairs — no network, no repo.
|
|
265
|
+
self_test() {
|
|
266
|
+
local f=0 n=0
|
|
267
|
+
t() { n=$((n+1)); if [ "$2" = "$3" ]; then echo "✅ $1 → $3"; else echo "❌ $1 → $3 (expected $2)"; f=1; fi; }
|
|
268
|
+
|
|
269
|
+
printf 'a\nb\nc\n' > "$TMP/p"; printf 'a\nb\nc\n' > "$TMP/l"
|
|
270
|
+
diff "$TMP/p" "$TMP/l" >/dev/null 2>&1 && r=CLEAN || r=DIFF
|
|
271
|
+
t "identical files" CLEAN "$r"
|
|
272
|
+
|
|
273
|
+
printf 'a\nb\nc\n' > "$TMP/p"; printf 'a\nb\nc\nd\n' > "$TMP/l"
|
|
274
|
+
[ "$(diff "$TMP/p" "$TMP/l" | grep -c '^<')" -eq 0 ] && r=CLEAN || r=DEL
|
|
275
|
+
t "pure addition (del must be 0)" CLEAN "$r"
|
|
276
|
+
|
|
277
|
+
# The measured event: 1 deletion buried under 91 additions. Totals call this net growth.
|
|
278
|
+
printf 'BANNER\na\nb\n' > "$TMP/p"; { echo a; echo b; for i in $(seq 1 91); do echo "new$i"; done; } > "$TMP/l"
|
|
279
|
+
[ "$(diff "$TMP/p" "$TMP/l" | grep -c '^<')" -gt 0 ] && r=DEL || r=CLEAN
|
|
280
|
+
t "banner dropped under 91 additions (the measured event)" DEL "$r"
|
|
281
|
+
local tot_add tot_del
|
|
282
|
+
tot_add=$(diff "$TMP/p" "$TMP/l" | grep -c '^>'); tot_del=$(diff "$TMP/p" "$TMP/l" | grep -c '^<')
|
|
283
|
+
[ "$tot_add" -gt "$tot_del" ] && r=NETGROWTH || r=NETLOSS
|
|
284
|
+
t " └ and a total-only check reads it as" NETGROWTH "$r"
|
|
285
|
+
|
|
286
|
+
# Trap #2 reproduced: a 0-line "previous" makes deletions unmeasurable, not zero.
|
|
287
|
+
: > "$TMP/p0"; printf 'a\nb\n' > "$TMP/l"
|
|
288
|
+
[ "$(diff "$TMP/p0" "$TMP/l" | grep -c '^<')" -eq 0 ] && r=LOOKS_CLEAN || r=DEL
|
|
289
|
+
t "0-line decode reads as clean (why 0 lines must be UNDECIDABLE)" LOOKS_CLEAN "$r"
|
|
290
|
+
|
|
291
|
+
printf 'YQ==' | b64_decode >/dev/null 2>&1 && r=OK || r=BROKEN
|
|
292
|
+
t "base64 decode available on this platform" OK "$r"
|
|
293
|
+
|
|
294
|
+
# Ack non-vacuity, both directions.
|
|
295
|
+
for pair in "ok:VACUOUS" "1:VACUOUS" "yes:VACUOUS" "removing the retired mirror banner, superseded by frontmatter:SUBSTANTIVE"; do
|
|
296
|
+
local val="${pair%:*}" want="${pair##*:}"
|
|
297
|
+
if printf '%s' "$val" | grep -qE '.{20,}'; then r=SUBSTANTIVE; else r=VACUOUS; fi
|
|
298
|
+
t "ack '${val:0:24}'" "$want" "$r"
|
|
299
|
+
done
|
|
300
|
+
|
|
301
|
+
# ── the verdict arithmetic, against the SHIPPED compute_lost ──────────────────
|
|
302
|
+
lostn() { compute_lost "$TMP/tr" "$TMP/tl" || { echo ERR; return; }; wc -l < "$TMP/lost" | tr -d ' '; }
|
|
303
|
+
|
|
304
|
+
# (a) the measured incident: a banner the remote has and the upload does not.
|
|
305
|
+
printf 'BANNER\na\nb\n' > "$TMP/tr"; printf 'a\nb\n' > "$TMP/tl"
|
|
306
|
+
t "remote line missing from the upload → lost" 1 "$(lostn)"
|
|
307
|
+
|
|
308
|
+
# (b) pure addition must not block — an always-red gate teaches the bypass (#33).
|
|
309
|
+
printf 'a\nb\n' > "$TMP/tr"; printf 'a\nb\nnew\n' > "$TMP/tl"
|
|
310
|
+
t "pure addition → 0 lost (over-block guard)" 0 "$(lostn)"
|
|
311
|
+
|
|
312
|
+
# (c) multiplicity: a SECOND copy of a line the file already had once is a real loss.
|
|
313
|
+
# Set membership reports 0 here; that is why comm (multiset) is used.
|
|
314
|
+
printf 'DUP\nDUP\n' > "$TMP/tr"; printf 'DUP\n' > "$TMP/tl"
|
|
315
|
+
t "remote has 2 copies, upload has 1 → 1 lost (set-test would say 0)" 1 "$(lostn)"
|
|
316
|
+
|
|
317
|
+
# (d) a block of remote-only lines counts as a block, not as one.
|
|
318
|
+
printf 'x\nP\nQ\nR\n' > "$TMP/tr"; printf 'x\n' > "$TMP/tl"
|
|
319
|
+
t "remote-only block of 3 → 3 lost" 3 "$(lostn)"
|
|
320
|
+
|
|
321
|
+
# (e) reordering is NOT loss under a line-frequency contract — pinned so the contract is
|
|
322
|
+
# explicit rather than accidental. (Position/section context is a named non-goal:
|
|
323
|
+
# this gate preserves line occurrences, not their placement.)
|
|
324
|
+
printf 'a\nb\n' > "$TMP/tr"; printf 'b\na\n' > "$TMP/tl"
|
|
325
|
+
t "reordered lines → 0 lost (frequency contract, not a position check)" 0 "$(lostn)"
|
|
326
|
+
|
|
327
|
+
# (f) instrument failure must not answer "nothing was lost".
|
|
328
|
+
compute_lost "$TMP/__no_such_file__" "$TMP/tl" >/dev/null 2>&1 && r=SILENT || r=RC_NONZERO
|
|
329
|
+
t "unreadable input → compute_lost fails loudly (not a clean 0)" RC_NONZERO "$r"
|
|
330
|
+
|
|
331
|
+
# (g) ACK binding, against the SHIPPED loss_token. The token must move with EVERY part
|
|
332
|
+
# of the material — content, path, repo, base — or a parked ack clears the wrong loss.
|
|
333
|
+
: > "$TMP/lost_all"; printf 'a.sh\tX\n' >> "$TMP/lost_all"
|
|
334
|
+
g_content=$(loss_token o/r base1)
|
|
335
|
+
: > "$TMP/lost_all"; printf 'a.sh\tY\n' >> "$TMP/lost_all"
|
|
336
|
+
g_other=$(loss_token o/r base1)
|
|
337
|
+
t "token moves when the lost LINE differs" DIFFERENT "$([ "$g_content" != "$g_other" ] && echo DIFFERENT || echo SAME)"
|
|
338
|
+
|
|
339
|
+
: > "$TMP/lost_all"; printf 'b.sh\tX\n' >> "$TMP/lost_all"
|
|
340
|
+
g_path=$(loss_token o/r base1)
|
|
341
|
+
t "token moves when the same line is lost from a DIFFERENT FILE" DIFFERENT \
|
|
342
|
+
"$([ "$g_content" != "$g_path" ] && echo DIFFERENT || echo SAME)"
|
|
343
|
+
|
|
344
|
+
: > "$TMP/lost_all"; printf 'a.sh\tX\n' >> "$TMP/lost_all"
|
|
345
|
+
t "token moves with the REPO" DIFFERENT "$([ "$g_content" != "$(loss_token o/other base1)" ] && echo DIFFERENT || echo SAME)"
|
|
346
|
+
t "token moves with the BASE" DIFFERENT "$([ "$g_content" != "$(loss_token o/r base2)" ] && echo DIFFERENT || echo SAME)"
|
|
347
|
+
t "token is stable for identical material" STABLE \
|
|
348
|
+
"$([ "$g_content" = "$(loss_token o/r base1)" ] && echo STABLE || echo UNSTABLE)"
|
|
349
|
+
t "token is 12 hex chars (8 is 32 bits on a value that must not collide by accident)" 12 \
|
|
350
|
+
"$(printf '%s' "$g_content" | wc -c | tr -d ' ')"
|
|
351
|
+
: > "$TMP/lost_all"
|
|
352
|
+
|
|
353
|
+
# (h) NUL detection. The first draft used `grep -q $'"'"'\x00'"'"'`, which bash collapses to the
|
|
354
|
+
# EMPTY pattern — every text file matched and every push would have been declared
|
|
355
|
+
# binary/UNDECIDABLE. Both directions are pinned so that regression cannot return.
|
|
356
|
+
has_nul() { LC_ALL=C tr -d '\000' < "$1" | cmp -s - "$1" || return 0; return 1; }
|
|
357
|
+
printf 'plain text\n' > "$TMP/tn"
|
|
358
|
+
has_nul "$TMP/tn" && r=BINARY || r=TEXT
|
|
359
|
+
t "plain text is not misread as binary" TEXT "$r"
|
|
360
|
+
printf 'a\000b\n' > "$TMP/tn"
|
|
361
|
+
has_nul "$TMP/tn" && r=BINARY || r=TEXT
|
|
362
|
+
t "a file with NUL bytes is detected as binary" BINARY "$r"
|
|
363
|
+
|
|
364
|
+
# (i) 404 must be the HTTP status, not any stderr containing "not found".
|
|
365
|
+
# `gh: command not found` used to be accepted as "new file at base" → fail-open.
|
|
366
|
+
for pair in "gh: Not Found (HTTP 404):IS404" "gh: command not found:NOT404" "repository not found:NOT404"; do
|
|
367
|
+
msg="${pair%:*}"; want="${pair##*:}"
|
|
368
|
+
printf '%s\n' "$msg" | grep -q 'HTTP 404' && r=IS404 || r=NOT404
|
|
369
|
+
t "404 discrimination '${msg:0:26}'" "$want" "$r"
|
|
370
|
+
done
|
|
371
|
+
|
|
372
|
+
# (j) structural: an unrecognised per-file rc must not aggregate to CLEAN.
|
|
373
|
+
if grep -q '\*) echo " 🟥 \$p — HARNESS-ERROR: unrecognised check rc' "$0"; then r=HAS_DEFAULT; else r=NO_DEFAULT; fi
|
|
374
|
+
t "aggregator has a default branch for unknown rc" HAS_DEFAULT "$r"
|
|
375
|
+
|
|
376
|
+
echo; [ "$f" -eq 0 ] && echo "✅ calibration passed ($n pairs)" || echo "❌ calibration FAILED ($n pairs)"
|
|
377
|
+
return "$f"
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# ── main ────────────────────────────────────────────────────────────────────────
|
|
381
|
+
[ "${1:-}" = "--self-test" ] && { self_test; exit $?; }
|
|
382
|
+
|
|
383
|
+
if [ "$#" -lt 3 ]; then
|
|
384
|
+
echo "usage: $0 <owner/repo> <base_sha> <path> [<path>...]"
|
|
385
|
+
echo " $0 --self-test"
|
|
386
|
+
exit 10
|
|
387
|
+
fi
|
|
388
|
+
REPO="$1"; BASE="$2"; shift 2
|
|
389
|
+
|
|
390
|
+
echo "── directional diff vs ${REPO}@${BASE:0:12} ──"
|
|
391
|
+
: > "$TMP/lost_all"
|
|
392
|
+
WORST=0
|
|
393
|
+
for p in "$@"; do
|
|
394
|
+
check_path "$REPO" "$BASE" "$p"; rc=$?
|
|
395
|
+
# 10 (undecidable) outranks 1 (deletions): an unmeasured file is a worse verdict than a
|
|
396
|
+
# measured bad one, because only the first is invisible.
|
|
397
|
+
# An UNRECOGNISED rc must not fall through as clean. check_path returns 0/1/10 today,
|
|
398
|
+
# but a future edit (or a `set -u` abort, a 127, a 128 from git) returning anything else
|
|
399
|
+
# would leave WORST at 0 and the run would end "✅ CLEAN" having decided nothing. The
|
|
400
|
+
# aggregator is where that becomes invisible, so the default lives here.
|
|
401
|
+
case "$rc" in
|
|
402
|
+
0) : ;;
|
|
403
|
+
1) [ "$WORST" -ne 10 ] && WORST=1 ;;
|
|
404
|
+
10) WORST=10 ;;
|
|
405
|
+
*) echo " 🟥 $p — HARNESS-ERROR: unrecognised check rc=$rc (treated as undecidable)"
|
|
406
|
+
WORST=10 ;;
|
|
407
|
+
esac
|
|
408
|
+
done
|
|
409
|
+
|
|
410
|
+
echo
|
|
411
|
+
case "$WORST" in
|
|
412
|
+
0) echo "✅ CLEAN — no remote-only lines lost"; exit 0 ;;
|
|
413
|
+
10) echo "🟥 UNDECIDABLE — the comparison could not be made for ≥1 file."
|
|
414
|
+
echo " Fails closed: a file-replacement push is a delete-class surface, and"
|
|
415
|
+
echo " 'could not compare' is not 'nothing was lost'. Fix the instrument and re-run."
|
|
416
|
+
exit 10 ;;
|
|
417
|
+
1) ACK="${DIRECTIONAL_DIFF_ACK:-}"
|
|
418
|
+
# Token over the UNION of every lost line, sorted so the same loss set always yields
|
|
419
|
+
# the same token regardless of the order files were given on the command line.
|
|
420
|
+
# Material = repo + base + every (path, lost line). An ack is then valid for exactly
|
|
421
|
+
# one loss, on one repo, at one base — not for "some line X somewhere". 12 hex chars
|
|
422
|
+
# rather than 8: an 8-char prefix is 32 bits, which is a collision surface on a value
|
|
423
|
+
# whose whole job is to be unforgeable-by-accident.
|
|
424
|
+
TOKEN=$(loss_token "$REPO" "$BASE")
|
|
425
|
+
if [ -z "$TOKEN" ]; then
|
|
426
|
+
# No hash tool ⇒ the ACK cannot be bound to anything ⇒ do not fall back to an
|
|
427
|
+
# unbound ACK. An unbindable acknowledgement on a delete-class surface is the
|
|
428
|
+
# reflex-ACK failure this token exists to stop.
|
|
429
|
+
echo "🟥 UNDECIDABLE — cannot compute the loss token (no shasum on PATH)."
|
|
430
|
+
echo " Refusing to accept an unbound acknowledgement. Fix the instrument."
|
|
431
|
+
exit 10
|
|
432
|
+
fi
|
|
433
|
+
if [ -z "$ACK" ]; then
|
|
434
|
+
echo "❌ DELETIONS — lines present on the remote would be lost, unacknowledged."
|
|
435
|
+
echo " Look at the list above. If every one of those removals is intended, say WHAT"
|
|
436
|
+
echo " is being removed and include this run's token:"
|
|
437
|
+
echo " DIRECTIONAL_DIFF_ACK=\"LOSS-$TOKEN <what is deleted and why>\" $0 $REPO $BASE <paths>"
|
|
438
|
+
echo " The token is derived from the lost lines themselves — it changes with the"
|
|
439
|
+
echo " content, so a previously-used ack will not clear a different loss."
|
|
440
|
+
exit 1
|
|
441
|
+
fi
|
|
442
|
+
if ! printf '%s' "$ACK" | grep -q "LOSS-$TOKEN"; then
|
|
443
|
+
echo "❌ DELETIONS — ack does not carry THIS run's token (expected LOSS-$TOKEN)."
|
|
444
|
+
echo " An ack from an earlier run acknowledges earlier content. Re-read the list above."
|
|
445
|
+
exit 1
|
|
446
|
+
fi
|
|
447
|
+
# ⚠️ `.{20,}` alone passes on 20 spaces. Require 20 NON-space characters — the point
|
|
448
|
+
# is a stated reason, and whitespace states nothing.
|
|
449
|
+
if [ "$(printf '%s' "$ACK" | sed "s/LOSS-$TOKEN//" | tr -cd '[:graph:]' | wc -c | tr -d ' ')" -lt 20 ]; then
|
|
450
|
+
echo "❌ DELETIONS — token present but no substantive reason."
|
|
451
|
+
echo " Name the removed content, not the fact of removal. The token proves you saw"
|
|
452
|
+
echo " the list; the prose proves you decided about it."
|
|
453
|
+
exit 1
|
|
454
|
+
fi
|
|
455
|
+
echo "⚠️ DELETIONS acknowledged — proceeding (recorded, not silent)"
|
|
456
|
+
echo " token: LOSS-$TOKEN"
|
|
457
|
+
echo " ack: $ACK"
|
|
458
|
+
exit 0 ;;
|
|
459
|
+
esac
|