leerness 1.36.81 → 1.36.82

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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.82 — 2026-07-28 — 공허한 가드 클래스 스윕: 날조된 완료 주장이 통과하던 fail-open · 안내만 되고 없던 플래그 · 자기참조 소스가드
4
+
5
+ 1.36.81 의 교훈("셋업이 조용히 실패해도 부정 단언은 통과한다")을 **클래스로 확장**해, "통과하지만 아무것도 증명하지 않는 가드"를 전수 스윕했다(5개 렌즈 · 변이 테스트로 실행 확정). 보고 8건 중 직접 재현한 것만 반영했고, **수치가 과장된 보고는 정정**했다(자기참조 가드: 보고 42건 → 실측 12건. 내 1차 탐지기도 `lib/` 를 빼먹어 39건으로 과대보고했다가 교정).
6
+
7
+ - **High — 플래그십 게이트가 가장 위험한 케이스에서 반대로 작동했다.** `verify-claim` 은 evidence 에 파일 경로가 없으면 `fileChecks.every([])`=true(공허참) · `stubFiles`=[] 이라 구현실체 true · git 교차검증은 `files.length>0` 을 요구해 skip — **모든 판정이 공허참**이 되어 "코드도 테스트도 없이 done" 이 `ok:true`/exit 0 이었다. **단조성이 뒤집혀 있었다**: 파일명을 적어 검증 가능하게 쓸수록 FAIL, 아무것도 안 적고 날조할수록 PASS. 코드 주석은 "공허참은 evidenceQualityOk 가 차단한다"고 적혀 있었지만 기본 모드의 그 값은 `hasFile||hasTest||hasLog` 라 `"tests: 32/32 passed"` 문자열 하나로 통과했다 — **주석의 전제가 거짓**이었다. 새 사유 `unverifiable-claim` 을 세 경로(collect/`--json`/human)가 공유하는 한 곳에 배선. 탈출구는 두 가지뿐(evidence 에 수정 파일 경로를 적거나 `--run-tests` 로 실행 결과를 붙이거나), 기존 `--lenient` opt-out 은 그대로.
8
+ - **한계(과장하지 않음)**: 이 수정은 "대조된 것이 하나도 없는" 구멍을 닫을 뿐, 존재하는 아무 파일명(예: `README.md`)을 적으면 통과한다. verify-claim 은 증거 대조이지 구현 증명이 아니다.
9
+ - **추출기 사각지대 동반 수정**: "대조 0건 = FAIL" 로 바꾸면 추출기가 못 보는 파일이 곧 정직한 주장의 거짓 차단이 된다. 확장자 없는 표준 파일명(`Dockerfile`/`Makefile`/`LICENSE` 등)을 추출 대상에 추가하고, `_evidenceQuality` 의 "파일 언급" 판정도 같이 맞췄다(주석이 "bin FILE_RE 와 정합"을 명시하는데 한쪽만 고치면 같은 명령 안에서 정의가 둘로 갈린다).
10
+ - **High — 툴이 안내하는 플래그가 구현되지 않았다.** `referee verify` 성공 시 leerness 는 "이제 `gate --require-referee <id>` 가 이 검증기를 신뢰합니다"라고 출력하는데, `gate` 는 그 플래그를 **읽지도 않았다**(패키지 전체에서 그 문자열은 안내문 한 줄뿐). 사용자는 안내받은 명령을 CI 에 넣고 검증기가 머지를 막는다고 믿지만 아무 일도 일어나지 않았고, 부재/무효 검증기에서도 exit 0 이라 오배선을 알 신호조차 없었다 — 1.36.80 에서 내가 만든 약속이 곧 거짓말이었다. 이제 실제로 게이팅한다: 미존재/미증명/무효 검증기는 실행 전 거부, 검증기가 현재 상태를 거부하면 게이트 실패, 반복 지정(`--require-referee a --require-referee b`) 지원, 플래그가 없으면 기존 5체크 그대로.
11
+ - **High — 자기참조 소스가드**: `read(__filename)` 로 자기 파일을 읽는 selftest 가, 검사하려던 코드 리터럴이 리팩터로 사라지면 그 문자열이 **가드 자신의 줄에만** 남아 영원히 통과한다(기능을 통째로 지워도 초록). 실제로 **verify-claim 의 `--json` exit 게이트**와 **개인키 파일 스캔 강제포함**이 그 상태였다. 고가치 4건을 현재 구현 기준으로 교체하고, **확산 금지 메타가드**를 추가했다 — 제품 코드(bin 나머지 + `lib/` + `scripts/`)에 없는 **코드형** 리터럴을 붙잡은 케이스가 늘어나면 실패한다. 잔여 부채 9건은 숨기지 않고 baseline 으로 명시했다(줄이는 방향으로만 갱신).
12
+ - **문서 수치 메타가드**: `docs/interoperability.md` 가 "86 도구"(3릴리스 낡음)를, `README.ko.md` 가 "selftest 210 케이스"(실제 336)를 **현재 상태**로 주장한 채 npm 에 실려 나갔다. `README.md` 는 `readmeSync` 관리구간이라 동적으로 맞았지만 관리구간 밖 문서는 아무도 맞춰주지 않는다. 낡을 수치를 살아있는 출처 안내로 대체하고, README/docs 의 MCP 도구·selftest 수가 실측과 어긋나면 selftest 가 실패하도록 했다(과거 라운드 로그인 AGENTS/CLAUDE/CHANGELOG 는 과거 수치가 정당하므로 제외 — 포함하면 false-BLOCK).
13
+ - **가드를 만들며 두 번 틀렸고 둘 다 실행으로 잡았다**: (1) 사이트 수치 가드 1차판이 5/5 오탐(주석·파생표현·다른 개념의 "0 의존성")이라 대상을 실제로 낡은 이력이 있는 수치로 좁혔다. (2) 그 가드가 하드코딩 재삽입을 **차단하지 못했다** — JS 의 `\b` 는 ASCII 기준이라 `(?:도구|tools?)\b` 가 한글 뒤에서 성립하지 않았다. 차별적 통제를 돌리지 않았다면 아무것도 지키지 않는 가드를 출하할 뻔했다. 같은 버그로 내 수동 grep 도 낡은 주장 2건 중 1건만 보고 있었다.
14
+ **검수 24회전이 BLOCK 판정 + High 6건** — 전건 직접 재현 후 수정. 이번 라운드는 **내 자체 검증이 여섯 개를 다 놓쳤다**:
15
+
16
+ - **내가 고치려던 역전을 다시 만들었다.** `_runProven` 을 `runResult.parsed` 로 판정한 탓에, `--test-cmd 'node -e "process.exit(0)"'` 처럼 **실제로 실행해 exit 0 을 받아도** 출력이 파싱 불가면 차단되고, evidence 에 `"1/1 passed"` 문자열만 적은 날조는 통과했다. 커스텀 리포터·`go test` 를 쓰는 정직한 실행이 벌을 받는 구조 — 진단 신호로 삼았던 단조성 역전 그 자체다. 판정 기준을 **실행 확증**(`allPassed`)으로 교체.
17
+ - **추출기 사각지대 4형태가 곧 거짓 차단이었다**: `ops/containers/Dockerfile`(다단계 경로의 bare 파일) · `.github/workflows/ci.yml`(선두 점이 잘려 `github/…` 라는 없는 파일로 판정) · `.gitignore` · `Dockerfile.dev`(내가 추가한 bare 규칙이 `Dockerfile` 로 잘라 "없는 파일"로 만듦). bin 추출기와 `_evidenceQuality` 로 갈려 있던 판정을 **단일 출처**(`lib/analyzers._EVIDENCE_FILE_RE`)로 통합.
18
+ - **툴이 안내하는 문법 그대로 쓰면 깨졌다.** `--require-referee` 를 value-flag 목록에 등록하지 않아 값이 positional 경로로 흡수돼 root 가 `<cwd>/<id>` 가 됐다(소스 주석이 "신규 value-flag 는 반드시 여기 등록" 이라고 명시하는데 어겼다). **내 e2e 는 항상 `--path` 와 `.` 을 줘서 이 결함을 가리고 있었다** — 픽스처가 버그를 숨기는 전형이라, 이제 안내된 문법 그대로(cwd + 플래그만) 검증한다. 1.36.80/81 의 다른 신규 value-flag 도 소급 등록.
19
+ - **값 없는 `--require-referee` 가 조용히 사라졌다** — CI 의 `--require-referee=$EMPTY_ENV` 가 아무 게이팅 없이 exit 0(fail-open). 명시적 실패 단계(`referee_missing_value`)로 전환.
20
+ - **메타가드 자신이 반쯤 공허했다**: 케이스 파싱이 337 중 170 만 잡았고, 자기 제외 표식을 `'selfref' + '-guard-own'` 로 조립해 **소스에 결합된 문자열이 없어 한 번도 동작하지 않았다**(자기를 세지 않은 건 우연). 백틱 리터럴로 우회도 가능했다. 시작 경계 분할로 전수 파싱 + 백틱 포함 + 표식 교정, baseline 은 과소집계였던 9 → **실측 12** 로 정정.
21
+ - **"탐지력 증명됨" 은 실제보다 강한 주장이었다** — 캘리브레이션이 확인하는 것은 등록된 known-good/known-bad 쌍이 기대 사유로 갈린다는 것과 `check` 의 기동 가능성뿐이고, good/bad 가 `check` 와 같은 절차인지는 **도구가 강제할 수 없다**. 문구를 사실에 맞추고 그 조건을 명시.
22
+ - Medium 동반 수정: gate 의 referee 실패가 `--json` 에서 `{name, ok:false}` 로만 축약되던 것 → `code`/`exit`/`timedOut`/`spawnError`/`signal` 노출 · 캘리브레이션 timeout 정책을 gate 와 공유(느린 검증기 거짓 차단 방지) · 같은 id 반복은 순서 보존 dedupe · 문서 수치 가드의 어순 양방향·core 문맥·`80+` 하한 표현·`CONTRIBUTING/SECURITY` 스캔 보강.
23
+
24
+ - 검증: selftest 337 · e2e **406/406**(신규 2블록) · 신규 단언의 **변이 테스트**(수정을 되돌리면 정확히 실패) · 메타가드 4방향(따옴표/백틱 자기참조 차단 · 정상 가드 무오탐) · false-BLOCK 시나리오(문서만 수정 / 추출기 사각지대 5형태 / 비-done / `--run-tests` 실행확증 / `--lenient`) 실측.
25
+
3
26
  ## 1.36.81 — 2026-07-28 — 게시본 클린룸 실측이 잡은 2건 (referee 상태 신호 정확화 · 출하 소스 생 NUL 제거)
