@chrono-meta/fh-gate 1.4.78 → 1.4.80
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-plugin/marketplace.json +2 -2
- package/CLAUDE.md +7 -2
- package/knowledge/shared/harness-core/loop_engineering.md +1 -1
- package/knowledge/shared/rules/operational_adaptation.md +144 -1
- package/package.json +12 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/scripts/consent_registry_check.sh +472 -0
- package/scripts/fh_session_load.sh +42 -3
- package/scripts/halffix_propagation_scan.sh +126 -0
- package/scripts/package_coverage_check.sh +18 -1
- package/scripts/pipe_verdict_guard.sh +93 -0
- package/scripts/selfcheck.sh +99 -6
- package/scripts/session_close_check.sh +15 -1
- package/scripts/sidecar_calibrate.sh +85 -0
- package/scripts/test_card_drift_probe.sh +55 -0
- package/scripts/test_consent_registry.sh +434 -0
- package/scripts/test_halffix_lanes.sh +170 -0
- package/scripts/test_ollama_panel_lanes.sh +120 -0
- package/scripts/test_package_coverage_lanes.sh +250 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +96 -0
- package/scripts/test_sidecar_wait_stdin.sh +13 -1
- package/templates/.git-hooks/pre-commit +73 -5
- package/templates/consent_classes.yaml.example +75 -0
- package/templates/settings.PreToolUse.snippet.json +49 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# halffix_propagation_scan.sh — pre-commit advisory: this fix may have landed in only one copy.
|
|
3
|
+
#
|
|
4
|
+
# THE DEFECT — "반쪽-수리" (half-fix)
|
|
5
|
+
# A defect class lives in N sibling copies. The fix lands in ONE and nothing says so. Measured 3x
|
|
6
|
+
# in this project, and the shape is worse than the count: every one of the three occurred INSIDE
|
|
7
|
+
# an edit that was itself repairing an earlier half-fix. scripts/psa_scan_lib.sh's header records
|
|
8
|
+
# five such divergences found in a single 2026-07-26 audit — every confidentiality defect that
|
|
9
|
+
# audit found was a divergence between duplicated copies, not a flaw in the idea.
|
|
10
|
+
#
|
|
11
|
+
# WHAT IT DOES
|
|
12
|
+
# Takes the distinctive symbols and path literals touched by the staged diff, re-greps the tree,
|
|
13
|
+
# and NAMES the tracked files that carry the same token but are not staged. That is the whole
|
|
14
|
+
# contribution: the author already knows what they fixed; what they lose is the sibling.
|
|
15
|
+
#
|
|
16
|
+
# MARK, DO NOT BLOCK — this is mandated, not preferred. The spec for this debt is explicit:
|
|
17
|
+
# "표시(차단 아님 — 정당한 복제도 있다)". Legitimate duplication exists (templates/ ships a
|
|
18
|
+
# field-propagated copy of scripts/ ON PURPOSE, and selfcheck asserts they stay byte-identical).
|
|
19
|
+
# A detector that blocks on correct duplication is a detector that gets disabled.
|
|
20
|
+
#
|
|
21
|
+
# THE DISCRIMINATOR — if every copy is staged, the fix propagated and this stays SILENT.
|
|
22
|
+
# Without that, the scan fires loudest exactly when the author did the right thing. Two prior
|
|
23
|
+
# claims on this same mistake in this repo: S5 (9/9 false positives, narrowed 2026-07-28) and
|
|
24
|
+
# S6 (0 true positives on the planned surface, retargeted 2026-07-31). Lane N3 pins it.
|
|
25
|
+
#
|
|
26
|
+
# Usage: bash scripts/halffix_propagation_scan.sh # reads the staged diff of $PWD
|
|
27
|
+
# Opt out: put `noqa: half-fix` on any added line in the commit.
|
|
28
|
+
|
|
29
|
+
set -u
|
|
30
|
+
cd "$(git rev-parse --show-toplevel 2>/dev/null || echo .)" || exit 0
|
|
31
|
+
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
32
|
+
|
|
33
|
+
# Deletions are excluded (ACM): a removed file's symbols surviving elsewhere is not a half-fix,
|
|
34
|
+
# it is the normal state of a deletion, and flagging it would be pure noise.
|
|
35
|
+
STAGED=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)
|
|
36
|
+
[ -n "$STAGED" ] || exit 0
|
|
37
|
+
|
|
38
|
+
DIFF=$(git diff --cached -U0 --diff-filter=ACM 2>/dev/null)
|
|
39
|
+
printf '%s' "$DIFF" | grep -qE '^\+.*noqa:?[[:space:]]*half-fix' && exit 0
|
|
40
|
+
|
|
41
|
+
# Anchor tokens, from CHANGED lines only (added and removed — a removed spelling is exactly what a
|
|
42
|
+
# sibling may still carry). Two shapes:
|
|
43
|
+
# · identifiers >= 10 chars — long enough that a collision is meaningful. Shell/py keywords and
|
|
44
|
+
# the everyday vocabulary (`then`, `echo`, `return`, `local`) are all shorter, so the length
|
|
45
|
+
# floor does the keyword filtering without a denylist to maintain. (Lane N6.)
|
|
46
|
+
# · path literals `a/b` — the spec names filenames as anchors alongside symbols. (Lane P7.)
|
|
47
|
+
# The ENCLOSING function counts as a changed symbol even when the edited line itself carries no
|
|
48
|
+
# distinctive token — and a half-fix is a function-level thing, so this is the common case, not an
|
|
49
|
+
# edge one. git already hands it over in the hunk header (`@@ -2 +2 @@ psa_low_allowlisted() {`),
|
|
50
|
+
# so the context comes for free rather than from a hand-rolled scope parser.
|
|
51
|
+
# Found by lane P1 failing: the fix edited only a `case` line, whose longest token was 9 chars, and
|
|
52
|
+
# the scan went silent on a textbook two-copy divergence.
|
|
53
|
+
CHANGED=$( { printf '%s\n' "$DIFF" | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)'
|
|
54
|
+
printf '%s\n' "$DIFF" | sed -n 's/^@@ .* @@ //p'
|
|
55
|
+
} )
|
|
56
|
+
TOKENS=$( { printf '%s\n' "$CHANGED" | grep -oE '[A-Za-z_][A-Za-z0-9_-]{9,}'
|
|
57
|
+
printf '%s\n' "$CHANGED" | grep -oE '[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+'
|
|
58
|
+
} | sort -u )
|
|
59
|
+
# NO early exit here. R1 (tokens) and R2 (whole-file copies) are INDEPENDENT rules, and an empty
|
|
60
|
+
# token set is the normal state for a short edit — `a() { :; }` → `a() { echo fixed; }` carries no
|
|
61
|
+
# 10-char anchor at all. An early `exit 0` on empty tokens silently disabled R2 for exactly the
|
|
62
|
+
# edits R2 exists to catch. (Caught by lane R2p, 2026-07-31, after the same shape had already
|
|
63
|
+
# passed 10/10 in the other lanes — a rule can be correct and unreachable.)
|
|
64
|
+
|
|
65
|
+
# Cap, and SAY SO when it bites. A silent truncation reads as "checked everything" when it did not.
|
|
66
|
+
MAX_TOKENS="${HALFFIX_MAX_TOKENS:-60}"
|
|
67
|
+
TOTAL=$(printf '%s\n' "$TOKENS" | grep -c .)
|
|
68
|
+
if [ "$TOTAL" -gt "$MAX_TOKENS" ]; then
|
|
69
|
+
echo " ℹ️ half-fix scan: $TOTAL anchor tokens in this diff, examining the first $MAX_TOKENS (raise with HALFFIX_MAX_TOKENS)." >&2
|
|
70
|
+
TOKENS=$(printf '%s\n' "$TOKENS" | head -n "$MAX_TOKENS")
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# A token in many files is framework vocabulary, not a duplicated fix site. (Lane N5.)
|
|
74
|
+
MAX_FILES="${HALFFIX_MAX_FILES:-8}"
|
|
75
|
+
staged_has() { printf '%s\n' "$STAGED" | grep -qxF "$1"; }
|
|
76
|
+
|
|
77
|
+
hits=""
|
|
78
|
+
while IFS= read -r tok; do
|
|
79
|
+
[ -n "$tok" ] || continue
|
|
80
|
+
files=$(git grep -l --fixed-strings -e "$tok" -- . 2>/dev/null)
|
|
81
|
+
[ -n "$files" ] || continue
|
|
82
|
+
n=$(printf '%s\n' "$files" | grep -c .)
|
|
83
|
+
[ "$n" -le "$MAX_FILES" ] || continue
|
|
84
|
+
others=""
|
|
85
|
+
while IFS= read -r f; do
|
|
86
|
+
[ -n "$f" ] || continue
|
|
87
|
+
staged_has "$f" || others="${others}${others:+, }$f"
|
|
88
|
+
done <<< "$files"
|
|
89
|
+
[ -n "$others" ] || continue # every copy staged → propagated → silent (lane N3)
|
|
90
|
+
hits="${hits} ⚠️ HALF-FIX \`$tok\` also lives in: $others
|
|
91
|
+
"
|
|
92
|
+
done <<< "$TOKENS"
|
|
93
|
+
|
|
94
|
+
# ── R2 — whole-file copy divergence. ─────────────────────────────────────────────────────────
|
|
95
|
+
# The token rule alone missed this repo's real duplicate pair: a script's NAME lives in 11–18 files
|
|
96
|
+
# here (docs, CATALOG, the manifest, selfcheck refs), so the ubiquity filter suppressed it, while an
|
|
97
|
+
# internal symbol like psa_low_allowlisted lives in 2. Measured 2026-07-31 — lanes 10/10 green, live
|
|
98
|
+
# probe silent. The lanes were necessary and not sufficient.
|
|
99
|
+
#
|
|
100
|
+
# Exact, not heuristic: the sibling was BYTE-IDENTICAL at HEAD and only one side is staged, so it is
|
|
101
|
+
# a divergence by construction and needs no threshold. Same-basename files that were never copies
|
|
102
|
+
# (CLAUDE.md vs templates/CLAUDE.md, the 40 SKILL.md files) stay silent — a basename rule would have
|
|
103
|
+
# flooded on exactly those.
|
|
104
|
+
while IFS= read -r sf; do
|
|
105
|
+
[ -n "$sf" ] || continue
|
|
106
|
+
base=$(basename "$sf")
|
|
107
|
+
head_blob=$(git rev-parse "HEAD:$sf" 2>/dev/null) || continue
|
|
108
|
+
while IFS= read -r cand; do
|
|
109
|
+
[ -n "$cand" ] && [ "$cand" != "$sf" ] || continue
|
|
110
|
+
staged_has "$cand" && continue
|
|
111
|
+
cand_blob=$(git rev-parse "HEAD:$cand" 2>/dev/null) || continue
|
|
112
|
+
[ "$cand_blob" = "$head_blob" ] || continue # were they the SAME file before this edit?
|
|
113
|
+
hits="${hits} ⚠️ HALF-FIX \`$sf\` was byte-identical to \`$cand\` at HEAD — only one side is staged
|
|
114
|
+
"
|
|
115
|
+
done <<< "$(git ls-files -- "*/$base" "$base" 2>/dev/null)"
|
|
116
|
+
done <<< "$STAGED"
|
|
117
|
+
|
|
118
|
+
[ -n "$hits" ] || exit 0
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
echo "⚠️ HALF-FIX PROPAGATION — symbols you changed also exist in files you did NOT stage."
|
|
122
|
+
echo " Not a verdict: templates/ ships deliberate copies of scripts/. Judge each, then proceed."
|
|
123
|
+
printf '%s' "$hits"
|
|
124
|
+
echo " Silence this commit with a \`noqa: half-fix\` comment on any added line."
|
|
125
|
+
} >&2
|
|
126
|
+
exit 0
|
|
@@ -21,10 +21,27 @@ set -uo pipefail
|
|
|
21
21
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
22
22
|
cd "$REPO_ROOT" || exit 1
|
|
23
23
|
|
|
24
|
-
|
|
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
|
|
25
32
|
echo "SKIP package-coverage (not a source checkout)"
|
|
26
33
|
exit 0
|
|
27
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
|
|
28
45
|
|
|
29
46
|
# ── Accepted-absent, with the reason each one is NOT a defect ────────────────────────────────
|
|
30
47
|
# Adding a line here is a decision, not a silencer: each entry states why shipping it would be
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pipe_verdict_guard.sh — PreToolUse(Bash) advisory: a verdict read from the wrong end of a pipe.
|
|
3
|
+
#
|
|
4
|
+
# THE DEFECT
|
|
5
|
+
# `cmd | tail -5; echo "exit=$?"` reports TAIL's status, not cmd's. A gate that FAILED reads as
|
|
6
|
+
# exit 0. The degrade direction is toward PASS, which is the direction that never announces
|
|
7
|
+
# itself. Measured 6× in this project between 2026-07-29 and 2026-07-31.
|
|
8
|
+
#
|
|
9
|
+
# WHY A HOOK AND NOT A FILE LINTER (measured 2026-07-31, and it reversed the plan on record)
|
|
10
|
+
# The session card prescribed "add an S6 class to scripts/degrade_direction_scan.sh". Every
|
|
11
|
+
# `pipe + $?` occurrence in this repo's shell scripts was then hand-verified: 7 hits, 7 correct
|
|
12
|
+
# — in all 7 the final stage WAS the command under test. True positives in shipped files: 0.
|
|
13
|
+
# All 6 recurrences lived in interactively-composed commands, which a repo scanner never reads.
|
|
14
|
+
# S6 would have shipped a probe with no true positives, the exact failure mode S5 records in
|
|
15
|
+
# its own comment ("100% FP trains dismissal of the one hit that will matter"). So the guard
|
|
16
|
+
# moved to the surface where the defect actually occurs: the Bash tool call itself.
|
|
17
|
+
#
|
|
18
|
+
# TWO RULES, DELIBERATELY UNEQUAL IN CONFIDENCE
|
|
19
|
+
# R1 — deterministic, zero-FP. `${PIPESTATUS[…]}` is bash-only. This project's Bash tool runs
|
|
20
|
+
# zsh, where that expands to the EMPTY STRING (zsh spells it `$pipestatus[1]`, 1-indexed).
|
|
21
|
+
# A verdict read from it is not wrong, it is ABSENT. No judgment involved.
|
|
22
|
+
# R2 — heuristic, narrowed to DISPLAY FILTERS as the final stage (tail/head/cat/less/more).
|
|
23
|
+
# A final `grep -q`, a script, or a subshell is usually the thing whose status is wanted —
|
|
24
|
+
# those are the 7 correct shapes above and are not flagged. Narrowing costs recall; the
|
|
25
|
+
# alternative is a probe nobody reads.
|
|
26
|
+
#
|
|
27
|
+
# DEGRADE DIRECTION: advisory. This guard WARNS and exits 0 — it never blocks a Bash call, because
|
|
28
|
+
# a mis-read verdict is re-runnable and a false block on a developer's shell trains --no-verify
|
|
29
|
+
# reflexes on hooks that DO guard irreversible surfaces. Set FH_PIPE_VERDICT_BLOCK=1 to escalate.
|
|
30
|
+
# If the command cannot be extracted, it stays silent: an unparsed input is not a finding.
|
|
31
|
+
#
|
|
32
|
+
# Usage:
|
|
33
|
+
# hook: PreToolUse matcher "Bash" → bash scripts/pipe_verdict_guard.sh
|
|
34
|
+
# test: printf '%s' "<command>" | bash scripts/pipe_verdict_guard.sh --stdin-raw
|
|
35
|
+
# Opt out on a single command with a trailing `# noqa: pipe-verdict`.
|
|
36
|
+
|
|
37
|
+
set -u
|
|
38
|
+
|
|
39
|
+
CMD=""
|
|
40
|
+
if [ "${1:-}" = "--stdin-raw" ]; then
|
|
41
|
+
CMD=$(cat)
|
|
42
|
+
else
|
|
43
|
+
RAW=$(cat)
|
|
44
|
+
# PreToolUse payload. Absent/!Bash/unparseable → stay silent (see degrade direction above).
|
|
45
|
+
CMD=$(printf '%s' "$RAW" | python3 -c '
|
|
46
|
+
import json,sys
|
|
47
|
+
try: d = json.load(sys.stdin)
|
|
48
|
+
except Exception: sys.exit(0)
|
|
49
|
+
if d.get("tool_name") != "Bash": sys.exit(0)
|
|
50
|
+
sys.stdout.write(d.get("tool_input", {}).get("command", "") or "")
|
|
51
|
+
' 2>/dev/null) || CMD=""
|
|
52
|
+
fi
|
|
53
|
+
[ -n "$CMD" ] || exit 0
|
|
54
|
+
|
|
55
|
+
# Explicit opt-outs, checked before any rule.
|
|
56
|
+
printf '%s' "$CMD" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*pipe-verdict' && exit 0
|
|
57
|
+
|
|
58
|
+
hits=""
|
|
59
|
+
add() { hits="${hits} ⚠️ PIPE-VERDICT $1
|
|
60
|
+
$2
|
|
61
|
+
"; }
|
|
62
|
+
|
|
63
|
+
# Flatten to ONE line before any matching. grep is line-oriented, so `.*` never spans a newline and
|
|
64
|
+
# every multi-line command missed — which is the worse half, because the invocations that actually
|
|
65
|
+
# recur here are multi-line. A newline is a statement separator, so `; ` is the faithful substitute.
|
|
66
|
+
# (Found by the Axis-2 adversarial pass on this guard, 2026-07-31; lanes A* pin it.)
|
|
67
|
+
FLAT=$(printf '%s' "$CMD" | tr '\n' ';' | sed 's/;/; /g')
|
|
68
|
+
|
|
69
|
+
# ── R1 — PIPESTATUS under zsh: the value is empty, so the verdict is absent. ──────────────────
|
|
70
|
+
# Brace-optional: zsh accepts `$PIPESTATUS[0]` as well, and the brace-anchored form missed it (lane B*).
|
|
71
|
+
if printf '%s' "$FLAT" | grep -qE '\$\{?PIPESTATUS[[{]' ; then
|
|
72
|
+
add "R1 \${PIPESTATUS[…]} is empty in zsh" \
|
|
73
|
+
"This shell is zsh; the bash array does not exist here, so the verdict expands to \"\". Use zsh's \`\$pipestatus[1]\` (1-indexed), or drop the pipe and read \$? directly."
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# ── R2 — display filter as the final stage, then a read of $?. ────────────────────────────────
|
|
77
|
+
# `||` is neutralized first: `a || b || echo 0` contains no pipeline, and reading it as one is
|
|
78
|
+
# how the sibling S5 probe produced 9 false positives before it was narrowed (2026-07-28).
|
|
79
|
+
# `set -o pipefail` in the same command makes `$?` after a pipeline correct — not a finding.
|
|
80
|
+
NORM=$(printf '%s' "$FLAT" | sed 's/||/__OR__/g')
|
|
81
|
+
if ! printf '%s' "$NORM" | grep -qE 'set -o pipefail|set -[a-zA-Z]*o[a-zA-Z]* pipefail'; then
|
|
82
|
+
if printf '%s' "$NORM" \
|
|
83
|
+
| grep -qE '\|[[:space:]]*(tail|head|cat|less|more)([[:space:]][^|;&]*)?[[:space:]]*[;&].*\$\?'; then
|
|
84
|
+
add "R2 \$? after a display filter" \
|
|
85
|
+
"\$? holds the filter's status (tail/head/cat almost always succeed), not the command's — a FAILED check reads as 0. Capture first: \`out=\$(cmd 2>&1); rc=\$?\` then print \"\$out\" | tail."
|
|
86
|
+
fi
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
[ -n "$hits" ] || exit 0
|
|
90
|
+
|
|
91
|
+
printf '%s' "$hits" >&2
|
|
92
|
+
if [ "${FH_PIPE_VERDICT_BLOCK:-0}" = "1" ]; then exit 2; fi
|
|
93
|
+
exit 0
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
# selfcheck.sh — mandatory-pass (deterministic) checks on FH's own executable surface.
|
|
3
3
|
# Class: mandatory-pass (harness_6axis_framework.md §Axis 5 check classes) — blocks on fail.
|
|
4
4
|
# Scope: executables shipped via npm files[] + the bash infra driving the FH gate chain.
|
|
5
|
-
#
|
|
5
|
+
# NOT syntax-only any more, and this line used to say it was. Syntax checks (node --check / bash -n)
|
|
6
|
+
# are only the first section; behavioural lane suites follow and they DO have side effects and
|
|
7
|
+
# environment needs: temp dirs, a loopback HTTP stub on 127.0.0.1:18011, git, `timeout`, and — via
|
|
8
|
+
# the session-close lanes — an optional `gh` call that reaches GitHub when the binary is present.
|
|
9
|
+
# Corrected 2026-07-31 (cross-family review): the stale "zero side effects, no network" claim
|
|
10
|
+
# survived the additions that falsified it, which is how a reader ends up trusting the wrong
|
|
11
|
+
# invariant. No remote network is REQUIRED; some is possible.
|
|
6
12
|
# Wiring: `npm test` for any session; `prepublishOnly` so a publish cannot ship a
|
|
7
13
|
# syntactically broken executable.
|
|
8
14
|
set -u
|
|
@@ -105,10 +111,25 @@ fi
|
|
|
105
111
|
# absent from the tarball — including templates/predelete_check.sh, which CLAUDE.md instructs you
|
|
106
112
|
# to run before a destructive op. Wired here in the same commit that created it, because the two
|
|
107
113
|
# previous anchors this session shipped with zero callers.
|
|
108
|
-
|
|
114
|
+
# Anchored 2026-07-31. Until then this was the ONE subject in this file exempt from the
|
|
115
|
+
# "subject present but anchor missing => FAIL" rule the eight blocks below enforce — and the
|
|
116
|
+
# exemption cost something real: its source-checkout predicate tested `-d .git`, so inside a git
|
|
117
|
+
# WORKTREE (where .git is a FILE) it printed SKIP and returned 0 without scanning. A worktree is
|
|
118
|
+
# how a fresh CI checkout gets approximated, so the check was absent from exactly the tree used
|
|
119
|
+
# to reason about CI. scripts/test_package_coverage_lanes.sh pins the predicate across all four
|
|
120
|
+
# tree shapes plus a known pair.
|
|
121
|
+
if [ ! -f scripts/package_coverage_check.sh ]; then
|
|
122
|
+
echo "SKIP test_package_coverage_lanes.sh (subject scripts/package_coverage_check.sh absent)"
|
|
123
|
+
elif [ -f scripts/test_package_coverage_lanes.sh ]; then
|
|
124
|
+
if ! bash scripts/test_package_coverage_lanes.sh; then
|
|
125
|
+
fail=1
|
|
126
|
+
fi
|
|
109
127
|
if ! bash scripts/package_coverage_check.sh; then
|
|
110
128
|
fail=1
|
|
111
129
|
fi
|
|
130
|
+
else
|
|
131
|
+
echo "FAIL test_package_coverage_lanes.sh: package_coverage_check.sh present but its anchor is missing"
|
|
132
|
+
fail=1
|
|
112
133
|
fi
|
|
113
134
|
|
|
114
135
|
# memory-link-check — the memory store is a GRAPH (memory_intent_recall.md: nodes=files,
|
|
@@ -132,6 +153,21 @@ fi
|
|
|
132
153
|
# prevent. test_card_drift_probe.sh had shipped with ZERO callers since it was written; wiring it
|
|
133
154
|
# here closes that, and the anchors are added to files[] in the same change so package mode runs
|
|
134
155
|
# them too rather than reporting a deleted anchor.
|
|
156
|
+
# consent-class registry floor. Its subject decides whether standing consent may skip an approval
|
|
157
|
+
# prompt, so an uncalibrated instrument there hands out autonomy the operator never granted. The
|
|
158
|
+
# anchor was written into tests/ with ZERO callers first — the same defect this file already
|
|
159
|
+
# records twice above; wiring it here is the fix, not a note about the fix.
|
|
160
|
+
if [ ! -f scripts/consent_registry_check.sh ]; then
|
|
161
|
+
echo "SKIP test_consent_registry.sh (subject scripts/consent_registry_check.sh absent)"
|
|
162
|
+
elif [ -f scripts/test_consent_registry.sh ]; then
|
|
163
|
+
if ! bash scripts/test_consent_registry.sh; then
|
|
164
|
+
fail=1
|
|
165
|
+
fi
|
|
166
|
+
else
|
|
167
|
+
echo "FAIL test_consent_registry.sh: consent_registry_check.sh present but its anchor is missing"
|
|
168
|
+
fail=1
|
|
169
|
+
fi
|
|
170
|
+
|
|
135
171
|
# sidecar_wait stdin plumbing. Its subject is dispatched by auto-decorrelation / steel-quench /
|
|
136
172
|
# sim-conductor / AGENTS.md as the REQUIRED wait form, so a regression there silently empties every
|
|
137
173
|
# cross-family verification. The anchor's first version shipped in tests/ with ZERO callers — the
|
|
@@ -177,6 +213,42 @@ else
|
|
|
177
213
|
fail=1
|
|
178
214
|
fi
|
|
179
215
|
|
|
216
|
+
# Two guards that read the AUTHOR's own actions rather than the repo's files. Both were added
|
|
217
|
+
# 2026-07-31; the pipe-verdict lane shipped in PR #209 WITHOUT this wiring, which is itself the
|
|
218
|
+
# half-fix class the second guard exists to catch — found by running that guard on this repo.
|
|
219
|
+
if [ ! -f scripts/sidecar_calibrate.sh ]; then
|
|
220
|
+
echo "SKIP test_ollama_panel_lanes.sh (subject scripts/sidecar_calibrate.sh absent)"
|
|
221
|
+
elif [ -f scripts/test_ollama_panel_lanes.sh ]; then
|
|
222
|
+
if ! bash scripts/test_ollama_panel_lanes.sh; then
|
|
223
|
+
fail=1
|
|
224
|
+
fi
|
|
225
|
+
else
|
|
226
|
+
echo "FAIL test_ollama_panel_lanes.sh: sidecar_calibrate.sh present but its ollama-leg anchor is missing"
|
|
227
|
+
fail=1
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
if [ ! -f scripts/pipe_verdict_guard.sh ]; then
|
|
231
|
+
echo "SKIP test_pipe_verdict_guard_lanes.sh (subject scripts/pipe_verdict_guard.sh absent)"
|
|
232
|
+
elif [ -f scripts/test_pipe_verdict_guard_lanes.sh ]; then
|
|
233
|
+
if ! bash scripts/test_pipe_verdict_guard_lanes.sh; then
|
|
234
|
+
fail=1
|
|
235
|
+
fi
|
|
236
|
+
else
|
|
237
|
+
echo "FAIL test_pipe_verdict_guard_lanes.sh: pipe_verdict_guard.sh present but its anchor is missing"
|
|
238
|
+
fail=1
|
|
239
|
+
fi
|
|
240
|
+
|
|
241
|
+
if [ ! -f scripts/halffix_propagation_scan.sh ]; then
|
|
242
|
+
echo "SKIP test_halffix_lanes.sh (subject scripts/halffix_propagation_scan.sh absent)"
|
|
243
|
+
elif [ -f scripts/test_halffix_lanes.sh ]; then
|
|
244
|
+
if ! bash scripts/test_halffix_lanes.sh; then
|
|
245
|
+
fail=1
|
|
246
|
+
fi
|
|
247
|
+
else
|
|
248
|
+
echo "FAIL test_halffix_lanes.sh: halffix_propagation_scan.sh present but its anchor is missing"
|
|
249
|
+
fail=1
|
|
250
|
+
fi
|
|
251
|
+
|
|
180
252
|
for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh; do
|
|
181
253
|
if [ ! -f scripts/session_close_check.sh ]; then
|
|
182
254
|
echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
|
|
@@ -203,10 +275,31 @@ if [ -d ".claude/rules" ]; then
|
|
|
203
275
|
# fail=0 → SELFCHECK: PASS. The check would have silently ceased to exist while still
|
|
204
276
|
# reporting a pass — the same shape count_check.sh:71 already guards against with its
|
|
205
277
|
# impossible-zero rule. fh-meta always has refs; zero means the instrument broke.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
278
|
+
# EXTRACTION MOVED OFF grep (2026-07-31, measured on the first real CI run). The pipeline used to
|
|
279
|
+
# be `grep -hoE` + sed + `grep -E` over CLAUDE.md, which is Korean-heavy. On macOS (BSD grep, UTF-8
|
|
280
|
+
# locale) it returned ~50 refs; on the ubuntu runner (GNU grep, LANG unset => C locale) it returned
|
|
281
|
+
# ZERO, and the impossible-zero guard below is the only reason that surfaced as a failure instead
|
|
282
|
+
# of "no refs, all clean". Same root cause as the card-drift probe failing its positive lanes in
|
|
283
|
+
# the same run: multibyte text through locale-dependent grep.
|
|
284
|
+
# python3 reads the files as UTF-8 explicitly, so this extractor no longer has a locale at all.
|
|
285
|
+
# It is already a hard dependency of selfcheck (validate_plugins/marketplace, memory_link_check),
|
|
286
|
+
# so this adds nothing to the requirement set. The regex is the same one, transcribed.
|
|
287
|
+
_refs=$(python3 - <<'REFPY' 2>/dev/null
|
|
288
|
+
import re, glob
|
|
289
|
+
pat = re.compile(r'^(knowledge|templates|scripts|docs|plugins|\.claude)/[^*{}<>$]+\.(md|sh|ya?ml|jsonc|json)$')
|
|
290
|
+
seen = set()
|
|
291
|
+
for f in ['CLAUDE.md'] + sorted(glob.glob('.claude/rules/*.md')):
|
|
292
|
+
try:
|
|
293
|
+
text = open(f, encoding='utf-8', errors='replace').read()
|
|
294
|
+
except OSError:
|
|
295
|
+
continue
|
|
296
|
+
for tok in re.findall(r'`([^` ]+)`', text):
|
|
297
|
+
if pat.match(tok):
|
|
298
|
+
seen.add(tok)
|
|
299
|
+
for p in sorted(seen):
|
|
300
|
+
print(p)
|
|
301
|
+
REFPY
|
|
302
|
+
)
|
|
210
303
|
if [ -z "$_refs" ]; then
|
|
211
304
|
echo "FAIL ref-path: extractor produced 0 refs — the scan broke, it did not pass"
|
|
212
305
|
fail=1
|
|
@@ -146,7 +146,21 @@ fi
|
|
|
146
146
|
# HONEST SCOPE: 카드 🔴/🟡 줄에서 부재-주장 키워드를 잡고, 그 줄의 이름/경로 토큰으로
|
|
147
147
|
# 실물을 글롭 검색한다. 어휘가 안 겹치면 못 잡는다(무음 FN) — 앵커지 floor 가 아니다.
|
|
148
148
|
# 방향은 advisory: 가역 표면에서 하드 블록은 --no-verify 를 학습시킨다(#165 HIGH-1 동일 원리).
|
|
149
|
-
|
|
149
|
+
# COLLATION-FREE BY CONSTRUCTION (2026-07-31, diagnosed ON the runner after two local hypotheses
|
|
150
|
+
# were refuted). This regex used to contain the Hangul RANGE `[^가-힣]`. A multibyte range inside a
|
|
151
|
+
# bracket expression is collation-dependent, and GNU grep in the C locale — the GitHub runner's
|
|
152
|
+
# default — rejects it outright: `grep: Invalid collation character`.
|
|
153
|
+
# The failure mode is what makes it serious: grep writes that to stderr, exits 2, and emits NOTHING.
|
|
154
|
+
# Downstream this is indistinguishable from exit 1 "no match", so the pipeline produced zero lines
|
|
155
|
+
# and the probe concluded "no absence claims in the card" — CLEAN — on every input, positive and
|
|
156
|
+
# negative alike. Its 7 lanes had passed 3/3 on macOS for weeks because BSD grep accepts the range.
|
|
157
|
+
# Same shape as the pyyaml CI trigger caught earlier the same day: grep's ERROR status folded into
|
|
158
|
+
# its NO-MATCH branch. `not found != 0`, and neither is `could not look`.
|
|
159
|
+
# The replacement uses an ASCII-only class, which has no collation to be invalid. SEMANTIC SHIFT,
|
|
160
|
+
# stated rather than hidden: the original meant "산출물 then non-Hangul then 0" (keep the claim
|
|
161
|
+
# inside one clause); this means "산출물 then no DIGIT within 20 chars then 0". Slightly wider, and
|
|
162
|
+
# the lanes below are what pin that it did not become too wide.
|
|
163
|
+
_ABSENCE_RE='미가동|산출물[^0-9]{0,20}0|로그[^0-9]{0,20}0|0건|부재|안 돌|미생성|not running|no output|zero output'
|
|
150
164
|
# 부정/정정 문맥 가드 — 부재-주장을 **인용하며** 정정하는 줄만 건너뛴다. 판별자는 debunk
|
|
151
165
|
# 어휘 단독이 아니라 **부재-키워드가 인용부호 안에 있는가** — challenger A-1 실측: 살아있는
|
|
152
166
|
# 주장 + 무관한 '정정 필요' 가 같은 줄이면 debunk-단독 가드가 진짜 경고를 무음 삼켰다(FN).
|
|
@@ -181,6 +181,91 @@ probe_runtime() {
|
|
|
181
181
|
probe_runtime codex "gpt-5.6-sol"
|
|
182
182
|
probe_runtime agy "Gemini 3.1 Pro (High)"
|
|
183
183
|
|
|
184
|
+
# ── Local OpenAI-compatible panel (Ollama) — a DIFFERENT anchor, on purpose. ──────────────────
|
|
185
|
+
# One host serves several model FAMILIES (measured 2026-07-31: an OpenAI-lineage open-weight, a
|
|
186
|
+
# Qwen, a Gemma, a Mistral on one endpoint), so the panel's unit here is the model, not the binary.
|
|
187
|
+
# Everything is local, so nothing leaves the machine — this is the only panel member a residency-
|
|
188
|
+
# constrained session may use on company-adjacent work.
|
|
189
|
+
#
|
|
190
|
+
# WHY THE IDENTITY PROBE IS REPLACED, NOT REUSED — the self-report anchor above is INVALID for this
|
|
191
|
+
# class. Measured: `gpt-oss:20b` asked which model it is answered "The underlying model is GPT-4
|
|
192
|
+
# (likely)". Open-weight models do not reliably know their own name, so a self-report anchor would
|
|
193
|
+
# mark every one of them UNTRUSTED-PIN and drop a genuinely exact pin from the panel. Applying an
|
|
194
|
+
# instrument that cannot separate a known-positive from a known-negative on this target is the
|
|
195
|
+
# calibration failure this whole file exists to prevent — so the anchor moves to the SERVER'S
|
|
196
|
+
# response envelope, which reports the model actually loaded. That is a server-side fact rather
|
|
197
|
+
# than a model's claim, i.e. strictly stronger than what codex/agy expose through their CLIs.
|
|
198
|
+
#
|
|
199
|
+
# HOST IS NEVER HARDCODED. A LAN/Tailscale address is an internal hostname, and this file is
|
|
200
|
+
# public-tracked; §Company residency keeps those out of committed content. Default is loopback;
|
|
201
|
+
# point FH_OLLAMA_HOST at a remote node from a local, gitignored place.
|
|
202
|
+
OLLAMA_HOST_URL="${FH_OLLAMA_HOST:-http://127.0.0.1:11434}"
|
|
203
|
+
# Measured floor, not a guess: at num_predict=64 a reasoning model spent the ENTIRE budget in its
|
|
204
|
+
# `thinking` field (780 chars) and returned an EMPTY response — which reads identically to "cannot
|
|
205
|
+
# carry a verdict". At 512 the same model answered `PASS`. Budget starvation and incapacity are
|
|
206
|
+
# different findings and must not be reported as one.
|
|
207
|
+
OLLAMA_NUM_PREDICT="${FH_OLLAMA_NUM_PREDICT:-512}"
|
|
208
|
+
|
|
209
|
+
probe_ollama() {
|
|
210
|
+
command -v curl >/dev/null 2>&1 || { echo "ollama ABSENT — curl missing (absence measured)"; return 0; }
|
|
211
|
+
curl -sf --max-time 10 "$OLLAMA_HOST_URL/api/version" >/dev/null 2>&1 || {
|
|
212
|
+
printf 'ollama ABSENT — no server at the configured host (absence measured, not assumed)\n'
|
|
213
|
+
printf ' set FH_OLLAMA_HOST to probe a remote node; default is loopback\n'
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
local models="${FH_OLLAMA_MODELS:-}"
|
|
218
|
+
if [ -z "$models" ]; then
|
|
219
|
+
models=$(curl -sf --max-time 15 "$OLLAMA_HOST_URL/api/tags" 2>/dev/null \
|
|
220
|
+
| python3 -c 'import json,sys
|
|
221
|
+
try: d=json.load(sys.stdin)
|
|
222
|
+
except Exception: raise SystemExit
|
|
223
|
+
print(",".join(m["name"] for m in d.get("models",[])[:6]))' 2>/dev/null)
|
|
224
|
+
fi
|
|
225
|
+
[ -n "$models" ] || { echo "ollama REACHABLE but no models listed"; return 0; }
|
|
226
|
+
|
|
227
|
+
# Control runs ONCE per host, not per model: it is a property of the server, and repeating it per
|
|
228
|
+
# model would just multiply the cost of a fact that cannot differ.
|
|
229
|
+
local ctl ctl_state
|
|
230
|
+
ctl=$(curl -s --max-time 20 "$OLLAMA_HOST_URL/api/generate" \
|
|
231
|
+
-d '{"model":"fh-calib-nonexistent:99b","prompt":"hi","stream":false}' 2>&1)
|
|
232
|
+
if printf '%s' "$ctl" | grep -qiE '"error"|not found'; then ctl_state="rejects-bogus"; else ctl_state="accepts-bogus"; fi
|
|
233
|
+
|
|
234
|
+
local IFS=,
|
|
235
|
+
for m in $models; do
|
|
236
|
+
[ -n "$m" ] || continue
|
|
237
|
+
local body out env_model resp compact pin v
|
|
238
|
+
body=$(python3 -c 'import json,sys; print(json.dumps({"model":sys.argv[1],"prompt":sys.argv[2],"stream":False,"options":{"num_predict":int(sys.argv[3]),"temperature":0}}))' \
|
|
239
|
+
"$m" "$VERDICT_PROMPT" "$OLLAMA_NUM_PREDICT")
|
|
240
|
+
out=$(curl -sf --max-time 240 "$OLLAMA_HOST_URL/api/generate" -d "$body" 2>/dev/null)
|
|
241
|
+
if [ -z "$out" ]; then
|
|
242
|
+
printf 'ollama %-22s UNREACHABLE-THIS-RUN (measured, not inferred)\n' "$m"; continue
|
|
243
|
+
fi
|
|
244
|
+
env_model=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("model",""))' 2>/dev/null)
|
|
245
|
+
resp=$(printf '%s' "$out" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("response") or "").strip())' 2>/dev/null)
|
|
246
|
+
compact=$(printf '%s' "$resp" | tr -s '[:space:]' ' ' | sed 's/^ *//; s/ *$//')
|
|
247
|
+
if [ "$env_model" = "$m" ]; then pin="PIN-OK(envelope)"; else pin="UNTRUSTED-PIN"; fi
|
|
248
|
+
if [ "${#compact}" -le 12 ] && printf '%s' "$compact" | grep -qiE '^(pass|fail)[.!]?$'; then v="VERDICT-OK"; else v="VERDICT-UNPARSEABLE"; fi
|
|
249
|
+
printf 'ollama %-22s REACHABLE · %s · control: %s · %s\n' "$m" "$pin" "$ctl_state" "$v"
|
|
250
|
+
[ -z "$QUIET" ] && printf ' answered: %s\n' "$(printf '%s' "$compact" | cut -c1-60)"
|
|
251
|
+
if [ "$v" = "VERDICT-UNPARSEABLE" ] && [ -z "$compact" ]; then
|
|
252
|
+
printf ' ⚠️ EMPTY answer. Before recording this as "cannot carry a verdict", re-run with a\n'
|
|
253
|
+
printf ' larger FH_OLLAMA_NUM_PREDICT — a reasoning model can spend the whole budget\n'
|
|
254
|
+
printf ' thinking and return nothing, which looks identical from out here.\n'
|
|
255
|
+
fi
|
|
256
|
+
[ "$pin" = "PIN-OK(envelope)" ] && [ "$v" = "VERDICT-OK" ] && PANEL="${PANEL:+$PANEL, }ollama:$m"
|
|
257
|
+
done
|
|
258
|
+
}
|
|
259
|
+
# `--stub-model` means the caller is the hermetic CLI lane harness, which drives stub BINARIES and
|
|
260
|
+
# asserts on an empty panel. This leg talks to a SERVER, so on a developer machine with a local
|
|
261
|
+
# Ollama running it would populate the panel and break those lanes — which is exactly what happened
|
|
262
|
+
# when this was first wired (test_sidecar_calibrate_lanes lane6, "empty panel not stated", caught by
|
|
263
|
+
# the existing suite rather than by review). Skipping under --stub-model keeps each harness hermetic
|
|
264
|
+
# in its own way; this leg's own lanes drive a stub SERVER via FH_OLLAMA_HOST instead.
|
|
265
|
+
if [ -z "$STUB_MODEL" ] && { [ -z "$ONLY" ] || [ "$ONLY" = "ollama" ]; }; then
|
|
266
|
+
probe_ollama
|
|
267
|
+
fi
|
|
268
|
+
|
|
184
269
|
if [ -n "$PANEL" ]; then
|
|
185
270
|
echo "PANEL: $PANEL — usable different-family auditor(s), pin verified this run"
|
|
186
271
|
else
|
|
@@ -29,6 +29,25 @@ run_fixture() { # $1=name $2=card-content $3=make-artifact(0/1) $4=expect-wa
|
|
|
29
29
|
local out; out=$(bash "$CHECK" "$T" 2>/dev/null)
|
|
30
30
|
local warned=0
|
|
31
31
|
printf '%s\n' "$out" | grep -q "⑤-b card-drift" && warned=1
|
|
32
|
+
# RUN EVERY FIXTURE TWICE — ambient locale AND LC_ALL=C. Added 2026-07-31 after the probe passed
|
|
33
|
+
# 3/3 here for weeks while returning warn=0 for EVERY lane on the ubuntu runner. Cause: the
|
|
34
|
+
# absence regex held a Hangul RANGE `[^가-힣]`, which GNU grep in the C locale rejects with
|
|
35
|
+
# "Invalid collation character" — it exits 2 and prints nothing, which downstream is
|
|
36
|
+
# indistinguishable from "no match", so the probe reported the card clean on every input.
|
|
37
|
+
# BSD grep accepts the range, so no amount of running this suite on macOS could ever have caught
|
|
38
|
+
# it. MEASURED LIMIT, stated because the first version of this comment overclaimed: LC_ALL=C on
|
|
39
|
+
# macOS is NOT a stand-in for GNU grep — reverting the Hangul range with this dual-run in place
|
|
40
|
+
# still passed here. So this leg detects the class only where GNU grep runs, i.e. in CI. It is
|
|
41
|
+
# kept because a locale-divergent verdict is a defect wherever it is observed, and it costs one
|
|
42
|
+
# extra invocation; the platform-independent detector is the source lint below.
|
|
43
|
+
local out_c; out_c=$(LC_ALL=C LANG=C bash "$CHECK" "$T" 2>/dev/null)
|
|
44
|
+
local warned_c=0
|
|
45
|
+
printf '%s\n' "$out_c" | grep -q "⑤-b card-drift" && warned_c=1
|
|
46
|
+
if [ "$warned" != "$warned_c" ]; then
|
|
47
|
+
echo "❌ $name — LOCALE-DIVERGENT: ambient warn=$warned but LC_ALL=C warn=$warned_c"
|
|
48
|
+
echo " 텍스트 계기가 로케일에 따라 다른 판정을 낸다 — 어느 쪽이 맞든 캘리브레이션 실패다."
|
|
49
|
+
rm -rf "$T"; FAILED=1; return
|
|
50
|
+
fi
|
|
32
51
|
rm -rf "$T"
|
|
33
52
|
if [ "$warned" = "$expect" ]; then
|
|
34
53
|
echo "✅ $name (warn=$warned, expected=$expect)"
|
|
@@ -73,5 +92,41 @@ run_fixture "P4 영어 부재주장 (A-4 앵커)" \
|
|
|
73
92
|
run_fixture "N7 인용된 부재키워드+정정 → 무경고 (실카드 07-22 클래스)" \
|
|
74
93
|
"- 🔴 foo-digest 산출 누락. 기존 카드의 \"미가동\" 주장은 오판정이었음" 1 0
|
|
75
94
|
|
|
95
|
+
|
|
96
|
+
# ── LOCALE-RANGE LINT (platform-independent, added 2026-07-31) ─────────────────────────
|
|
97
|
+
# The behavioural dual-run above cannot fire on BSD grep, so the class needs a detector that does
|
|
98
|
+
# not depend on which grep is installed. This one reads the SOURCE: a bracket expression containing
|
|
99
|
+
# a non-ASCII RANGE (`[^가-힣]`, `[ㄱ-ㅎ]`, …) is collation-dependent by construction and will be
|
|
100
|
+
# rejected outright by GNU grep in the C locale — "Invalid collation character", exit 2, no output,
|
|
101
|
+
# which downstream reads as "no match" and therefore as clean.
|
|
102
|
+
# Alternation of non-ASCII literals (`(🔴|🟡)`, `미가동|부재`) is NOT flagged: it carries no
|
|
103
|
+
# collation, and the runner proved it works (the emoji line-selection stage passed there while the
|
|
104
|
+
# range stage errored). Flagging it would push an author to mangle working code.
|
|
105
|
+
echo "-- locale-range lint: 비ASCII 문자 범위를 쓰는 정규식 (collation 의존) --"
|
|
106
|
+
LINT_OUT=$(python3 - "$SCRIPT_DIR" <<'LINTPY'
|
|
107
|
+
import re, sys, glob, os
|
|
108
|
+
root = sys.argv[1]
|
|
109
|
+
# a bracket expression containing <non-ascii> - <non-ascii>
|
|
110
|
+
rng = re.compile(r'\[[^]\n]*[^\x00-\x7F]-[^\x00-\x7F][^]\n]*\]')
|
|
111
|
+
hits = []
|
|
112
|
+
for f in sorted(glob.glob(os.path.join(root, '*.sh'))):
|
|
113
|
+
for i, line in enumerate(open(f, encoding='utf-8', errors='replace'), 1):
|
|
114
|
+
if line.lstrip().startswith('#'):
|
|
115
|
+
continue
|
|
116
|
+
m = rng.search(line)
|
|
117
|
+
if m:
|
|
118
|
+
hits.append(f"{os.path.basename(f)}:{i}: {m.group(0)}")
|
|
119
|
+
print('\n'.join(hits))
|
|
120
|
+
LINTPY
|
|
121
|
+
)
|
|
122
|
+
if [ -n "$LINT_OUT" ]; then
|
|
123
|
+
echo "❌ 비ASCII 범위 발견 — GNU grep(C 로케일)에서 exit 2 로 죽고 결과가 '무매치'로 읽힌다:"
|
|
124
|
+
printf '%s\n' "$LINT_OUT" | sed 's/^/ /'
|
|
125
|
+
FAILED=1
|
|
126
|
+
else
|
|
127
|
+
echo "✅ locale-range lint: 비ASCII 문자 범위 없음"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
|
|
76
131
|
echo "── card-drift probe calibration: $([ "$FAILED" -eq 0 ] && echo "PASS (전 픽스처) — 배선 가능" || echo "FAIL — 계기 불량, 배선 금지") ──"
|
|
77
132
|
exit "$FAILED"
|