@chrono-meta/fh-gate 3.1.4 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/registry/agent_cards.json +1 -1
- package/.claude/rules/fh_4axis_gate.md +26 -1
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +8 -2
- package/CATALOG.md +4 -4
- package/CHEATSHEET.md +1 -1
- package/CLAUDE.md +54 -10
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/docs/OUTPUT_EVIDENCE.md +2 -2
- package/docs/STANDARDS_ALIGNMENT.md +1 -1
- package/docs/codex-compat.md +1 -1
- package/knowledge/shared/harness-core/agents_md_runtime_details.md +2 -2
- package/knowledge/shared/harness-core/claude_md_gate_details.md +26 -0
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +1 -1
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +76 -3
- package/knowledge/shared/harness-core/governance_engineering_definition.md +89 -0
- package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +1 -1
- package/knowledge/shared/harness-core/skill_quality_rubric.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +99 -0
- package/knowledge/shared/rules/auto_project_mapping.md +1 -1
- package/knowledge/shared/rules/modes_and_value.md +2 -2
- package/package.json +12 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-commons/README.md +38 -0
- package/plugins/fh-commons/skills/preprep/SKILL.md +23 -0
- package/plugins/fh-commons/skills/preprep/fixtures/font_revert_probe.py +92 -0
- package/plugins/fh-commons/skills/preprep/lane_font.py +462 -0
- package/plugins/fh-commons/skills/preprep/preprep.py +16 -1
- package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +17 -0
- package/plugins/fh-commons/skills/preprep/test_lane_font.py +452 -0
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +164 -0
- package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +40 -0
- package/plugins/fh-meta/skills/frontier-digest/SKILL.md +1 -1
- package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +43 -5
- package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL.md +75 -3
- package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL_detail.md +2 -2
- package/plugins/fh-meta/skills/harvest-loop/SKILL_detail.md +2 -2
- package/plugins/fh-meta/skills/install-doctor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
- package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
- package/plugins/fh-meta/skills/pipeline-conductor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +1 -1
- package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
- package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
- package/scripts/doc_claim_triad_scan.py +303 -0
- package/scripts/finding_fleet.sh +558 -0
- package/scripts/finding_pipeline.sh +572 -0
- package/scripts/finding_verifier.sh +173 -0
- package/scripts/finding_verify.py +468 -0
- package/scripts/frontier_digest_autopilot.sh +3 -3
- package/scripts/gate_pathspec_check.sh +1 -0
- package/scripts/gate_shape_scan.sh +120 -0
- package/scripts/selfcheck.sh +50 -0
- package/scripts/test_doc_claim_triad_lanes.sh +124 -0
- package/scripts/test_finding_pipeline_lanes.sh +2011 -0
- package/scripts/test_gate_shape_scan_lanes.sh +47 -0
- package/scripts/test_heavy_classifier_lanes.sh +13 -3
- package/scripts/test_marker_crossfamily_lanes.sh +75 -3
- package/scripts/test_marker_standpoint_lanes.sh +31 -6
- package/scripts/test_preprep_font_lanes.sh +87 -0
- package/templates/.git-hooks/pre-commit +158 -11
- package/templates/PRE-PUBLISH-CHECKLIST.md +29 -0
- package/templates/local_fh_context.md +1 -1
- package/templates/regression_guard.sh +1 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""doc_claim_triad_scan.py — 문서가 「A 가 B 를 쓴다」고 적었을 때, A 가 실제로 B 를 «실행»하는가.
|
|
3
|
+
|
|
4
|
+
왜 존재하나
|
|
5
|
+
-----------
|
|
6
|
+
`[[feedback_rule_misdescribes_its_own_machine]]` — 문서는 정상 발화하는데 «자기 기계에 대한
|
|
7
|
+
서술»이 틀린 결함 클래스. 코드가 아니라 «주장»이 틀린 것이라 적대검증·블라인드 sim·되돌림이
|
|
8
|
+
구조적으로 못 잡는다. 이 레포에서 실제로 두 번 났다(둘 다 CLAUDE.md 상주층).
|
|
9
|
+
|
|
10
|
+
판별자는 «이름이 있나»가 아니라 «실행하나»다 — 실측 known-pair:
|
|
11
|
+
templates/.git-hooks/pre-push:631 bash "$REPO_ROOT/scripts/session_close_check.sh" → 실행
|
|
12
|
+
templates/.git-hooks/pre-push:514 grep -E '(...|predelete_check)\\.sh' → 언급뿐
|
|
13
|
+
|
|
14
|
+
🟥 이것은 «리뷰 표면»이지 판정이 아니다. 문서가 두 경로를 나란히 적었다고 해서 «A 가 B 를
|
|
15
|
+
부른다»는 주장인 것은 아니다(과탐 방향). 그래서 출력은 «확인하라»지 «결함이다»가 아니다.
|
|
16
|
+
|
|
17
|
+
🟥 구조 술어만 쓴다 — FH 고유 어휘(마커·4축·영혼)를 넣지 않는다. 그래야 남의 레포에도 돈다.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
import os, re, sys, json, argparse
|
|
21
|
+
from typing import Dict, List, Tuple
|
|
22
|
+
|
|
23
|
+
DOC_EXT = {".md"}
|
|
24
|
+
EXEC_EXT = {".sh", ".py", ".js", ".mjs", ".ts", ".bash"}
|
|
25
|
+
HOOK_NAMES = {"pre-commit", "pre-push", "commit-msg", "post-commit", "prepare-commit-msg"}
|
|
26
|
+
|
|
27
|
+
PATH_RE = re.compile(r'(?<![\w/.-])((?:[\w.-]+/)+[\w.-]+(?:\.(?:sh|py|js|mjs|ts|bash|yml|yaml|json|md))?)')
|
|
28
|
+
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".playwright-mcp"}
|
|
29
|
+
|
|
30
|
+
def is_execish(p: str) -> bool:
|
|
31
|
+
b = os.path.basename(p)
|
|
32
|
+
return os.path.splitext(b)[1] in EXEC_EXT or b in HOOK_NAMES
|
|
33
|
+
|
|
34
|
+
def walk(root: str, exts=None) -> List[str]:
|
|
35
|
+
out = []
|
|
36
|
+
for dp, dns, fns in os.walk(root):
|
|
37
|
+
dns[:] = [d for d in dns if d not in SKIP_DIRS]
|
|
38
|
+
for fn in fns:
|
|
39
|
+
if exts is None or os.path.splitext(fn)[1] in exts:
|
|
40
|
+
out.append(os.path.join(dp, fn))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
def _inside(root: str, path: str) -> bool:
|
|
44
|
+
"""path 가 root 안에 «실제로» 있나 — 심링크까지 풀어서 판정한다.
|
|
45
|
+
|
|
46
|
+
🟥 이 함수가 없으면 «문서가 부르는 경로»가 스캔 루트를 벗어난다. 이 스캐너는 대상 파일의
|
|
47
|
+
매칭 줄을 증거로 «출력»하므로, `../private/deploy.sh` 한 줄이면 그 파일의 자격증명이 든 줄이
|
|
48
|
+
리포트에 실린다. 그리고 이 스캐너는 신뢰할 수 없는 문서를 읽는 것이 일이다.
|
|
49
|
+
(cross-family security review 2026-09-09 — 루트 밖 읽기와 스니펫 반환을 프로브로 실증했다.)
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
r = os.path.realpath(root)
|
|
53
|
+
c = os.path.realpath(path)
|
|
54
|
+
except OSError:
|
|
55
|
+
return False
|
|
56
|
+
return c == r or c.startswith(r + os.sep)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve(root: str, p: str) -> str | None:
|
|
60
|
+
# `..` 를 담은 참조는 «해석하기 전에» 거절한다 — 해석 후 판정은 심링크 한 겹에 뚫린다.
|
|
61
|
+
cand = os.path.join(root, p)
|
|
62
|
+
if os.path.isfile(cand) and not os.path.islink(cand) and _inside(root, cand):
|
|
63
|
+
return cand
|
|
64
|
+
base = os.path.basename(p)
|
|
65
|
+
hits = [f for f in walk(root)
|
|
66
|
+
if os.path.basename(f) == base and not os.path.islink(f) and _inside(root, f)]
|
|
67
|
+
return hits[0] if len(hits) == 1 else None
|
|
68
|
+
|
|
69
|
+
# 실행 증거: 인터프리터 뒤 또는 명령 위치. 주석줄과 grep 패턴 안은 제외.
|
|
70
|
+
def executes(src_path: str, target_base: str, target_path: str = "") -> Tuple[str, str]:
|
|
71
|
+
"""→ (verdict, evidence) verdict ∈ EXECUTES | MENTIONS-ONLY | ABSENT"""
|
|
72
|
+
try:
|
|
73
|
+
lines = open(src_path, encoding="utf-8", errors="replace").read().split("\n")
|
|
74
|
+
except OSError:
|
|
75
|
+
return ("ABSENT", "unreadable")
|
|
76
|
+
mention = ""
|
|
77
|
+
esc = re.escape(target_base)
|
|
78
|
+
# 🟥 어간으로도 잡는다 — grep 대안식 안에서는 베이스네임이 이어져 있지 않다.
|
|
79
|
+
# 실측: pre-push:514 는 `predelete_check)\.sh` 라 "predelete_check.sh" 로는 0건이다.
|
|
80
|
+
stem = os.path.splitext(target_base)[0]
|
|
81
|
+
stem_re = re.compile(re.escape(stem) + r'[)\\]*' + re.escape(os.path.splitext(target_base)[1]))
|
|
82
|
+
exec_re = re.compile(r'(?:^|[;&|(]|\$\()\s*(?:(?:bash|sh|source|\.|python3?|node|exec|zsh)\s+)?'
|
|
83
|
+
r'[^\n#]{0,120}?' + esc)
|
|
84
|
+
# 🟥 전체 경로가 줄에 있으면 그걸 우선한다 (cross-family codex [A]).
|
|
85
|
+
# basename 만 보면 다른 디렉터리의 동명 파일 실행을 B 실행으로 오인한다.
|
|
86
|
+
for i, ln in enumerate(lines, 1):
|
|
87
|
+
has_full = bool(target_path) and target_path in ln
|
|
88
|
+
if not has_full and target_base not in ln and not stem_re.search(ln):
|
|
89
|
+
continue
|
|
90
|
+
mention = mention or f"{i}: {ln.strip()[:110]}"
|
|
91
|
+
stripped = ln.lstrip()
|
|
92
|
+
if stripped.startswith("#"):
|
|
93
|
+
continue
|
|
94
|
+
# grep/rg 패턴 안이면 언급이다
|
|
95
|
+
if re.search(r'\b(?:grep|rg|egrep|fgrep|ag)\b[^\n]{0,80}' + esc, ln):
|
|
96
|
+
continue
|
|
97
|
+
# 인터프리터 호출 또는 명령 위치
|
|
98
|
+
# 🟥 출력 구문 안의 인터프리터 호출은 실행이 아니다 (cross-family codex [A]).
|
|
99
|
+
# `echo "bash target.sh"` 는 실행처럼 «보이지만» 아무것도 안 돌린다.
|
|
100
|
+
if re.search(r'\b(?:echo|printf|cat|sed|awk)\b[^\n]{0,40}' + esc, ln):
|
|
101
|
+
continue
|
|
102
|
+
if re.search(r'(?:bash|sh|zsh|source|python3?|node|exec)\s+["\']?[^"\']{0,120}' + esc, ln):
|
|
103
|
+
return ("EXECUTES", f"{i}: {ln.strip()[:110]}")
|
|
104
|
+
if exec_re.search(ln) and not re.search(r'(?:echo|printf|cat)\s', ln):
|
|
105
|
+
return ("EXECUTES", f"{i}: {ln.strip()[:110]}")
|
|
106
|
+
return ("MENTIONS-ONLY", mention) if mention else ("ABSENT", "")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── 🟥 나열과 주장을 가른다 ───────────────────────────────────────────────────
|
|
110
|
+
# 손검증 2026-09-07: 초판은 «한 줄에 두 실행아티팩트» 를 주장으로 셌는데, 표본이 전부
|
|
111
|
+
# **File:** a.sh · b.sh · c (나열)
|
|
112
|
+
# Fixtures: `x_lanes.sh` · `y_lanes.sh`. (나열)
|
|
113
|
+
# python3 scan.py --files src/a.py (예시 인자)
|
|
114
|
+
# 였다. 즉 «관계» 가 아니라 «공존» 을 쟀다 — [[feedback_metric_measures_presence_not_relation]].
|
|
115
|
+
#
|
|
116
|
+
# 좁히는 술어는 «구조»로만 짠다(언어 중립 — 남의 레포에도 같은 잣대여야 하므로):
|
|
117
|
+
# 두 경로 «사이»의 텍스트가 구분자/공백/괄호/백틱뿐이면 그건 나열이다.
|
|
118
|
+
# 사이에 실질 낱말이 최소 1개 있어야 관계 주장 후보다.
|
|
119
|
+
_SEP_ONLY = re.compile(r'^[\s`\'"*·,;|/()\[\]{}<>&+~\-—–:.]*$')
|
|
120
|
+
|
|
121
|
+
# 🟥 접속사만 남은 사이 = 여전히 나열이다 (cross-family codex 2026-09-07 [B] · 레인 L3 가 빨개져서 확인).
|
|
122
|
+
# 실측: `A · B` `A, B` `"A" "B"` 는 이미 걸렀는데 **`A and B` · `A plus B` 는 통과했다.**
|
|
123
|
+
# 실물 사례 — CATALOG.md:137 `Anchors: X (pre-commit) and Y (pre-push)`.
|
|
124
|
+
#
|
|
125
|
+
# 🟥 **명명된 대가: 이 목록은 언어 의존이다.** 이 파일의 나머지는 구조 술어라 어느 레포에나
|
|
126
|
+
# 같은 잣대인데, 이 한 줄만 «영어·한국어 접속사»를 안다. 남의 레포에 돌릴 때 그 언어의
|
|
127
|
+
# 접속사가 목록에 없으면 **그쪽 나열이 주장으로 세어진다**(= 그쪽 주장 수가 부풀려진다).
|
|
128
|
+
# 측정에 쓸 때 편향 방향: 영어 접속사는 덮으므로 **영어 레포는 더 엄격하게** 걸러진다 —
|
|
129
|
+
# 즉 octo 쪽 후보가 줄고 FH 쪽 분모가 상대적으로 커 보이는 방향이다(우리에게 불리한 쪽).
|
|
130
|
+
_CONNECTORS = {"and", "plus", "or", "및", "와", "과", "그리고", "vs", "versus", "&"}
|
|
131
|
+
|
|
132
|
+
def asserts_relation(line: str, a: str, b: str) -> bool:
|
|
133
|
+
ia, ib = line.find(a), line.find(b)
|
|
134
|
+
if ia < 0 or ib < 0:
|
|
135
|
+
return False
|
|
136
|
+
lo, hi = (ia + len(a), ib) if ia < ib else (ib + len(b), ia)
|
|
137
|
+
if hi <= lo:
|
|
138
|
+
return False
|
|
139
|
+
between = line[lo:hi]
|
|
140
|
+
if len(between) > 200:
|
|
141
|
+
return False
|
|
142
|
+
if _SEP_ONLY.match(between):
|
|
143
|
+
return False
|
|
144
|
+
# 구분자를 걷어낸 나머지가 접속사뿐이면 그것도 나열이다
|
|
145
|
+
words = [w for w in re.split(r'[^\w가-힣&]+', between) if w]
|
|
146
|
+
if words and all(w.lower() in _CONNECTORS for w in words):
|
|
147
|
+
return False
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
def scan(root: str) -> Dict:
|
|
151
|
+
root = os.path.abspath(root)
|
|
152
|
+
claims = []
|
|
153
|
+
for doc in walk(root, DOC_EXT):
|
|
154
|
+
try:
|
|
155
|
+
lines = open(doc, encoding="utf-8", errors="replace").read().split("\n")
|
|
156
|
+
except OSError:
|
|
157
|
+
continue
|
|
158
|
+
for i, ln in enumerate(lines, 1):
|
|
159
|
+
paths = [p for p in PATH_RE.findall(ln)]
|
|
160
|
+
paths = [p for p in dict.fromkeys(paths)]
|
|
161
|
+
execs = [p for p in paths if is_execish(p)]
|
|
162
|
+
if len(paths) < 2 or not execs:
|
|
163
|
+
continue
|
|
164
|
+
for a in execs:
|
|
165
|
+
for b in paths:
|
|
166
|
+
if b == a or not is_execish(b):
|
|
167
|
+
continue
|
|
168
|
+
if not asserts_relation(ln, a, b):
|
|
169
|
+
continue
|
|
170
|
+
claims.append((os.path.relpath(doc, root), i, a, b, ln.strip()[:150]))
|
|
171
|
+
# 🟥 방향은 문서에서 못 읽는다 — «A 가 B 에 배선됐다» 와 «A 가 B 를 부른다» 가
|
|
172
|
+
# 같은 두 경로로 반대 방향을 뜻한다. 그래서 **무순서 쌍**으로 접고, 어느 한쪽이라도
|
|
173
|
+
# 실행하면 EXECUTES 다. 이 접기를 안 하면 참인 주장 절반이 MENTIONS-ONLY 로 나온다
|
|
174
|
+
# (실측: CLAUDE.md:1422 session_close_check ↔ pre-push).
|
|
175
|
+
# 🟥 명명된 대가 (cross-family codex 2026-09-07 [S]): 이 접기 때문에 **방향이 실제로 반대인
|
|
176
|
+
# 거짓 주장은 통과한다.** 고치지 않는다 — 방향은 산문에서 안 읽히고, 그걸 읽으려는 시도가
|
|
177
|
+
# 이 계기가 세 번 뚫린 이유다. 이 계기는 오탐을 줄이는 쪽을 택하고, 방향 오류는
|
|
178
|
+
# **사람/에이전트 판단에 남긴다**. 리뷰 표면이지 판정이 아니라는 계약과 일관된다.
|
|
179
|
+
seen = set(); results = []
|
|
180
|
+
for doc, ln, a, b, text in claims:
|
|
181
|
+
key = (doc, ln, *sorted((a, b)))
|
|
182
|
+
if key in seen:
|
|
183
|
+
continue
|
|
184
|
+
seen.add(key)
|
|
185
|
+
ap = resolve(root, a); bp = resolve(root, b)
|
|
186
|
+
if ap is None or bp is None:
|
|
187
|
+
miss = "A-MISSING" if ap is None else "B-MISSING"
|
|
188
|
+
results.append(dict(doc=doc, line=ln, a=a, b=b, verdict=miss, ev="", text=text)); continue
|
|
189
|
+
v1, e1 = executes(ap, os.path.basename(b), b)
|
|
190
|
+
v2, e2 = executes(bp, os.path.basename(a), a)
|
|
191
|
+
if v1 == "EXECUTES":
|
|
192
|
+
results.append(dict(doc=doc, line=ln, a=a, b=b, verdict="EXECUTES", ev=f"{a}: {e1}", text=text))
|
|
193
|
+
elif v2 == "EXECUTES":
|
|
194
|
+
results.append(dict(doc=doc, line=ln, a=b, b=a, verdict="EXECUTES", ev=f"{b}: {e2}", text=text))
|
|
195
|
+
else:
|
|
196
|
+
ev = f"{a}: {e1}" if e1 else (f"{b}: {e2}" if e2 else "")
|
|
197
|
+
v = "MENTIONS-ONLY" if (e1 or e2) else "ABSENT"
|
|
198
|
+
results.append(dict(doc=doc, line=ln, a=a, b=b, verdict=v, ev=ev, text=text))
|
|
199
|
+
counts = {}
|
|
200
|
+
for r in results:
|
|
201
|
+
counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
|
|
202
|
+
return dict(root=root, docs=len(walk(root, DOC_EXT)), claims=len(results),
|
|
203
|
+
counts=counts, results=results)
|
|
204
|
+
|
|
205
|
+
# ── 계기 보정 ────────────────────────────────────────────────────────────────
|
|
206
|
+
def selftest(root: str) -> int:
|
|
207
|
+
"""known-pair — 이 레포의 «실물»로 보정한다. 픽스처가 아니라 실재하는 두 관계."""
|
|
208
|
+
pairs = [
|
|
209
|
+
# (A, B, 기대, 왜)
|
|
210
|
+
("templates/.git-hooks/pre-push", "scripts/session_close_check.sh", "EXECUTES",
|
|
211
|
+
"known-negative: 진짜로 실행한다 (:631 bash ...)"),
|
|
212
|
+
("templates/.git-hooks/pre-push", "scripts/predelete_check.sh", "MENTIONS-ONLY",
|
|
213
|
+
"known-positive: grep 패턴 안 + 주석뿐 (:514 :705)"),
|
|
214
|
+
]
|
|
215
|
+
# ── 절반 ①: 주장 추출기 (나열 vs 관계) ──────────────────────────────
|
|
216
|
+
# 🟥 이 보정이 없어서 초판이 «공존» 을 «주장» 으로 세고 756건이라는 거짓 숫자를 냈다.
|
|
217
|
+
# 픽스처는 그때 손검증에서 나온 «실제로 뚫린 표기» 그대로 쓴다.
|
|
218
|
+
A, B = "scripts/foo.sh", "scripts/bar.sh"
|
|
219
|
+
extract_pairs = [
|
|
220
|
+
(f"**File:** {A} \u00b7 {B} \u00b7 templates/.git-hooks/pre-commit", False, "나열(가운뎃점)"),
|
|
221
|
+
(f"Fixtures: `{A}` \u00b7 `{B}`.", False, "나열(백틱+가운뎃점)"),
|
|
222
|
+
(f"Anchors: `{A}`, `{B}`", False, "나열(쉼표)"),
|
|
223
|
+
(f"{A} 는 {B} 를 호출한다", True, "관계(한국어)"),
|
|
224
|
+
(f"`{A}` is wired into `{B}`", True, "관계(영어)"),
|
|
225
|
+
(f"the hook {A} blocks by running {B}", True, "관계(영어·동사)"),
|
|
226
|
+
]
|
|
227
|
+
print("계기 보정 ① — 주장 추출기 (나열을 주장으로 세지 않는가)")
|
|
228
|
+
bad0 = 0
|
|
229
|
+
for line, want, why in extract_pairs:
|
|
230
|
+
got = asserts_relation(line, A, B)
|
|
231
|
+
ok = got == want
|
|
232
|
+
bad0 += 0 if ok else 1
|
|
233
|
+
print(f" {'PASS' if ok else 'FAIL'} got={str(got):<5} want={str(want):<5} {why}")
|
|
234
|
+
if bad0:
|
|
235
|
+
print(f"\nINSTRUMENT ERROR — 주장 추출기가 {bad0}건을 안 가른다.")
|
|
236
|
+
return 2
|
|
237
|
+
print()
|
|
238
|
+
|
|
239
|
+
print("계기 보정 \u2461 — 실행 판별기 (이 레포의 실물 관계)")
|
|
240
|
+
bad = 0
|
|
241
|
+
for a, b, want, why in pairs:
|
|
242
|
+
ap = resolve(root, a)
|
|
243
|
+
if ap is None:
|
|
244
|
+
print(f" FAIL {a} 를 못 찾음 — 보정 불가"); bad += 1; continue
|
|
245
|
+
# 🟥 B 의 실재도 본다 (cross-family codex, 2026-09-07 [S]).
|
|
246
|
+
# 안 보면 대상이 삭제·이동돼도 «A 가 이름을 언급»한다는 이유로 보정이 통과한다.
|
|
247
|
+
# known-pair 가 조용히 낡는 자리다.
|
|
248
|
+
if resolve(root, b) is None:
|
|
249
|
+
print(f" FAIL {b} 가 실재하지 않는다 — known-pair 가 낡았다"); bad += 1; continue
|
|
250
|
+
got, ev = executes(ap, os.path.basename(b))
|
|
251
|
+
ok = got == want
|
|
252
|
+
bad += 0 if ok else 1
|
|
253
|
+
print(f" {'PASS' if ok else 'FAIL'} {os.path.basename(b):<28} got={got:<14} want={want:<14} {why}")
|
|
254
|
+
if not ok:
|
|
255
|
+
print(f" 증거: {ev[:100]}")
|
|
256
|
+
if bad:
|
|
257
|
+
print(f"\nINSTRUMENT ERROR — {bad} 건이 안 갈린다. 이 스캐너의 숫자를 쓰지 마라.")
|
|
258
|
+
return 2
|
|
259
|
+
print("\n계기가 둘을 가른다. 「실행」과 「언급」이 구별된다.")
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
def main() -> int:
|
|
263
|
+
ap = argparse.ArgumentParser()
|
|
264
|
+
ap.add_argument("root", nargs="?", default=".")
|
|
265
|
+
ap.add_argument("--selftest", action="store_true")
|
|
266
|
+
ap.add_argument("--json", action="store_true")
|
|
267
|
+
ap.add_argument("--home", default=None,
|
|
268
|
+
help="외부 레포를 스캔할 때 «실행 판별기» 보정을 어느 레포에서 할지. "
|
|
269
|
+
"executes() 는 파일 내용의 순수 함수라 보정이 이식된다 — "
|
|
270
|
+
"그러나 추출기 보정(합성 픽스처)은 어디서나 같다.")
|
|
271
|
+
ap.add_argument("--show", default="MENTIONS-ONLY,B-MISSING,A-MISSING")
|
|
272
|
+
a = ap.parse_args()
|
|
273
|
+
root = os.path.abspath(a.root)
|
|
274
|
+
cal_root = os.path.abspath(a.home) if a.home else root
|
|
275
|
+
if a.selftest:
|
|
276
|
+
return selftest(cal_root)
|
|
277
|
+
# 보정 없이 숫자를 못 낸다 — 보정 실패면 스캔 안 한다
|
|
278
|
+
if selftest(cal_root) != 0:
|
|
279
|
+
print("\n🟥 보정 실패 → 스캔하지 않는다.")
|
|
280
|
+
return 2
|
|
281
|
+
print()
|
|
282
|
+
r = scan(root)
|
|
283
|
+
if r["claims"] == 0:
|
|
284
|
+
print(f"NO-CLAIMS — 문서 {r['docs']}건에서 «두 실행아티팩트가 한 줄에» 나오는 주장이 0건이다.")
|
|
285
|
+
print("이것은 «결함 0» 이 아니라 «이 계기가 잴 표면이 없음» 이다.")
|
|
286
|
+
return 3
|
|
287
|
+
print(f"문서 {r['docs']}건 · 주장후보 {r['claims']}건")
|
|
288
|
+
for k in sorted(r["counts"], key=lambda x: -r["counts"][x]):
|
|
289
|
+
print(f" {r['counts'][k]:>5} {k}")
|
|
290
|
+
show = set(a.show.split(","))
|
|
291
|
+
flagged = [x for x in r["results"] if x["verdict"] in show]
|
|
292
|
+
print(f"\n── 확인 대상 {len(flagged)}건 (리뷰 표면이지 판정이 아니다) ──")
|
|
293
|
+
for x in flagged[:40]:
|
|
294
|
+
print(f" {x['doc']}:{x['line']} [{x['verdict']}] {x['a']} → {x['b']}")
|
|
295
|
+
if x["ev"]: print(f" A쪽 {x['ev']}")
|
|
296
|
+
if len(flagged) > 40:
|
|
297
|
+
print(f" … 외 {len(flagged)-40}건")
|
|
298
|
+
if a.json:
|
|
299
|
+
print(json.dumps(r, ensure_ascii=False, indent=1))
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
sys.exit(main())
|