4
27
 
5
28
  1.36.80 을 게시한 뒤 **게시된 tarball 로** 신규 기능을 실행해 본 결과 2건이 나왔다. 둘 다 게이트(selftest 334 · e2e 404)는 초록이었다 — 단언이 없던 자리다.
package/README.ko.md CHANGED
@@ -154,7 +154,7 @@ leerness mcp serve # JSON-RPC over stdio, 80+ 도구
154
154
  - **원자적 UTF-8 쓰기** — temp + rename 으로 부분쓰기 손상 방지, BOM 자동 strip.
155
155
  - **shell 미경유 MCP** — `mcp serve` 의 도구 호출은 셸을 거치지 않고 인자를 직접 전달(명령 주입 차단).
156
156
  - **순수 `--json` / 일관 exit code** — 성공·실패·미존재 경로 모두 파싱 가능한 JSON(`{ok,error,code}`) + 실패 시 exit 1. 자동화·CI 친화.
157
- - **모듈 분리(DI)** + **내장 자가검증** — `lib/` 순수 유틸/IO/카탈로그 분리, `selftest` 210 케이스로 설치 무결성 검증(`doctor` 가 함께 실행). 문서(README) 부재에도 코어 무결성 진단은 통과.
157
+ - **모듈 분리(DI)** + **내장 자가검증** — `lib/` 순수 유틸/IO/카탈로그 분리, `selftest` 코어 함수 무결성을 자가 검증(`doctor` 가 함께 실행하며 케이스 수를 실측 출력). 문서(README) 부재에도 코어 무결성 진단은 통과.
158
158
 
159
159
  ---
160
160
 
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.81 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.82 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.81는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.82는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.81는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.81 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.82 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.81: 2026-07-28
238
+ Last synced by Leerness v1.36.82: 2026-07-28
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.81';
37
+ const VERSION = '1.36.82';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -208,6 +208,26 @@ function arg(name, def = null) {
208
208
  return eq ? eq.slice(name.length + 1) : def;
209
209
  }
210
210
  function has(name) { return process.argv.includes(name); }
211
+ // 1.36.82: 반복 지정 값 플래그를 `--flag=v` 형태까지 포함해 수집하고, **값 누락도 보고**한다.
212
+ // (검수 High#4) 값 없는 `--require-referee` 나 `--require-referee=` 를 조용히 버리면
213
+ // CI 의 `--require-referee=$EMPTY_ENV` 가 아무 게이팅 없이 exit 0 — fail-open 이다.
214
+ // 기존 argAll() 은 값만 돌려주므로 누락을 알 수 없어, 누락 개수를 함께 반환하는 변형을 쓴다.
215
+ function _argAllStrict(name) {
216
+ const values = [];
217
+ let missing = 0;
218
+ const a = process.argv;
219
+ for (let i = 0; i < a.length; i++) {
220
+ if (a[i] === name) {
221
+ const next = a[i + 1];
222
+ if (next != null && !next.startsWith('-')) { values.push(next); i++; }
223
+ else missing++;
224
+ } else if (a[i].startsWith(name + '=')) {
225
+ const v = a[i].slice(name.length + 1);
226
+ if (v) values.push(v); else missing++;
227
+ }
228
+ }
229
+ return { values, missing };
230
+ }
211
231
  // 공통 root 해석 (외부리뷰 CV-1/UR-0076): --path(=값 포함) 플래그 > 유효 positional > cwd.
212
232
  // 여러 dispatcher 가 positional 또는 --path 한쪽만 처리하던 불일치(특히 session close 의 wrong-root 쓰기) 해소.
213
233
  function _resolveRoot(positional) {
@@ -218,7 +238,7 @@ function _resolveRoot(positional) {
218
238
  }
219
239
  function nonFlagArgs() {
220
240
  const out = [];
221
- const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--to','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단. 1.17.2 (UR-0045): --test-cmd 동일 원칙(신규 value-flag 는 반드시 여기 등록)
241
+ const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--to','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd','--require-referee','--referee','--expect-bad','--good','--bad','--static-dir','--dev-url']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단. 1.17.2 (UR-0045): --test-cmd 동일 원칙(신규 value-flag 는 반드시 여기 등록). 1.36.82 (검수 High#3): --require-referee 미등록으로 `gate --require-referee <id>` 의 값이 positional 경로로 흡수돼 root 가 <cwd>/<id> 가 됐다 — 툴이 안내하는 문법 그대로 쓰면 깨졌다. 1.36.80/81 신규 value-flag 도 소급 등록.
222
242
  const a = process.argv.slice(2);
223
243
  for (let i = 0; i < a.length; i++) {
224
244
  const x = a[i];
@@ -4442,8 +4462,15 @@ function _selfTestCases() {
4442
4462
  // 1) done 기본 claimsChecked(--strict-claims 옵트인 아님) 2) --json 에 claimsConsistent+gitCrossCheck 3) --lenient 탈출 4) optimism 전체스캔(walk root) 5) git strongMismatch 는 advisory(기본 게이트 제외)
4443
4463
  const defaultGate = src.includes("const claimsChecked = !lenient && (isDoneClaim || has('--require-evidence') || has('--strict-claims'));");
4444
4464
  const jsonExposed = src.includes('claimsConsistent: !claimsChecked ? null : strictOk') && src.includes('gitCrossCheck: !gitApplicable ? null : !gitStrongMismatch');
4445
- const jsonGate = src.includes('(claimsChecked && !strictOk) || !gitClaimOk) return process.exit(1);');
4446
- const overall = src.includes('(claimsChecked && !strictOk) || !evidenceQualityOk || !gitClaimOk;');
4465
+ // 1.36.82 (공허한 가드 스윕 High): 리터럴은 리팩터로 사라진 옛 표현이라 **자기 줄만 매칭**하고 있었다 —
4466
+ // verify-claim --json exit 게이트가 어떤 selftest 로도 보호되지 않았다(게이트를 통째로 지워도 초록).
4467
+ // 현재 구현(reasons 기반 단일 판정 + exit 전파)을 검사한다.
4468
+ const jsonGate = src.includes("if (claimsChecked && !strictOk) out.reasons.push('optimism/honesty')")
4469
+ && src.includes('out.ok = out.reasons.length === 0;') && src.includes('if (!out.ok) return process.exit(1);');
4470
+ // 1.36.82: 끝의 `;` 때문에 실제 overallFail 라인과 어긋나 **자기 줄만 매칭**하고 있었다(리팩터로 뒤에 항이 추가됨).
4471
+ // 현재 라인과 일치시키고, 1.36.82 에 추가한 unverifiableClaim 게이팅이 human 경로에 배선됐는지도 함께 단언한다.
4472
+ const overall = src.includes('(claimsChecked && !strictOk) || !evidenceQualityOk || !gitClaimOk')
4473
+ && /const overallFail = unverifiableClaim \|\|/.test(src);
4447
4474
  const wholeScan = src.includes('function _scanCodeForPatterns(root)') && src.includes('walk(root, 0);');
4448
4475
  const gitAdvisory = src.includes("const gitClaimOk = !(has('--strict-claims') && gitStrongMismatch);");
4449
4476
  return defaultGate && jsonExposed && jsonGate && overall && wholeScan && gitAdvisory;
@@ -4463,7 +4490,12 @@ function _selfTestCases() {
4463
4490
  const src = read(__filename);
4464
4491
  const coreDef = src.match(/function _verifyClaimsAll\(root\) \{[\s\S]*?\n\}/); // 함수 본문만 캡처(첫 줄머리 } 까지)
4465
4492
  const core = !!coreDef && coreDef[0].includes('return { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };') && !/process\.exit\(/.test(coreDef[0]); // 코어는 절대 process.exit( 안 함(게이트 step 집계 보호)
4466
- const gateOptIn = src.includes("const withClaims = has('--claims');") && src.includes('# leerness gate (${withClaims ? 6 : 5} checks)') && src.includes("if (withClaims) step('verify-claims', () => { const r = _verifyClaimsAll(root);");
4493
+ // 1.36.82: 헤더 문자열을 **정확 리터럴**로 잡으면 줄을 만질 때마다 자기 줄만 매칭해 조용히 통과한다
4494
+ // (이번 라운드에만 두 번 그렇게 됐다). 표현이 아니라 **불변식**을 본다:
4495
+ // 헤더가 withClaims 로 5/6 을 계산하고, --claims 일 때만 verify-claims 단계가 붙는다.
4496
+ const gateOptIn = src.includes("const withClaims = has('--claims');")
4497
+ && /# leerness gate \(\$\{[^}]*withClaims \? 6 : 5[^}]*\} checks\)/.test(src)
4498
+ && src.includes("if (withClaims) step('verify-claims', () => { const r = _verifyClaimsAll(root);");
4467
4499
  const reuse = src.includes('const res = _verifyClaimsAll(root);'); // CLI 도 코어 공유(분기 없음)
4468
4500
  const tools = require('../lib/mcp-tools');
4469
4501
  const def = tools.find(t => t.name === 'leerness_verify_claim_all');
@@ -4772,9 +4804,13 @@ function _selfTestCases() {
4772
4804
  && src.includes("else if (surface === 'lessons') _saveLessons(root, _parseLessonEntries(read(activePath)))");
4773
4805
  // P2-4: plan remove 가 모든 헤딩(#{1,3} )을 경계로 split (사용자 h2 섹션 흡수 방지)
4774
4806
  const planOk = src.includes('const blocks = text.split(/\\n(?=#{1,3} )/);');
4775
- // P2-5: FILE_RE + _evidenceQuality basename 글자에 숫자 허용 (저-백슬래시 판별 조각)
4776
- const fileReOk = src.includes(')?[A-Za-z0-9][');
4777
- const analyzersOk = read(path.join(__dirname, '..', 'lib', 'analyzers.js')).includes('[A-Za-z0-9][\\w./');
4807
+ // P2-5: 숫자로 시작하는 실파일('123.js'·'2fa.ts')이 추출돼야 한다 되면 존재검사가 공허통과했다.
4808
+ // 1.36.82: 정규식 **조각 문자열**을 검사하던 것을 **행위 검사**로 교체 — 추출기를 단일 출처(lib/analyzers)
4809
+ // 옮기자 조각이 사라져 가드가 자기 줄만 매칭할 뻔했다(정확 리터럴 가드의 전형적 부패).
4810
+ const _re = require('../lib/analyzers')._EVIDENCE_FILE_RE('g');
4811
+ const _hit = (s) => Array.from(String(s).matchAll(_re), m => m[0]);
4812
+ const fileReOk = _hit('123.js 수정').includes('123.js') && _hit('2fa.ts 추가').includes('2fa.ts');
4813
+ const analyzersOk = require('../lib/analyzers')._evidenceQuality('123.js 수정, tests: 1/1 passed').hasFile === true;
4778
4814
  // P2-3: archive 쓰기에 _lineSafe (개행 델리미터 위조 차단)
4779
4815
  const archiveOk = src.includes('`\\n## 제거 ${today()} (target: "${_lineSafe(target)}")\\n${archiveBlocks}\\n`');
4780
4816
  // P2-7: drop/remove not-found 가 failJson (--json 에러 구조화)
@@ -4934,6 +4970,85 @@ function _selfTestCases() {
4934
4970
  while ((m = re.exec(self)) !== null) { if (m[1].includes(NL)) offenders++; }
4935
4971
  return offenders === 0;
4936
4972
  } },
4973
+ { name: '자기참조 소스가드 확산 금지 (1.36.82, 공허한 가드 스윕): 제품에 없는 코드 리터럴을 붙잡은 selftest 가 늘어나면 실패 (메타가드)', run: () => {
4974
+ // 사고: 소스가드가 `read(__filename)` 로 자기 파일을 읽는데, 검사하려는 코드 리터럴이 리팩터로 사라지면
4975
+ // 그 문자열은 **가드 자신의 줄에만** 남는다 → includes 는 계속 참 → 기능을 통째로 지워도 초록.
4976
+ // 실제로 verify-claim 의 --json exit 게이트와 개인키 파일 스캔 강제포함 가드가 이 상태였다.
4977
+ // 판정: selftest 영역 밖(제품 코드 = bin 나머지 + lib/ + scripts/)에 존재하지 않는 **코드형** 리터럴.
4978
+ // 데이터 문자열(파일명·placeholder 등)이 제품에 없는 건 정상이므로 코드 신호가 있는 것만 본다(false-BLOCK 회피).
4979
+ const src = read(__filename);
4980
+ // (검수 High#6) 1차판은 `'selfref' + '-guard-own'` 을 MARK 로 써서 **소스 본문에는 결합된 문자열이 없어**
4981
+ // 자기 제외가 한 번도 동작하지 않았다(자기 자신을 안 센 건 우연이었다). 소스에 그대로 존재하는 표식을 쓴다.
4982
+ const MARK = 'SELFREF_METAGUARD_OWN_CASE';
4983
+ const s = src.indexOf('function _selfTestCases(');
4984
+ if (s < 0) return false;
4985
+ const nextFn = src.indexOf('\nfunction ', s + 10);
4986
+ const e = nextFn < 0 ? src.length : nextFn;
4987
+ let outside = src.slice(0, s) + src.slice(e);
4988
+ for (const sub of ['lib', 'scripts']) {
4989
+ const dir = path.join(path.dirname(__filename), '..', sub);
4990
+ if (!exists(dir)) continue;
4991
+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.js')) outside += '\n' + read(path.join(dir, f));
4992
+ }
4993
+ const region = src.slice(s, e);
4994
+ // (검수 High#6) 1차판의 종결자 기반 정규식(`\n } },`)은 337건 중 170건만 매치했다 —
4995
+ // 여러 케이스를 하나로 뭉치거나(마지막 무쉼표 케이스 등) 통째로 놓쳐 가드 자신이 반쯤 공허했다.
4996
+ // 케이스 **시작 경계**로 분할한다(줄머리 ` { name:`) — 종결 형태에 의존하지 않는다.
4997
+ const chunks = region.split(/\n(?= \{ name: )/).slice(1);
4998
+ // 백틱 리터럴도 스캔한다(1차판은 따옴표만 봐서 `.includes(\`…\`)` 로 우회 가능했다).
4999
+ const litRe = /\.includes\((['"`])((?:\\.|(?!\1)[\s\S])*?)\1\)/g;
5000
+ const codeish = (L) => /[(){}=;]|=>|\|\||&&/.test(L) && /[a-zA-Z_$]{3,}/.test(L);
5001
+ let offenders = 0;
5002
+ for (const body of chunks) {
5003
+ if (body.includes(MARK)) continue; // 이 메타가드 자신 (표식은 소스에 그대로 존재)
5004
+ if (!/read\(__filename\)/.test(body)) continue;
5005
+ litRe.lastIndex = 0;
5006
+ let lm;
5007
+ while ((lm = litRe.exec(body)) !== null) {
5008
+ const L = lm[2].replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\');
5009
+ if (L.length >= 20 && codeish(L) && !outside.includes(L)) { offenders++; break; }
5010
+ }
5011
+ }
5012
+ // 1.36.82 실측 잔여 부채 = 12건(파싱 교정 후 전수 337 케이스 기준) — 늘어나면 실패한다.
5013
+ // 줄이는 방향으로만 갱신할 것. "아직 남아 있음"을 숨기지 않기 위해 수치를 명시한다.
5014
+ // 1차판이 보고하던 9 는 케이스 파싱이 337 중 170 만 잡던 과소집계였다(검수 High#6).
5015
+ return offenders <= 12;
5016
+ } },
5017
+ { name: '동봉 문서의 낡을 수치 주장 금지 (1.36.82): README/docs 의 MCP 도구·selftest 케이스 수가 실측과 불일치 시 실패 (메타가드)', run: () => {
5018
+ // 사고: docs/interoperability.md 가 "86 도구"(3릴리스 낡음), README.ko.md 가 "selftest 210 케이스"(실제 335) 를
5019
+ // **현재 상태**로 주장한 채 npm 에 실려 나갔다. README.md 는 readmeSync 관리구간이라 동적으로 맞았지만
5020
+ // 관리구간 밖 문서는 아무도 맞춰주지 않는다 — leerness 자신이 "측정 안 된 수치 주장 금지"를 강제하는 도구인데 자기 문서가 어겼다.
5021
+ // 범위(정직하게 좁힘): 현재 상태를 서술하는 문서만. AGENTS.md/CLAUDE.md/CHANGELOG.md 는 **버전별 과거 라운드 로그**라
5022
+ // 과거 수치가 정당하므로 제외한다(포함하면 false-BLOCK 이 된다).
5023
+ const root = path.join(path.dirname(__filename), '..');
5024
+ // (검수 Medium) npm tarball 에 실리는 루트 문서를 빠짐없이 — CONTRIBUTING/SECURITY 도 대상이다.
5025
+ const targets = ['README.md', 'README.ko.md', 'CONTRIBUTING.md', 'SECURITY.md'].map(f => path.join(root, f));
5026
+ const docsDir = path.join(root, 'docs');
5027
+ if (exists(docsDir)) for (const f of fs.readdirSync(docsDir)) if (f.endsWith('.md')) targets.push(path.join(docsDir, f));
5028
+ const mcpN = _mcpToolCount();
5029
+ const selfN = _selfTestCases().length;
5030
+ let coreN = 0; try { coreN = (require('../lib/mcp-tools').CORE || []).length; } catch {}
5031
+ for (const f of targets) {
5032
+ if (!exists(f)) continue;
5033
+ for (const raw of read(f).split('\n')) {
5034
+ // 버전 표기는 카운트 주장이 아니다 — 지우고 본다(1.36.81 을 "36 도구"로 오독하는 것 방지)
5035
+ const line = raw.replace(/\bv?\d+\.\d+(?:\.\d+)?\b/g, ' ');
5036
+ // (검수 Medium) core 프로필 문맥의 수치는 full 과 비교하면 거짓 차단이다 — 그 줄은 core 기준으로 본다.
5037
+ const coreCtx = /\bcore\b|코어|프로필/i.test(line);
5038
+ const expectMcp = coreCtx ? coreN : mcpN;
5039
+ // "80+" 같은 하한 표현은 정확 수치 주장이 아니다 — 대상에서 제외(false-BLOCK 회피).
5040
+ if (/\d\s*\+/.test(line)) continue;
5041
+ // (검수 Medium FN) 어순 양방향: "89 도구" 와 "MCP tools: 89" 둘 다 잡는다.
5042
+ // 주의: JS \b 는 ASCII 기준이라 한글 뒤에서 성립하지 않는다 → 경계는 영문 tools 에만 붙인다.
5043
+ let m = /\b(\d{2,3})\s*(?:개\s*)?(?:MCP\s*)?(?:도구|tools?\b)/i.exec(line)
5044
+ || /(?:MCP[- ]?tools?|MCP\s*도구)\s*[:=]?\s*(\d{2,3})\b/i.exec(line);
5045
+ if (m && expectMcp && Number(m[1]) !== expectMcp) return false;
5046
+ m = /selftest[^\n]{0,16}?\b(\d{2,4})\b/i.exec(line) || /\b(\d{2,4})\s*selftest/i.exec(line);
5047
+ if (m && Number(m[1]) !== selfN) return false;
5048
+ }
5049
+ }
5050
+ return true;
5051
+ } },
4937
5052
  { name: '출하 소스 생 NUL 금지 (1.36.81, 클린룸 실측): bin/lib 전수 0 — 구분자를 이스케이프로 (메타가드)', run: () => {
4938
5053
  // 1.36.80 사고: 지문/ID 구분자를 소스에 **생 NUL 바이트**로 써서 도구가 파일을 바이너리로 취급했다(grep 차단).
4939
5054
  // 정규화 도구가 조용히 지우면 해시 입력이 바뀌어 전 프로젝트의 캘리브레이션/ID 가 한꺼번에 어긋난다.
@@ -5043,7 +5158,10 @@ function _selfTestCases() {
5043
5158
  const bin = read(__filename);
5044
5159
  const cat = read(path.join(path.dirname(__filename), '..', 'lib', 'catalogs.js'));
5045
5160
  const pu = read(path.join(path.dirname(__filename), '..', 'lib', 'pure-utils.js'));
5046
- const keyFile = bin.includes('const isKeyFile =') && bin.includes('!isKeyFile) continue;');
5161
+ // 1.36.82 (공허한 가드 스윕): `!isKeyFile) continue;` 는 1.36.56 리팩터로 사라진 코드다 —
5162
+ // 그 문자열이 이 가드 자신의 줄에만 남아 **자기참조로 통과**하고 있었다(개인키 스캔을 꺼도 초록).
5163
+ // 현재 구현(_known 강제 포함)을 검사하도록 교체 + 행위로도 확인한다.
5164
+ const keyFile = bin.includes('const isKeyFile =') && bin.includes('|| isEnvFamily || isKeyFile');
5047
5165
  const dbVg = /DB connection string[^\n]*valueGroup: 1/.test(cat);
5048
5166
  const phPlaceholder = pu.includes('root|admin|user|username|yourpassword');
5049
5167
  const retroGuard = bin.includes("failJson(has('--json'), 'invalid_arg'") && bin.includes('Math.min(days, 36500)');
@@ -12984,7 +13102,12 @@ function verifyClaimCmd(root, taskId, opts = {}) {
12984
13102
  // codex 버그헌트 P2: basename 첫 글자를 [A-Za-z] 로만 잡아 '123.js'·'2fa.ts' 같은 숫자시작 실파일이
12985
13103
  // 추출 안 돼 존재검사가 vacuous-pass → 없는 파일 인용한 done 주장이 통과(gate FN). 첫 글자에 숫자 허용.
12986
13104
  // (prev-separator 가드가 UNC/드라이브/URL 꼬리 오탐은 계속 차단하므로 FP 억제 유지)
12987
- const FILE_RE = new RegExp(`(?:[A-Za-z][A-Za-z0-9_-]*[\\/\\\\])?[A-Za-z0-9][\\w./\\\\-]*\\.(?:${FILE_EXTS})\\b`, 'g');
13105
+ // 1.36.82 (검수 High#2): 추출기와 `_evidenceQuality` 의 "파일 언급" 판정이 각자 따로였다(주석만 "정합").
13106
+ // 같은 명령 안에서 정의가 갈리면 정직한 주장이 사유 없이 거부되므로 **단일 출처**(lib/analyzers)를 쓴다.
13107
+ // 해소된 사각지대: 다단계 경로의 bare 파일(ops/containers/Dockerfile) · 선두 점 디렉토리(.github/workflows/ci.yml
13108
+ // 가 github/… 로 잘리던 것) · 점파일(.gitignore) · Dockerfile.dev 가 Dockerfile 로 잘려 "없는 파일"이 되던 것.
13109
+ void FILE_EXTS; // (구 상수 — 위 단일 출처로 대체. 주변 주석의 이력 보존용)
13110
+ const FILE_RE = require('../lib/analyzers')._EVIDENCE_FILE_RE('g');
12988
13111
  // 1.36.11 (클린룸 P1-B): 매치 직전 문자가 경로 구분자면 더 긴 경로의 "꼬리"만 잡은 것(UNC \\\\server\\..., 드라이브 X:\\...,
12989
13112
  // 숫자 시작 선두 세그먼트 등) — 잘린 꼬리를 루트-상대 경로로 검증하면 무관한 로컬 파일이 통과(우회성). 그런 매치는 버린다.
12990
13113
  const filePatterns = [];
@@ -13148,14 +13271,32 @@ function verifyClaimCmd(root, taskId, opts = {}) {
13148
13271
  // 1.9.309: 기본 임계는 비례적 — 근거 일부(파일·테스트·로그 중 하나)만 있으면 통과(Opus 의 "증거 0" 만 차단).
13149
13272
  // --require-evidence(명시) 는 엄격(파일+테스트 모두, 1.9.287). 과탐 방지.
13150
13273
  const evidenceQualityOk = !mustHaveEvidence || (has('--require-evidence') ? evq.ok : (evq.hasFile || evq.hasTest || evq.hasLog));
13151
- // 공허참("증거 0 done") evidenceQualityOk 차단하므로 filesAllExist 표준 의미 유지(주장 파일들이 실제 존재하는가).
13274
+ // filesAllExist "주장 파일들이 실제 존재하는가" 주장 파일이 0건이면 공허참( 배열의 every)이다.
13152
13275
  const filesAllExist = fileChecks.every(c => c.exists);
13276
+ // 1.36.82 (공허한 가드 스윕 High #1, 실측 재현): **아무것도 대조하지 못한 주장을 통과로 보고하지 않는다.**
13277
+ // 이전 주석은 "공허참은 evidenceQualityOk 가 차단한다"고 적혀 있었으나 전제가 거짓이었다 —
13278
+ // 기본 모드의 evidenceQualityOk 는 hasFile||hasTest||hasLog 라 evidence 에 "tests: 32/32 passed" 문자열
13279
+ // 하나만 있어도 통과한다. 그 결과 파일 0건·테스트 0개 프로젝트에서 done 을 선언하면
13280
+ // filesAllExist(빈배열)=true · implementationSubstance(검사대상 0)=true · gitApplicable(files>0 요구)=false 로
13281
+ // **전 판정이 공허참**이 되어 ok:true/exit 0 이 나왔다.
13282
+ // 단조성이 뒤집혀 있었다: 파일명을 적어 검증 가능하게 쓸수록 FAIL, 아무것도 안 적고 날조할수록 PASS.
13283
+ // 탈출구는 두 가지뿐 — evidence 에 수정 파일 경로를 적거나, --run-tests 로 실제 실행 결과를 붙인다.
13284
+ // 기존 opt-out(--lenient)이 그대로 적용되므로 의도된 예외는 사용자가 되돌릴 수 있다.
13285
+ // (같은 저장소의 `contract verify` 가 이미 빈 계약을 empty_contract 로 거부하는 것과 대칭.)
13286
+ // 계산은 claimsChecked 선언 이후에 둔다(아래) — 여기서 참조하면 TDZ 다.
13153
13287
 
13154
13288
  // 1.11.2 (14th 버그헌트 P1-CRITICAL, UR-0175): 허위완료 차단을 "기본값"에서 작동 — done 주장은 optimism(evidence 주장↔코드 호출 흔적) + git 교차검증을 기본 게이팅.
13155
13289
  // 이전엔 --strict-claims 옵트인일 때만 잡아 기본 모드(verify-claim --run-tests)가 허위완료를 통과(exit 0)시켰고, --json 은 git 신호조차 누락(머신 경로가 더 위험)했음.
13156
13290
  // 범위: done/완료 주장(또는 --require-evidence/--strict-claims). opt-out: --lenient. --json/human 양 경로가 동일 계산을 공유.
13157
13291
  const lenient = has('--lenient');
13158
13292
  const claimsChecked = !lenient && (isDoneClaim || has('--require-evidence') || has('--strict-claims'));
13293
+ // 1.36.82 (High #1): "아무것도 대조하지 못함" — 위 주석 참조. 세 경로(collect/--json/human)가 이 하나를 공유한다.
13294
+ // (검수 High#2-d, 실측 재현) 처음엔 `runResult.parsed` 를 요구했는데 그건 **같은 역전을 다시 만든 것**이었다:
13295
+ // `--test-cmd 'node -e "process.exit(0)"'` 처럼 실제로 실행해 exit 0 을 받았어도 출력이 파싱 불가면 차단되고,
13296
+ // 반대로 evidence 에 "1/1 passed" 문자열만 적은 날조는 통과했다. 커스텀 리포터·go test 등 정직한 실행이 벌을 받는다.
13297
+ // 판정 기준은 **실행 확증**이어야 한다 — 실제로 돌았고 실패하지 않았는가(allPassed = exit 0 이며 파싱됐다면 전부 통과).
13298
+ const _runProven = !!(runResult && !runResult.skipped && runResult.allPassed);
13299
+ const unverifiableClaim = claimsChecked && files.length === 0 && !_runProven;
13159
13300
  let optimismSuspects = [], honestyFindings = [], strictOk = true;
13160
13301
  if (claimsChecked) {
13161
13302
  const codeText = _scanCodeForPatterns(root);
@@ -13183,6 +13324,7 @@ function verifyClaimCmd(root, taskId, opts = {}) {
13183
13324
  const _tcMatch = declaredTestCount == null ? null : (!testMeasured ? null : actualTestCount >= declaredTestCount);
13184
13325
  const _runFail = !!(runResult && !runResult.skipped && !runResult.allPassed);
13185
13326
  const reasons = [];
13327
+ if (unverifiableClaim) reasons.push('unverifiable-claim'); // 1.36.82 (High #1): 대조된 것이 하나도 없음
13186
13328
  if (!filesAllExist) reasons.push('files-missing');
13187
13329
  if (_tcMatch === false) reasons.push('test-count-short');
13188
13330
  if (!evidenceQualityOk) reasons.push('evidence-incomplete');
@@ -13231,6 +13373,7 @@ function verifyClaimCmd(root, taskId, opts = {}) {
13231
13373
  // 1.35.7 (UR-0013 GPT5.5평가 개선②): top-level ok/reasons — CI 가 verdict 필드를 재조합하지 않아도 되도록 collect 경로와 동일 어휘.
13232
13374
  // exit 는 reasons 기반 단일 판정(ok↔exit 불일치 원천 차단) — 기존 게이트(1.11.2/1.17.3/1.17.4) 동일 + declared-pass-mismatch 추가.
13233
13375
  out.reasons = [];
13376
+ if (unverifiableClaim) out.reasons.push('unverifiable-claim'); // 1.36.82 (High #1)
13234
13377
  if (!filesAllExist) out.reasons.push('files-missing');
13235
13378
  if (out.verdict.testCountMatch === false) out.reasons.push('test-count-short');
13236
13379
  if (!evidenceQualityOk) out.reasons.push('evidence-incomplete');
@@ -13328,7 +13471,10 @@ function verifyClaimCmd(root, taskId, opts = {}) {
13328
13471
  }
13329
13472
  // 1.9.309 (UR-0048): done 주장 evidence 완전성 — 기본 강제(상단 pre-compute). --lenient 로 opt-out.
13330
13473
  if (mustHaveEvidence) {
13331
- log(` - ${t('evidence 완전성 (done 기본 강제)', 'evidence completeness (done default)')}: ${evidenceQualityOk ? t('✓ pass (파일+테스트 근거 있음)', '✓ pass (file + test evidence present)') : `✗ FAIL ${t(`(누락: ${evq.missing.join(', ')})`, `(missing: ${evq.missing.join(', ')})`)}`}`);
13474
+ // 1.36.82 (스윕 medium): 기본 모드의 통과 조건은 hasFile||hasTest||hasLog 인데 라벨은 "파일+테스트 근거 있음"이라
13475
+ // hasFile:false 인 같은 실행의 JSON 과 정면 모순하는 **거짓 표기**였다. 실제로 확인된 근거만 나열한다.
13476
+ const _evParts = [evq.hasFile && t('파일 경로', 'file path'), evq.hasTest && t('테스트', 'test'), evq.hasLog && t('실행 로그', 'run log')].filter(Boolean);
13477
+ log(` - ${t('evidence 완전성 (done 기본 강제)', 'evidence completeness (done default)')}: ${evidenceQualityOk ? `✓ pass (${_evParts.join(' + ') || t('근거 없음', 'none')})` : `✗ FAIL ${t(`(누락: ${evq.missing.join(', ')})`, `(missing: ${evq.missing.join(', ')})`)}`}`);
13332
13478
  if (!evidenceQualityOk) log(` · ${t('done 주장은 수정 파일 경로 + 테스트명/개수 가 evidence 에 있어야 함 (테스트 통과만으로는 불충분). 완화: --lenient', 'a done claim needs changed-file paths + test name/count in evidence (passing tests alone is insufficient). relax: --lenient')}`);
13333
13479
  }
13334
13480
  // 1.17.3 (UR-0046): 구현 실체(스텁) + 테스트-구현 연결 — Attack C(주석뿐 구현+assert(true)) 차단.
@@ -13342,7 +13488,12 @@ function verifyClaimCmd(root, taskId, opts = {}) {
13342
13488
  } else if (testLinkOk === true && claimsChecked) {
13343
13489
  log(` - ${t('테스트-구현 연결', 'test-impl link')}: ${t('✓ pass (테스트가 구현을 참조)', '✓ pass (test references the impl)')}`);
13344
13490
  }
13345
- const overallFail = !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk) || (claimsChecked && !strictOk) || !evidenceQualityOk || !gitClaimOk || (claimsChecked && stubFiles.length > 0) || (has('--strict-claims') && testLinkOk === false) || _declaredPassMismatch; // 1.35.7 (UR-0013): declared-pass 부풀림 게이팅
13491
+ // 1.36.82 (High #1): 대조된 것이 하나도 없으면 "일치"가 아니라 **검증 불가**다 통과로 보고하지 않는다.
13492
+ if (unverifiableClaim) {
13493
+ log(` - ${t('검증 가능성', 'verifiability')}: ${t('✗ FAIL — 이 주장에서 실제로 대조된 것이 없습니다(주장 파일 0건 · 실행 확증 없음)', '✗ FAIL — nothing in this claim was actually checked (0 claimed files · no run evidence)')}`);
13494
+ log(` · ${t('evidence 에 수정 파일 경로를 적거나 --run-tests 로 실제 실행 결과를 붙이세요. 의도한 예외면 --lenient.', 'add changed-file paths to evidence, or attach a real run with --run-tests. If intended, use --lenient.')}`);
13495
+ }
13496
+ const overallFail = unverifiableClaim || !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk) || (claimsChecked && !strictOk) || !evidenceQualityOk || !gitClaimOk || (claimsChecked && stubFiles.length > 0) || (has('--strict-claims') && testLinkOk === false) || _declaredPassMismatch; // 1.35.7 (UR-0013): declared-pass 부풀림 게이팅 · 1.36.82 (High #1): unverifiableClaim
13346
13497
  // 1.9.287: 정직한 한계 고지 — 테스트 통과 ≠ 의미적 구현 정확성
13347
13498
  if (claimsChecked || mustHaveEvidence) {
13348
13499
  log('');
@@ -15362,7 +15513,9 @@ function gate(root) {
15362
15513
  // 1.34.1 (16th리뷰 정직화): 기본 5체크(특히 lazy detect)는 워크스페이스-상태(handoff/test-run/evidence 부재) 신호로 거짓완료를 잡지, 콘텐츠(파일/카운트/스텁)는 검사하지 않음.
15363
15514
  // --claims 는 콘텐츠-레벨 검증을 추가 — 워크스페이스가 깨끗한 성숙 프로젝트에선 기본 5체크가 통과(exit 0)해도 --claims 만 콘텐츠 거짓을 잡아(exit 1) README 약속("claims fail → cannot merge")을 문자 그대로 강제. (실증 가드: e2e B(1.34.1))
15364
15515
  const withClaims = has('--claims');
15365
- if (!jsonMode) log(`# leerness gate (${withClaims ? 6 : 5} checks)`);
15516
+ const _ref1 = _argAllStrict('--require-referee'); // 1.36.82 (High #2): 안내만 하고 구현이 없던 플래그 실제 게이팅
15517
+ const _refIds = _ref1.values, _refMissing = _ref1.missing;
15518
+ if (!jsonMode) log(`# leerness gate (${(withClaims ? 6 : 5) + _refIds.length + _refMissing} checks)`);
15366
15519
  function step(label, fn) {
15367
15520
  const code0 = process.exitCode || 0;
15368
15521
  if (!jsonMode) log(`\n## ${label}`);
@@ -15373,9 +15526,13 @@ function gate(root) {
15373
15526
  const failed = threw != null || !!(process.exitCode && process.exitCode !== code0);
15374
15527
  if (threw && !jsonMode) fail(`${label} threw: ${threw.message}`);
15375
15528
  if (failed) bad++;
15376
- checks.push({ name: label, ok: !failed, ...(threw ? { error: threw.message } : {}) });
15529
+ // 1.36.82 (검수 Medium): --json 에서 모든 실패가 {name, ok:false} 로만 축약돼
15530
+ // referee_not_found / referee_stale / timeout / spawn 오류가 구분되지 않았다 — 단계가 남긴 진단을 그대로 싣는다.
15531
+ checks.push({ name: label, ok: !failed, ...(threw ? { error: threw.message } : {}), ..._stepDetail });
15532
+ _stepDetail = {};
15377
15533
  process.exitCode = 0;
15378
15534
  }
15535
+ let _stepDetail = {}; // step 내부에서 채우면 그 단계의 checks[] 항목에 병합된다
15379
15536
  step('verify', () => verify(root));
15380
15537
  step('audit', () => audit(root));
15381
15538
  step('scan secrets', () => scanSecrets(root));
@@ -15383,6 +15540,38 @@ function gate(root) {
15383
15540
  step('lazy detect', () => lazyDetect(root));
15384
15541
  // 1.33.3: opt-in 정밀 per-claim 검증. 코어(_verifyClaimsAll)는 exit 안 하고 결과만 반환 → step 의 exitCode 감지로 실패 집계. 비-json 모드는 불일치 task 를 fail() 로 표면화.
15385
15542
  if (withClaims) step('verify-claims', () => { const r = _verifyClaimsAll(root); if (!r.ok) { process.exitCode = 1; if (!jsonMode) r.results.filter(x => x && !x.ok).forEach(x => fail(`${x.id} 불일치: ${x.reasons.join(', ')}`)); } });
15543
+ // 1.36.82 (공허한 가드 스윕 High #2, 실측 확인): `referee verify` 성공 시 툴이 사용자에게
15544
+ // "이제 gate --require-referee <id> 가 이 검증기를 신뢰합니다" 라고 **안내하는데 gate 는 이 플래그를 읽지도 않았다**.
15545
+ // 사용자는 안내받은 명령을 CI 에 넣고 검증기가 머지를 막는다고 믿지만 아무 일도 일어나지 않았고,
15546
+ // 무효/부재 referee 여도 exit 0 이라 오배선을 알아챌 신호조차 없었다 — 안내가 곧 거짓 약속이었다.
15547
+ // 이제 실제로 게이팅한다: 미검증/무효/부재 검증기는 실행 전에 거부하고, 검증기가 현재 상태를 거부하면 게이트가 실패한다.
15548
+ // (검수 High#4) 값 누락은 조용히 버리지 않는다 — CI 의 빈 환경변수가 게이팅을 무력화하는 fail-open 을 막는다.
15549
+ for (let i = 0; i < _refMissing; i++) {
15550
+ step('referee:(값 없음)', () => {
15551
+ process.exitCode = 1;
15552
+ _stepDetail = { code: 'referee_missing_value' };
15553
+ if (!jsonMode) fail('--require-referee 에 검증기 id 가 없습니다 (예: --require-referee build-check). 값이 비면 게이팅이 사라지므로 실패로 처리합니다.');
15554
+ });
15555
+ }
15556
+ // (검수 Low) 같은 id 반복은 같은 명령을 중복 실행할 뿐 — 순서를 보존해 중복만 제거한다.
15557
+ for (const _rid of Array.from(new Set(_refIds))) {
15558
+ step(`referee:${_rid}`, () => {
15559
+ const g = _ref.requireReferee(root, _rid);
15560
+ if (!g.ok) { process.exitCode = 1; _stepDetail = { code: g.code }; if (!jsonMode) fail(`${g.code}: ${g.message}`); return; }
15561
+ // (검수 Medium) 캘리브레이션과 동일한 시간 정책 — referee verify --timeout 으로 늘린 검증기를
15562
+ // gate 에서만 10분에 끊으면 정직한 검증기가 거짓 차단된다. 저장된 값이 있으면 그것을 쓴다.
15563
+ const _to = Math.max(1000, Number(g.referee.timeoutMs) || 600000);
15564
+ const rr = cp.spawnSync(g.referee.check, { cwd: root, shell: true, encoding: 'utf8', timeout: _to, maxBuffer: 8 * 1024 * 1024 });
15565
+ const code = rr.status == null ? 1 : rr.status;
15566
+ const timedOut = !!(rr.error && /ETIMEDOUT|timed? ?out/i.test(String(rr.error.message || rr.error.code || '')));
15567
+ if (code !== 0) {
15568
+ process.exitCode = 1;
15569
+ const why = rr.error ? ` (${rr.error.code || rr.error.message})` : '';
15570
+ _stepDetail = { code: timedOut ? 'referee_timeout' : 'referee_failed', exit: code, ...(timedOut ? { timedOut: true } : {}), ...(rr.error ? { spawnError: String(rr.error.code || rr.error.message) } : {}), ...(rr.signal ? { signal: rr.signal } : {}) };
15571
+ if (!jsonMode) fail(`referee ${_rid} 가 현재 상태를 거부(exit ${code})${why}: ${g.referee.check}`);
15572
+ }
15573
+ });
15574
+ }
15386
15575
  if (jsonMode) { log(JSON.stringify({ version: VERSION, root, ok: bad === 0, total: checks.length, failed: bad, checks }, null, 2)); if (bad) process.exitCode = 1; return; }
15387
15576
  log(`\n# gate summary: ${bad} 단계 실패`);
15388
15577
  if (bad) process.exitCode = 1;
@@ -7,7 +7,7 @@ leerness 는 **에이전트도 에디터도 아닌, 에이전트 위의 운영
7
7
  | 표면 | 형태 | 용도 |
8
8
  |---|---|---|
9
9
  | **CLI** | 명령줄 | 모든 에이전트/사람의 기본 진입점 |
10
- | **MCP** (`leerness mcp serve`) | stdio JSON-RPC, 86 도구 | 외부 AI 가 자연어→도구 호출로 직접 사용. `adapter <tool>` 이 `.mcp.json` 자동 병합 |
10
+ | **MCP** (`leerness mcp serve`) | stdio JSON-RPC (도구 수는 `leerness doctor` 가 실측 출력) | 외부 AI 가 자연어→도구 호출로 직접 사용. `adapter <tool>` 이 `.mcp.json` 자동 병합 |
11
11
  | **`.leerness/` state substrate** | 파일(JSON) | 에이전트 간 인수인계 표준 — Claude Code 가 한 작업을 Codex/Goose 가 이어받음. 오프라인·0-deps |
12
12
  | **지침 계층** | AGENTS.md / CLAUDE.md / 전역 조건부(`adapter codex --global`) | 문서를 읽는 에이전트 대상 협조 계층 |
13
13
  | **강제 계층** | `enforce`(pre-commit) + `ci init`(gate) | **프로토콜 무관** — 어떤 방식으로 붙은 에이전트든 커밋/PR 관문에서 검증 |
@@ -17,7 +17,7 @@ leerness 는 **에이전트도 에디터도 아닌, 에이전트 위의 운영
17
17
  **입장: 직접 구현하지 않는다 (고려 후 결정, 2026-07).**
18
18
 
19
19
  - ACP 의 토폴로지는 에디터(클라이언트) ↔ 코딩 에이전트(서버)다. leerness 는 그 어느 쪽도 아니므로 ACP 서버/클라이언트를 구현할 구조적 자리가 없다.
20
- - **간접 호환은 이미 성립**: ACP 로 에디터에 붙는 에이전트(Claude Code ACP 어댑터, Gemini CLI 등)는 도구 계층으로 MCP 를 사용한다 → 프로젝트의 `.mcp.json`(leerness adapter 가 병합)을 통해 leerness 86 도구에 그대로 접근한다.
20
+ - **간접 호환은 이미 성립**: ACP 로 에디터에 붙는 에이전트(Claude Code ACP 어댑터, Gemini CLI 등)는 도구 계층으로 MCP 를 사용한다 → 프로젝트의 `.mcp.json`(leerness adapter 가 병합)을 통해 leerness MCP 도구 전체에 그대로 접근한다.
21
21
  - **강제는 이미 커버**: pre-commit enforce 는 접속 프로토콜과 무관하게 작동한다(커밋이 보편 관문).
22
22
  - 재검토 트리거: ACP 가 에이전트-측 도구 계층으로 MCP 외 자체 도구 규격을 강제하게 되는 경우.
23
23
 
package/lib/analyzers.js CHANGED
@@ -34,10 +34,30 @@ function _shellGuardAnalyze(cmd, ctx) {
34
34
  }
35
35
  return { shell, psVersion: psVer, issues };
36
36
  }
37
+ // 1.36.82 (검수 High#2): evidence 에서 파일 경로를 알아보는 **단일 출처**.
38
+ // 이전엔 bin 의 FILE_RE 와 여기 hasFile 정규식이 각자 따로 있어(주석만 "정합") 실제로는 어긋났고,
39
+ // "대조 0건이면 FAIL" 을 도입한 뒤로는 추출기 사각지대가 곧 정직한 주장의 거짓 차단이 됐다.
40
+ // 실측으로 확인된 사각지대: 다단계 경로의 확장자 없는 파일(ops/containers/Dockerfile),
41
+ // 선두 점 디렉토리(.github/workflows/ci.yml → github/… 로 잘림), 점파일(.gitignore),
42
+ // 그리고 Dockerfile.dev 가 Dockerfile 로 잘려 "없는 파일" 이 되던 것.
43
+ const _VC_FILE_EXTS = 'webmanifest|properties|tscn|tres|godot|json5|prisma|swift|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|sql|mjs|cjs|vue|svelte|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|cpp|c|h';
44
+ // 확장자 없는 표준 파일명 — 임의의 대문자 단어가 아니라 정확한 철자만(대소문자 구분)으로 FP 억제.
45
+ const _VC_BARE_FILES = 'Dockerfile|Containerfile|Makefile|Gemfile|Procfile|Rakefile|Jenkinsfile|Vagrantfile|Caddyfile|Brewfile|Justfile|CODEOWNERS|LICENCE|LICENSE|NOTICE';
46
+ // 점파일 — 확장자 규칙으로는 잡히지 않지만 명백한 파일 참조.
47
+ const _VC_DOTFILES = '\\.(?:gitignore|gitattributes|gitmodules|dockerignore|npmrc|npmignore|nvmrc|editorconfig|prettierrc|eslintrc|babelrc|browserslistrc)';
48
+ // 경로 접두: 0개 이상 세그먼트(각 세그먼트는 선두 점 허용) — `ops/containers/`, `.github/workflows/` 모두 포함.
49
+ const _VC_PREFIX = '(?:\\.?[A-Za-z0-9][A-Za-z0-9._-]*[\\/\\\\])*';
50
+ // basename: (a) 확장자 파일 (b) 표준 bare 파일명(뒤에 .확장자가 오면 (a)에 양보) (c) 점파일
51
+ // bare 파일은 관례적 접미사를 허용한다(Dockerfile.dev / Makefile.local) — 없으면 그 주장이 "파일 0건"이 된다.
52
+ const _VC_BASE = `(?:[A-Za-z0-9][\\w.-]*\\.(?:${_VC_FILE_EXTS})\\b|(?:${_VC_BARE_FILES})(?:\\.[A-Za-z0-9_-]+)?(?![\\w.-])|${_VC_DOTFILES}(?![\\w-]))`;
53
+ function _EVIDENCE_FILE_RE(flags = '') { return new RegExp(`${_VC_PREFIX}${_VC_BASE}`, flags); }
54
+
37
55
  function _evidenceQuality(evidence) {
38
56
  const e = String(evidence || '');
39
57
  // codex 버그헌트 P2: basename 첫 글자에 숫자 허용 ('123.js' 등 숫자시작 실파일) — bin FILE_RE 와 정합.
40
- const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z0-9][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|prisma|sh)\b/i.test(e);
58
+ // 1.36.82: "파일을 언급했는가"의 정의가 두 곳(bin 추출기 · 여기)으로 갈리면 정직한 주장이 사유 없이 거부된다.
59
+ // 판정을 단일 출처(_EVIDENCE_FILE_RE)로 통일한다.
60
+ const hasFile = _EVIDENCE_FILE_RE().test(e);
41
61
  const hasTest = /(\d+)\s*(?:\/\s*\d+\s*)?(?:통과|passed|passing|개\s*테스트)|\btests?\b\s*[:=]?\s*\d|Tests?:\s*\d|\b\d+\s*tests?\b/i.test(e);
42
62
  const hasLog = /Exit\s*[:=]|exit\s*code|Command\s*[:=]|npm\s+(?:test|run)|pytest|cargo\s+test|go\s+test/i.test(e);
43
63
  const missing = [];
@@ -88,4 +108,4 @@ function _epistemicHonestyCheck(text) {
88
108
  return { ok: findings.length === 0, findings, dimensions: ['pretend-knowledge', 'premature-judgment', 'no-info-gathering'] };
89
109
  }
90
110
 
91
- module.exports = { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck };
111
+ module.exports = { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck, _EVIDENCE_FILE_RE };
package/lib/referee.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // lib/referee.js — 검증기 캘리브레이션 (1.36.80, P-0001 · 외부 사례 검토 채택).
2
2
  // 문제: leerness 의 모든 검사(verify-claim/gate/contract)는 "정적·텍스트 대리 지표"였다 — 검증기 자신이
3
3
  // 실패를 잡을 수 있는지는 아무도 증명하지 않았다. 초록 스위트가 실제로는 아무것도 검사하지 않는 경우를 못 걸렀다.
4
- // 해법(anthropics/code-migration-kit 의 judge-setup 원리): 검증기를 신뢰하기 전에 **실행으로 탐지력을 증명**한다
4
+ // 해법(anthropics/code-migration-kit 의 judge-setup 원리): 검증기를 신뢰하기 전에 **실행으로 확인**한다
5
+ // (1.36.82 정직화) 확인하는 것은 "등록된 known-good/known-bad 쌍이 기대한 사유로 갈린다"는 것과 check 의 기동 가능성이다.
6
+ // good/bad 가 check 와 동일한 검증 절차를 서로 다른 상태에 대해 실행하는지는 **도구가 강제할 수 없다** — 사용자의 등록 책임이다.
5
7
  // ① known-good 을 통과시키고 ② 일부러 망가뜨린 known-bad 를 거부하며 ③ 거부 사유가 심어둔 결함과 일치해야 하고
6
8
  // ④ 명령/기대치가 바뀌면 캘리브레이션은 무효(stale)가 된다. 무관한 이유의 실패(환경 오류 등)는 탐지력이 아니다.
7
9
  // 0-deps · 오프라인 · 기존 스토어 규율(손상 fail-closed · 형상 무효 거부 · --json 순수) 준수.
@@ -229,7 +231,11 @@ function refereeCmd(root, sub, rest, deps = {}) {
229
231
  r.calibration = cal;
230
232
  if (json) { log(JSON.stringify({ ok: cal.ok, id: r.id, calibrated: cal.ok, ...cal }, null, 2)); if (!cal.ok) process.exitCode = 1; return; }
231
233
  if (cal.ok) {
232
- ok(`referee ${r.id} 캘리브레이션 통과 탐지력 증명됨 (good exit 0 · bad exit ${cal.badExit}${r.expectBad ? ' · 사유 일치' : ''})`);
234
+ // 1.36.82 (검수 High#5): "탐지력 증명됨" 실제보다 강한 주장이었다 캘리브레이션이 증명하는 것은
235
+ // **등록한 known-good/known-bad 쌍이 기대한 사유로 갈린다**는 것과 check 가 기동 가능하다는 것뿐이다.
236
+ // good/bad 가 check 와 같은 절차인지는 도구가 강제할 수 없다(사용자가 그렇게 등록해야 한다).
237
+ ok(`referee ${r.id} 캘리브레이션 통과 — known-good 통과 · known-bad 를 기대 사유로 거부 (good exit 0 · bad exit ${cal.badExit}${r.expectBad ? ' · 사유 일치' : ''})`);
238
+ log(` ⓘ 이 결과는 **등록한 good/bad 쌍**에 대한 것입니다 — good/bad 가 check 와 같은 검증 절차를 서로 다른 상태에 대해 실행할 때만 check 의 탐지력을 뜻합니다.`);
233
239
  log(` → 이제 verify-claim --referee ${r.id} / gate --require-referee ${r.id} 가 이 검증기를 신뢰합니다.`);
234
240
  log(` ⓘ 명령이나 기대 신호를 바꾸면 캘리브레이션은 자동 무효(stale) 가 됩니다.`);
235
241
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.81",
3
+ "version": "1.36.82",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -6569,6 +6569,118 @@ total++;
6569
6569
  if (!ok) failed++;
6570
6570
  }
6571
6571
 
6572
+ // 1.36.82 회귀 (공허한 가드 스윕 High #1): **아무것도 대조하지 못한 done 주장**을 통과로 보고하지 않는다.
6573
+ // 사고: evidence 에 파일 경로가 없으면 fileChecks=[] → filesAllExist(빈배열 every)=true,
6574
+ // stubFiles=[] → implementationSubstance=true, gitApplicable 은 files>0 요구 → skip.
6575
+ // 전 판정이 공허참이라 "코드도 테스트도 없이 done" 이 ok:true/exit 0 이었고, 단조성이 뒤집혀 있었다
6576
+ // (파일명을 적어 검증 가능하게 쓸수록 FAIL, 날조할수록 PASS). 판별 케이스로 고정한다.
6577
+ total++;
6578
+ {
6579
+ let ok = false;
6580
+ try {
6581
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-vac-'));
6582
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { encoding: 'utf8', timeout: 60000 });
6583
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 60000 });
6584
+ const J = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
6585
+ const mkDone = (req, ev) => {
6586
+ const id = (R(['task', 'add', req]).stdout.match(/T-\d{4,}/) || [])[0];
6587
+ R(['task', 'update', id, '--status', 'done', '--evidence', ev, '--next', 'none']);
6588
+ return id;
6589
+ };
6590
+ // (1) 날조: 소스도 테스트도 없이 done + 테스트 모양 문자열만 → 차단돼야
6591
+ const fake = mkDone('결제 재시도 구현', 'tests: 32/32 passed');
6592
+ const rFake = R(['verify-claim', fake, '--json']);
6593
+ const jFake = J(rFake) || {};
6594
+ const blocked = rFake.status === 1 && (jFake.reasons || []).includes('unverifiable-claim');
6595
+ // (2) --lenient opt-out 은 존중돼야 (기존 계약 유지)
6596
+ const lenientOk = R(['verify-claim', fake, '--lenient', '--json']).status === 0;
6597
+ // (3) 정직한 주장은 통과해야 — false-BLOCK 0 (이 게이트의 최악 실패모드)
6598
+ fs.mkdirSync(path.join(d, 'src'), { recursive: true });
6599
+ fs.writeFileSync(path.join(d, 'src', 'pay.js'), 'function retry(fn,n){let e;for(let i=0;i<n;i++){try{return fn()}catch(x){e=x}}throw e}\nmodule.exports={retry}\n');
6600
+ fs.mkdirSync(path.join(d, 'tests2'), { recursive: true });
6601
+ fs.writeFileSync(path.join(d, 'tests2', 'pay.test.js'), 'const {retry}=require("../src/pay.js");\ntest("r",()=>{if(retry(()=>1,2)!==1)throw 0});\n');
6602
+ const honest = mkDone('재시도 헬퍼 추가', 'src/pay.js 구현 + tests2/pay.test.js 1개 테스트 (1/1 통과)');
6603
+ const honestOk = R(['verify-claim', honest, '--json']).status === 0;
6604
+ // (4) 추출기 사각지대가 정직한 주장의 거짓 차단이 되지 않도록 — 검수(High#2)가 짚은 4형태를 모두 고정
6605
+ fs.writeFileSync(path.join(d, 'Dockerfile'), 'FROM node:18\n');
6606
+ fs.writeFileSync(path.join(d, 'Dockerfile.dev'), 'FROM node:18\n');
6607
+ fs.writeFileSync(path.join(d, '.gitignore'), 'node_modules\n');
6608
+ fs.mkdirSync(path.join(d, 'ops', 'containers'), { recursive: true });
6609
+ fs.writeFileSync(path.join(d, 'ops', 'containers', 'Dockerfile'), 'FROM node:18\n');
6610
+ fs.mkdirSync(path.join(d, '.github', 'workflows'), { recursive: true });
6611
+ fs.writeFileSync(path.join(d, '.github', 'workflows', 'ci.yml'), 'name: ci\n');
6612
+ const bareCases = [
6613
+ ['도커 베이스 갱신', 'Dockerfile 베이스 이미지 변경 (1/1 통과)'],
6614
+ ['도커 dev 갱신', 'Dockerfile.dev 수정 (1/1 통과)'],
6615
+ ['무시목록 갱신', '.gitignore 갱신 (1/1 통과)'],
6616
+ ['다단계 경로', 'ops/containers/Dockerfile 갱신 (1/1 통과)'],
6617
+ ['선두 점 디렉토리', '.github/workflows/ci.yml 수정 (1/1 통과)'],
6618
+ ];
6619
+ const bareOk = bareCases.every(([req, ev]) => R(['verify-claim', mkDone(req, ev), '--json']).status === 0);
6620
+ // (4b) 실제 실행 확증(exit 0)은 출력이 파싱 불가여도 면제돼야 — 커스텀 리포터를 벌하지 않는다
6621
+ const runId = mkDone('실행 확증', 'tests: 1/1 passed');
6622
+ const runOk = cp.spawnSync(process.execPath, [CLI, 'verify-claim', runId, '--path', d, '--run-tests', '--test-cmd', 'node -e "process.exit(0)"', '--json'], { encoding: 'utf8', timeout: 90000 }).status === 0;
6623
+ // (5) 일괄/게이트 경로도 동일 판정 공유
6624
+ const all = J(R(['verify-claim', '--all', '--json'])) || {};
6625
+ const allCatches = (all.results || []).some(x => x && x.id === fake && x.ok === false);
6626
+ const gcj = J(R(['gate', '.', '--claims', '--json'])) || {};
6627
+ const vc = (gcj.checks || []).find(c => c.name === 'verify-claims');
6628
+ fs.rmSync(d, { recursive: true, force: true });
6629
+ ok = blocked && lenientOk && honestOk && bareOk && runOk && allCatches && !!vc && vc.ok === false;
6630
+ if (!ok) console.log(` [공허주장 디버그] blocked=${blocked} lenient=${lenientOk} honest=${honestOk} bare=${bareOk} run=${runOk} all=${allCatches} gate=${vc && vc.ok}`);
6631
+ } catch (e) {}
6632
+ console.log(ok ? '✓ B(1.36.82) 공허한 done 주장 차단: 대조 0건 → unverifiable-claim(exit 1) · --lenient 존중 · 정직한 주장/추출기 사각지대 5형태/실행확증 통과(false-BLOCK 0) · --all/gate 동일판정' : '✗ 공허한 done 주장 차단 가드 실패');
6633
+ if (!ok) failed++;
6634
+ }
6635
+
6636
+ // 1.36.82 회귀 (High #2): `gate --require-referee <id>` — 툴이 사용자에게 안내하면서 gate 는 읽지도 않아
6637
+ // 조용히 exit 0 이던 플래그. 이제 실제 게이팅한다(미존재/미증명/무효 거부 + 검증기가 현재 상태를 거부하면 실패).
6638
+ total++;
6639
+ {
6640
+ let ok = false;
6641
+ try {
6642
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-refgate-'));
6643
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { encoding: 'utf8', timeout: 60000 });
6644
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 60000 });
6645
+ const J = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
6646
+ const N = (js) => `node -e "${js}"`;
6647
+ const stepOf = (g, name) => ((g && g.checks) || []).find(c => c.name === name);
6648
+ // 안내 문구와 실제 지원이 일치하는가 — 이 불일치가 이번 사고의 본질이었다
6649
+ const advertised = fs.readFileSync(path.join(__dirname, '..', 'lib', 'referee.js'), 'utf8').includes('gate --require-referee');
6650
+ // (검수 High#3) **안내된 문법 그대로** — positional/--path 없이 cwd 에서. 이전 픽스처는 항상 '.' 과 --path 를
6651
+ // 같이 줘서 "값이 positional 경로로 흡수되던" 결함을 가렸다. 픽스처가 버그를 숨기지 않게 한다.
6652
+ const asAdvertised = cp.spawnSync(process.execPath, [CLI, 'gate', '--require-referee', 'nope', '--json'], { cwd: d, encoding: 'utf8', timeout: 60000 });
6653
+ let aj = null; try { aj = JSON.parse(asAdvertised.stdout); } catch {}
6654
+ const rootOk = !!aj && path.resolve(aj.root) === path.resolve(d); // root 가 <cwd>/nope 로 오인되면 실패
6655
+ // (검수 High#4) 값 누락은 조용히 사라지면 안 된다 — CI 의 빈 환경변수 fail-open 차단
6656
+ const bare = J(R(['gate', '.', '--require-referee', '--json']));
6657
+ const bareBlocked = !!bare && bare.ok === false && (bare.checks || []).some(c => c.code === 'referee_missing_value');
6658
+ const missing = J(R(['gate', '.', '--require-referee', 'nope', '--json']));
6659
+ const mStep = stepOf(missing, 'referee:nope');
6660
+ R(['referee', 'add', 'r1', '--check', N('process.exit(0)'), '--good', N('process.exit(0)'), '--bad', N("console.log('SIG'); process.exit(1)"), '--expect-bad', 'SIG']);
6661
+ const uncal = stepOf(J(R(['gate', '.', '--require-referee', 'r1', '--json'])), 'referee:r1');
6662
+ R(['referee', 'verify', 'r1']);
6663
+ const cal = stepOf(J(R(['gate', '.', '--require-referee', 'r1', '--json'])), 'referee:r1');
6664
+ R(['referee', 'add', 'r2', '--check', N('process.exit(7)'), '--good', N('process.exit(0)'), '--bad', N("console.log('SIG'); process.exit(1)"), '--expect-bad', 'SIG']);
6665
+ R(['referee', 'verify', 'r2']);
6666
+ const rejects = stepOf(J(R(['gate', '.', '--require-referee', 'r2', '--json'])), 'referee:r2');
6667
+ // 플래그 없으면 기존 5체크 무변경(회귀 0)
6668
+ const plain = J(R(['gate', '.', '--json']));
6669
+ const noRegression = plain && plain.total === 5 && !(plain.checks || []).some(c => /referee/.test(c.name));
6670
+ // 반복 지정
6671
+ const twice = J(R(['gate', '.', '--require-referee', 'r1', '--require-referee', 'r2', '--json']));
6672
+ const bothRan = ((twice && twice.checks) || []).filter(c => /^referee:/.test(c.name)).length === 2;
6673
+ fs.rmSync(d, { recursive: true, force: true });
6674
+ // 진단 코드가 --json 에 실려야 한다(모든 실패가 {name,ok:false} 로 축약되면 오배선을 구분할 수 없다)
6675
+ const codeExposed = !!mStep && mStep.code === 'referee_not_found';
6676
+ ok = advertised && rootOk && bareBlocked && codeExposed && !!mStep && mStep.ok === false && !!uncal && uncal.ok === false
6677
+ && !!cal && cal.ok === true && !!rejects && rejects.ok === false && noRegression && bothRan;
6678
+ if (!ok) console.log(` [referee게이트 디버그] adv=${advertised} rootOk=${rootOk} bare=${bareBlocked} code=${codeExposed} missing=${mStep && mStep.ok} uncal=${uncal && uncal.ok} cal=${cal && cal.ok} reject=${rejects && rejects.ok} noReg=${noRegression} both=${bothRan}`);
6679
+ } catch (e) {}
6680
+ console.log(ok ? '✓ B(1.36.82) gate --require-referee 실제 게이팅: 안내문법 그대로 root 정상 · 미존재/미증명 거부(code 노출) · 값누락 차단 · 캘리브레이션 후 통과 · 검증기 거부 시 실패 · 반복 지정 · 플래그 없으면 5체크 무변경' : '✗ gate --require-referee 가드 실패');
6681
+ if (!ok) failed++;
6682
+ }
6683
+
6572
6684
  // 1.35.10 (자체 gate 적대적 헌트): gate 를 "보안 가드레일"로 검증 — 8-프로브 헌트(FP 4 + FN 3 + by-design 1) 결과 제품 결함 0 확인 후, 유일 커버리지 갭(gate 레벨 보안 동작)을 회귀 가드로 고정. 커밋된 실키 → 차단(exit 1, scan secrets step), 정상 .env.example placeholder → 오탐 없이 통과(exit 0). scan-step 리팩터가 가드레일 보안을 조용히 무너뜨리는 회귀 차단.
6573
6685
  total++;
6574
6686
  {