leerness 1.36.126 → 1.36.127

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/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.126 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.127 하네스를 사용합니다. 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.126는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.127는 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.126는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
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.126 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.127 릴리스 태그 이력** (수백 라운드) · _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.126: 2026-08-16
238
+ Last synced by Leerness v1.36.127: 2026-08-17
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.126';
37
+ const VERSION = '1.36.127';
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') 시 호스트 프로세스 오염.
@@ -6693,6 +6693,62 @@ function _selfTestCases() {
6693
6693
  } finally { if (saved !== undefined) process.env.LEERNESS_MCP_TIMEOUT_MS = saved; else delete process.env.LEERNESS_MCP_TIMEOUT_MS; }
6694
6694
  } },
6695
6695
  // 1.36.125: 측정 통제 자체의 가드 — 목록이 비거나 배선이 끊기면 selftest 는 다시 사용자 설정에 휘둘린다.
6696
+ // 1.36.127 (T-0105/T-0106 도그푸딩): 고아 가드 탐지기가 **자기 저장소에서 눈이 멀어 있었다** —
6697
+ // CI 가 `bin/leerness.js` 를 실행하므로 그 파일이 러너로 잡히고, 그 안의 selftest 단언 문자열
6698
+ // (`'test:core'` 등)이 호출로 읽혀 진짜 고아 2건이 0건으로 보고됐다(실측).
6699
+ // ① 판정은 그대로 두고 **반사실**(자기 진입점 텍스트를 뺀 결과와의 차이)을 함께 보고한다.
6700
+ // ② 자기가 부르는 파일이 전부 다른 곳에서 이미 도는 **별칭**은 잠든 검사가 아니므로 면제한다.
6701
+ // 순수 함수라 픽스처로 행위 검증한다(우리 저장소는 이제 배선을 끝내 이 경로를 안 밟는다 —
6702
+ // 그래서 저장소에 의존하면 이 기능이 조용히 썩는다).
6703
+ { name: '1.36.127 (T-0105): 고아 가드 — 자기 진입점 텍스트에 가려진 후보 보고 + 별칭 면제 + 진짜 고아/인라인 대조군', run: () => {
6704
+ const pu = require('../lib/pure-utils');
6705
+ const ci = { file: '.github/workflows/ci.yml', unconditional: true, viaScripts: [], text: 'run: npm test' };
6706
+ const base = { readErrors: 0, skippedLarge: 0, workspaces: false };
6707
+ // ① 반사실 — 자기 진입점이 잠든 가드 이름을 **문자열로만** 담고 있으면 그 사실을 말해야 한다
6708
+ const masked = pu._detectOrphanGuards(Object.assign({}, base, {
6709
+ packageScripts: { test: 'node scripts/a.js', 'test:dead': 'node scripts/dead.js' },
6710
+ scriptFiles: ['scripts/a.js', 'scripts/dead.js'],
6711
+ runners: [ci, { file: 'bin/prod.js', unconditional: true, viaScripts: [], ownEntry: true, text: 'const doc = "run test:dead (scripts/dead.js)";' }],
6712
+ }));
6713
+ if (masked.orphanScripts.length !== 0 || masked.orphanFiles.length !== 0) return false; // 판정은 종전대로 조용
6714
+ if (!masked.maskedByOwnEntry) return false; // 그러나 가려진 사실은 말한다
6715
+ if (!masked.maskedByOwnEntry.scripts.includes('test:dead')) return false;
6716
+ if (!masked.maskedByOwnEntry.files.includes('scripts/dead.js')) return false;
6717
+ // ② 대조군 — 자기 진입점이 없으면 반사실은 아예 없다(조용해야 할 때 조용한가)
6718
+ const plain = pu._detectOrphanGuards(Object.assign({}, base, {
6719
+ packageScripts: { test: 'node scripts/a.js', 'test:dead': 'node scripts/dead.js' },
6720
+ scriptFiles: ['scripts/a.js', 'scripts/dead.js'], runners: [ci],
6721
+ }));
6722
+ if (plain.maskedByOwnEntry !== null) return false;
6723
+ if (!plain.orphanScripts.includes('test:dead')) return false; // 진짜 고아는 여전히 잡힌다
6724
+ // ③ 별칭 면제 — 부르는 파일이 전부 이미 도는 스크립트는 잠든 검사가 아니다
6725
+ const alias = pu._detectOrphanGuards(Object.assign({}, base, {
6726
+ packageScripts: { test: 'node scripts/a.js && node scripts/b.js', 'test:alias': 'node scripts/a.js' },
6727
+ scriptFiles: ['scripts/a.js', 'scripts/b.js'], runners: [ci],
6728
+ }));
6729
+ if (alias.orphanScripts.length !== 0) return false;
6730
+ // ④ 면제가 너무 넓지 않은가 — 파일을 하나도 안 부르는 인라인 스크립트는 근거가 없으므로 면제 금지
6731
+ const inline = pu._detectOrphanGuards(Object.assign({}, base, {
6732
+ packageScripts: { test: 'node scripts/a.js', 'test:inline': 'node -e "process.exit(0)"' },
6733
+ scriptFiles: ['scripts/a.js'], runners: [ci],
6734
+ }));
6735
+ if (!inline.orphanScripts.includes('test:inline')) return false;
6736
+ // ⑤ (검수 P1, 실측 재현) 면제가 **다른 파일을 같은 파일로 착각**하면 안 된다 —
6737
+ // 확장자 뒤 경계가 없으면 `check.tsx` 가 `check.ts` 로 잘려 죽은 가드가 별칭으로 사라졌다.
6738
+ const extBoundary = pu._detectOrphanGuards(Object.assign({}, base, {
6739
+ packageScripts: { test: 'tsx scripts/check.tsx', 'test:dead': 'tsx scripts/check.ts' },
6740
+ scriptFiles: ['scripts/check.tsx', 'scripts/check.ts'], runners: [ci],
6741
+ }));
6742
+ if (!extBoundary.orphanScripts.includes('test:dead')) return false;
6743
+ // ⑥ (검수 P1, 실측 재현) **주석 속 경로**는 실행 근거가 아니다 —
6744
+ // `# scripts/dead.js 는 나중에 정리` 한 줄로 그 가드가 목록에서 사라졌다.
6745
+ const commented = pu._detectOrphanGuards(Object.assign({}, base, {
6746
+ packageScripts: { test: 'node scripts/a.js', 'test:dead': 'node scripts/dead.js' },
6747
+ scriptFiles: ['scripts/a.js', 'scripts/dead.js'],
6748
+ runners: [{ file: '.github/workflows/ci.yml', unconditional: true, viaScripts: [], text: '# scripts/dead.js 는 나중에 정리\n - run: npm test' }],
6749
+ }));
6750
+ return commented.orphanScripts.includes('test:dead');
6751
+ } },
6696
6752
  // 1.36.126 (T-0107): `.leerness` 를 살아 있는 저장소로 **주장하지 않는다**.
6697
6753
  // ⚠ 이 케이스는 **행위검사만** 한다(자기 소스 읽기 금지 — 1.36.84 메타가드). 상수와 하드코딩 수의
6698
6754
  // 모순 금지 불변식은 e2e V 블록이 소스를 읽어 건다. 여기서는 실제 함수를 불러 결과를 본다.
package/lib/audit.js CHANGED
@@ -7,7 +7,7 @@ const path = require('path');
7
7
  const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
8
8
  const { SECRET_PATTERNS } = require('./catalogs');
9
9
  const { findCorruptedStateJson } = require('./state-integrity'); // 1.36.1 (클린룸 리뷰 FN): 상태 JSON 무결성
10
- const { _briefUnfilled, _planGoalUnfilled, _detectOrphanGuards } = require('./pure-utils'); // 1.36.19: 전략 앵커 · 1.36.116: 고아 가드
10
+ const { _briefUnfilled, _planGoalUnfilled, _detectOrphanGuards, _normPath } = require('./pure-utils'); // 1.36.19: 전략 앵커 · 1.36.116: 고아 가드 · 1.36.127: 경로 접기 규칙 공유
11
11
  const fs = require('fs');
12
12
 
13
13
  // 1.36.116 — "존재하지만 아무도 실행하지 않는 가드" 를 audit 이 본다.
@@ -49,7 +49,11 @@ function _collectGuardInputs(root) {
49
49
  if (typeof pkg.bin === 'string') ent.push(pkg.bin);
50
50
  else if (pkg.bin && typeof pkg.bin === 'object') ent.push(...Object.values(pkg.bin));
51
51
  if (typeof pkg.main === 'string') ent.push(pkg.main);
52
- ownEntries = new Set(ent.filter(x => typeof x === 'string').map(x => x.replace(/^\.\//, '').replace(/\\/g, '/')));
52
+ // 1.36.127 (검수 P1): 선행 `./` 백슬래시만 걷어내면 **중간 dot-segment** 남는다
53
+ // CI 가 `node ./bin/./p.js` 로 적으면 수집 파일명은 `bin/./p.js`, own entry 는 `bin/p.js` 라
54
+ // 정확 일치 비교가 어긋나 `ownEntry:false` 가 되고, 이 릴리스가 없애려는 **조용한 0건이 그대로 재발**한다.
55
+ // 양쪽을 같은 규칙(_normEntry)으로 접어서 비교한다.
56
+ ownEntries = new Set(ent.filter(x => typeof x === 'string').map(x => _normPath(x).replace(/^\.\//, '')));
53
57
  } catch { readErrors++; } // package.json 이 깨져 있으면 '스크립트 0개' 가 아니라 **못 읽었다** 고 말해야 한다(검수 P2)
54
58
  // `viaScript` = 이 파일을 러너로 담게 만든 npm 스크립트 이름(CI 설정 파일이면 null).
55
59
  // 1.36.116 (검수 P2): 아무도 안 부르는 wrapper 가 참조하는 파일도 러너로 담기고, 그 텍스트에 잎 가드 이름이
@@ -70,7 +74,11 @@ function _collectGuardInputs(root) {
70
74
  return;
71
75
  }
72
76
  const t = rd(path.join(root, file));
73
- if (t != null) runners.push({ file, text: t, viaScript: viaScript || null, viaScripts: viaScript ? [viaScript] : [], unconditional: !viaScript });
77
+ // 1.36.127 (T-0105/T-0106 실측): CI 패키지 **자신의 진입점**을 실행하면(우리 저장소가 그렇다) 파일이
78
+ // 러너로 들어오고, 그 안의 **문자열 리터럴**(우리 selftest 가 단언하는 `'test:core'` 등)이 배선으로 읽힌다.
79
+ // 그래서 leerness 자신의 진짜 고아 2건(`test:core`·`test:smoke`)이 0건으로 보고됐다 — 도구가 자기 저장소에서
80
+ // 자기 기능에 눈이 먼 상태. 판정은 그대로 두되(오탐 편향 유지), **무엇이 가려졌는지 셀 수 있게** 표시만 남긴다.
81
+ if (t != null) runners.push({ file, text: t, viaScript: viaScript || null, viaScripts: viaScript ? [viaScript] : [], unconditional: !viaScript, ownEntry: ownEntries.has(file) });
74
82
  };
75
83
  try { for (const f of fs.readdirSync(path.join(root, '.github', 'workflows'))) if (/\.ya?ml$/.test(f)) push(`.github/workflows/${f}`); } catch {}
76
84
  // 1.36.116 (검수 #5): 정확히 `Makefile` 과 훅 2개만 읽었다 — `GNUmakefile`·소문자 `makefile`·`commit-msg` 를
@@ -86,7 +94,7 @@ function _collectGuardInputs(root) {
86
94
  // ⚠ **중간 디렉터리**를 허용해야 한다: `scripts/a/run.js` 처럼 한 단계 더 들어간 러너가 아예 수집되지 않아
87
95
  // 그 러너가 배선한 것이 전부 거짓 고아가 됐다(실측: `scripts/run.js` 는 잡히고 `scripts/a/run.js` 는 안 잡힘).
88
96
  const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\](?:[\w.-]+[/\\])*[\w.-]+\.(?:m?js|cjs|ts|sh)/g;
89
- const _norm = (x) => String(x).replace(/\\/g, '/').replace(/^\.\//, '');
97
+ const _norm = (x) => _normPath(x).replace(/^\.\//, ''); // 1.36.127 (검수 P1): 중간 dot-segment 까지 같은 규칙으로 접는다
90
98
  // CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
91
99
  // ⚠ 자기 진입점 제외는 **CI 설정이 직접 부르는 경우까지** 막으면 안 된다(검수 P2) — 그 파일이 실제로
92
100
  // 가드를 배선하고 있으면 그걸 못 봐서 정상 가드가 고아로 뒤집힌다. CI 가 부르면 그건 러너가 맞다.
@@ -263,6 +271,25 @@ function audit(root, opts = {}, deps = {}) {
263
271
  guardCount: g.guardCount, reason: g.reason,
264
272
  });
265
273
  }
274
+ // 1.36.127 (T-0105/T-0106 실측): CI 가 패키지 **자신의 진입점**을 실행하는 저장소에서는, 그 파일 안의
275
+ // 문자열 리터럴이 배선으로 읽혀 진짜 고아가 0건으로 보고된다. leerness 자신이 그 상태였다 —
276
+ // `test:core`·`test:smoke` 2건이 우리 selftest 단언 문자열에 가려져 있었다(도구가 자기 저장소에 눈이 멂).
277
+ // 판정은 바꾸지 않는다(오탐 편향 유지 — 오탐이 이 경고를 죽인다). 대신 **가려진 후보를 이름으로** 말한다.
278
+ // 고아가 0건이든 아니든 별도로 낸다 — 조용한 0건을 없애는 것이 목적이다.
279
+ if (g && g.maskedByOwnEntry) {
280
+ const mk = g.maskedByOwnEntry;
281
+ const parts = [];
282
+ if ((mk.scripts || []).length) parts.push(`npm ${mk.scripts.join(', ')}`);
283
+ if ((mk.files || []).length) parts.push(`파일 ${mk.files.join(', ')}`);
284
+ warnings++;
285
+ warn(`가드 배선 판정이 이 패키지 자신의 진입점 텍스트에 가려질 수 있습니다 — 확인 필요: ${parts.join(' · ')}`);
286
+ log(` 이유: CI 가 이 패키지의 bin/main 을 실행하므로 러너로 잡히는데, 그 안의 **문자열**(문서·템플릿·테스트 단언)에`);
287
+ log(` 스크립트 이름이 있으면 호출로 읽힙니다. 위 항목은 그 텍스트를 빼면 '아무도 안 부름' 으로 나옵니다.`);
288
+ log(` → 실제로 부르는 곳이 있으면 무시하세요. 없으면 CI 에 배선하거나 삭제하세요.`);
289
+ _finding('orphan_guard_masked_by_own_entry', 'warn', 'own bin/main text may mask orphan guards', {
290
+ maskedScripts: mk.scripts || [], maskedFiles: mk.files || [], guardCount: g.guardCount,
291
+ });
292
+ }
266
293
  } catch {}
267
294
  // 1.36.126 (T-0107): 예전 버전의 `migrate-workspace-dir` 는 `.harness` 를 `.leerness` 로 **복사만** 하고
268
295
  // "마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 말했다. 실제로는 그 뒤 모든 쓰기가
package/lib/pure-utils.js CHANGED
@@ -1061,9 +1061,16 @@ const _ENUMS_SCRIPTS_RE = /\b(?:Object\.(?:keys|entries)\s*\(\s*[\w.?\s]*\bscrip
1061
1061
  // 1.36.116 (검수 #6): `/^test:/.test(name)` 형태를 못 뽑아 접두가 0개가 되고, 그러면 "전부 덮는다" 로 처리돼
1062
1062
  // 같은 파일의 `check:dead` 까지 실행된 것으로 계상됐다(미탐). 정규식 리터럴 + `.test(` 형태를 추가한다.
1063
1063
  const _ENUM_PREFIX_RE = /startsWith\s*\(\s*["'`]([\w:.-]+)["'`]\s*\)|\.match\s*\(\s*\/\^([\w:.-]+)|\/\^([\w:.-]+)[^/\n]*\/[a-z]*\s*\.test\s*\(/g;
1064
- function _detectOrphanGuards(input) {
1064
+ // 1.36.127 (T-0105/T-0106 실측): `opts.ignoreOwnEntryText` — 패키지 **자신의 진입점**의 텍스트를 배선 근거에서만
1065
+ // 뺀다(러너 자격·도달성 루트로는 그대로 남긴다). 판정을 바꾸려는 것이 아니라 **무엇이 가려졌는지 재기 위한
1066
+ // 반사실**이다. 우리 저장소에서 실측: 기본 0건 → 진입점 텍스트 제외 시 `test:core`·`test:smoke` 2건.
1067
+ // 왜 이 방식인가: "문자열인가 호출인가" 를 가리려면 파서가 필요하고, 이 저장소는 0-deps 로 손수 만든
1068
+ // 마스커에 이미 한 번 빠졌다(검수 7회 → 되돌림). 그래서 **판정 로직은 손대지 않고** 차이만 보고한다.
1069
+ function _detectOrphanGuards(input, opts) {
1070
+ const _ignoreOwnEntryText = !!(opts && opts.ignoreOwnEntryText);
1065
1071
  const scripts = (input && input.packageScripts) || {}; // { name: body }
1066
- const runners = (input && input.runners) || []; // [{ file, text }] — CI 워크플로 등
1072
+ const runners = ((input && input.runners) || []) // [{ file, text }] — CI 워크플로 등
1073
+ .map(r => (_ignoreOwnEntryText && r && r.ownEntry) ? Object.assign({}, r, { text: '' }) : r);
1067
1074
  const scriptFiles = (input && input.scriptFiles) || []; // ['scripts/check-x.js', ...]
1068
1075
  // ⚠ 관례상 **진입점**인 이름은 대상이 아니다 — `npm test` 는 사람도 CI 도 직접 부르는 표준 동사다.
1069
1076
  // 신규 프로젝트(스크립트 `test` 하나, CI 없음)에서 그걸 "아무도 안 부른다" 고 하면 그건 잡음이고,
@@ -1262,7 +1269,28 @@ function _detectOrphanGuards(input) {
1262
1269
  // CI 설정 쪽도 **주석은 빼고** 본다 — "# turbo 로 옮길지 검토중" 한 줄로 경고 전체가 꺼지면 안 된다(검수 P2).
1263
1270
  const unknownOrchestrator = Object.entries(scripts).some(([n, b]) => isLive(n) && _ORCH_RE.test(String(b || '')))
1264
1271
  || runners.some(r => _isConfigRunner(r.file) && _ORCH_RE.test(_dropCommentLines(String(r.text || ''))));
1265
- const orphanScripts = unknownOrchestrator ? [] : guardNames.filter(n => !isLive(n));
1272
+ // 1.36.127 (T-0105 도그푸딩): 잡으려는 것은 **잠든 검사**이지 단축키가 아니다. `test:core` 처럼
1273
+ // 자기가 부르는 파일이 **전부 다른 곳에서 이미 도는** 스크립트는, 아무도 그 이름을 부르지 않아도
1274
+ // 검사가 잠들지 않는다 — 사람용 별칭일 뿐이다. 그런 것을 "아무도 실행하지 않는 검사" 라 부르면
1275
+ // 오탐이고, 오탐이 이 경고를 죽인다. 파일을 하나도 안 부르는 스크립트(인라인 `node -e` 등)는
1276
+ // 판단 근거가 없으므로 **면제하지 않는다**(빈 집합 공허참 차단).
1277
+ // ⚠ 알려진 한계(false-PASS 편향대로 유지): 같은 파일을 **다른 플래그**로 부르는 경우
1278
+ // (`x.js` vs `x.js --strict`)는 별칭으로 보고 면제한다 — 플래그 차이까지 보려면 명령 파싱이 필요하다.
1279
+ // ⚠ (검수 P1, 실측 재현) 확장자 뒤 **경계**가 없으면 `scripts/check.tsx` 가 `scripts/check.ts` 로 잘려
1280
+ // 서로 다른 두 파일이 같은 것으로 보이고, 죽은 스크립트가 별칭으로 면제된다. 경계를 요구한다.
1281
+ const _filesOf = (body) => [...String(body || '').matchAll(/(?:\.{0,2}[/\\])?[\w.-]+[/\\][\w./\\-]*\.(?:m?js|cjs|mts|cts|tsx?|sh)(?![\w])/g)]
1282
+ .map(m => _normPath(m[0]).replace(/^\.\//, ''));
1283
+ const _reachedElsewhere = (name) => {
1284
+ const mine = _filesOf(scripts[name]);
1285
+ if (!mine.length) return false; // 파일 없는 스크립트는 근거가 없다 — 면제 안 함
1286
+ const others = Object.entries(scripts).filter(([n]) => n !== name && isLive(n)).map(([, b]) => _filesOf(b)).flat();
1287
+ // ⚠ (검수 P1, 실측 재현) 러너 텍스트를 원문 그대로 보면 **주석 속 경로**가 면제 근거가 된다
1288
+ // (`# scripts/dead.js 는 나중에 정리` 한 줄로 그 가드가 사라졌다). 주석은 빼고 본다 —
1289
+ // 같은 함정을 파일 판정 쪽에서 이미 한 번 고쳤는데(검수 재현) 이 신규 경로에 그대로 되살아났다.
1290
+ const runnerText = _normPath(_dropCommentLines(runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n')));
1291
+ return mine.every(f => others.includes(f) || runnerText.includes(f));
1292
+ };
1293
+ const orphanScripts = unknownOrchestrator ? [] : guardNames.filter(n => !isLive(n) && !_reachedElsewhere(n));
1266
1294
  void mentions;
1267
1295
 
1268
1296
  // ③ scripts/ 아래 파일 가드 — 어디에서도 참조되지 않는 것
@@ -1332,6 +1360,18 @@ function _detectOrphanGuards(input) {
1332
1360
  ? ` ⚠ 수집 불완전(읽기실패 ${Number(input.readErrors) || 0} · 대용량 제외 ${Number(input.skippedLarge) || 0}) — 이 목록은 과다보고일 수 있다`
1333
1361
  : '')
1334
1362
  + (monorepoOutOfScope ? ` ⚠ workspaces 저장소 — 루트 package.json 만 봤다(하위 패키지는 범위 밖)` : '');
1363
+ // 1.36.127: 반사실 — 자기 진입점의 **텍스트만** 배선 근거에서 빼면 무엇이 더 보이는가.
1364
+ // 판정(orphanScripts/orphanFiles)은 그대로 두고 차이만 싣는다. 재귀는 한 단계로 막는다.
1365
+ // 이 값이 비어 있지 않다는 것은 "0건이라는 결과를 그대로 믿지 말라" 는 뜻이다 — 조용한 0건을 없앤다.
1366
+ let maskedByOwnEntry = null;
1367
+ if (!_ignoreOwnEntryText && runners.some(r => r && r.ownEntry && String(r.text || ''))) {
1368
+ try {
1369
+ const cf = _detectOrphanGuards(input, { ignoreOwnEntryText: true });
1370
+ const sMore = (cf.orphanScripts || []).filter(x => !orphanScripts.includes(x));
1371
+ const fMore = (cf.orphanFiles || []).filter(x => !orphanFiles.includes(x));
1372
+ if (sMore.length || fMore.length) maskedByOwnEntry = { scripts: sMore, files: fMore };
1373
+ } catch { /* 반사실을 못 재면 그냥 안 싣는다 — 본 판정에 영향 없음 */ }
1374
+ }
1335
1375
  return {
1336
1376
  guardCount: guardNames.length,
1337
1377
  dynamicRunners,
@@ -1340,6 +1380,7 @@ function _detectOrphanGuards(input) {
1340
1380
  orphanFiles,
1341
1381
  scanIncomplete,
1342
1382
  monorepoOutOfScope,
1383
+ maskedByOwnEntry,
1343
1384
  deferred: unknownOrchestrator,
1344
1385
  // 판정 근거를 함께 돌려준다 — 숫자만 보고 못 믿는 일이 없도록(사용자가 근거 없이 목록을 받으면 그냥 끈다)
1345
1386
  reason: (unknownOrchestrator
@@ -1813,6 +1854,9 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1813
1854
  }
1814
1855
 
1815
1856
  module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
1857
+ // 1.36.127 (검수 P1): 경로 접기 규칙은 **한 곳**이어야 한다 — 수집기가 따로 구현했다가 중간 dot-segment 에서
1858
+ // 갈라져 `ownEntry` 판정이 어긋났다(같은 파일을 다른 이름으로 봄).
1859
+ _normPath,
1816
1860
  _parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
1817
1861
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
1818
1862
  _isSecretKey, redactSecrets, hasCredentialMarker, compareVer, parseHarnessVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.126",
3
+ "version": "1.36.127",
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",
@@ -48,7 +48,7 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e.js",
51
+ "test": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/e2e.js",
52
52
  "test:core": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js",
53
53
  "test:fast": "node ./scripts/lint.js && node ./bin/leerness.js selftest && node ./scripts/smoke.js",
54
54
  "test:smoke": "node ./scripts/smoke.js",
package/scripts/e2e.js CHANGED
@@ -4649,7 +4649,7 @@ total++;
4649
4649
  // 1.36.85: selftest 가 소스문자열 검사 → **행위 검사**로 바뀌며 4초대 → 약 13초가 됐다(CLI spawn 비용).
4650
4650
  // 30s 는 4초 시절 기준이라 전체 e2e 부하에서 타임아웃으로 터졌다(격리 재현 3/3 통과 = 검사 자체는 정상).
4651
4651
  // 여유를 넉넉히 준다 — 이 블록이 재는 것은 "비초기화 dir 에서도 통과하는가"이지 속도가 아니다.
4652
- const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 180000 });
4652
+ const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 300000 });
4653
4653
  const sout = (sr.stdout || '') + (sr.stderr || '');
4654
4654
  const selftestOk = sr.status === 0 && /전체 \d+건 통과/.test(sout) && !/설치 손상/.test(sout);
4655
4655
  const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 300000 }); // 1.36.87: doctor 는 selftest 를 내장한다 · 1.36.105: 실측 97~101s(게시본 포함) 라 120s 는 여유 1.2배였다 → 300s
@@ -5065,7 +5065,7 @@ total++;
5065
5065
  let ok = false;
5066
5066
  try {
5067
5067
  // 1.36.85: 행위검사 전환으로 selftest 약 13s — 4초 시절 기준 30s 는 전체 부하에서 터진다.
5068
- const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { encoding: 'utf8', timeout: 180000 });
5068
+ const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { encoding: 'utf8', timeout: 300000 });
5069
5069
  const j = JSON.parse(r.stdout);
5070
5070
  ok = j.ok === true && j.pass === j.total && j.fail === 0 && j.total >= 112 && r.status === 0;
5071
5071
  } catch {}
@@ -5083,7 +5083,7 @@ total++;
5083
5083
  let ok = false;
5084
5084
  try {
5085
5085
  const repoRoot = path.resolve(__dirname, '..');
5086
- const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { cwd: repoRoot, encoding: 'utf8', timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
5086
+ const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { cwd: repoRoot, encoding: 'utf8', timeout: 300000, maxBuffer: 64 * 1024 * 1024 });
5087
5087
  const pureStdout = typeof r.stdout === 'string' && r.stdout.trimStart().startsWith('{');
5088
5088
  const j = JSON.parse(r.stdout); // 계약: stdout 전체가 단일 JSON 문서
5089
5089
  const payloadOk = j.ok === true && j.pass === j.total && j.fail === 0 && j.total > 0;
@@ -7164,8 +7164,12 @@ total++;
7164
7164
  // ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
7165
7165
  // 1.36.87: doctor 는 내부에서 selftest 를 돌린다 — 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
7166
7166
  // 제한을 올린 대신 종료코드도 함께 단언한다 — 타임아웃(status=null)이 출력 매칭만으로 통과하면 안 된다(codex 26차 #11).
7167
- const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 120000, cwd: d });
7168
- const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 120000, cwd: d });
7167
+ // 1.36.127 (실측): 1.36.105 형제 블록의 doctor 제한을 120s→300s 올리며 "여유가 1.2배뿐이라
7168
+ // 전체 부하에서 반복해 터진다" 적었는데, **이 클론은 120s 남아 있었다**(같은 수정의 누락분).
7169
+ // 이번 게이트에서 실제로 여기서만 터졌고, 격리 실측은 `doctor --language en` **120s**(= 제한과 동일, 여유 1.0배).
7170
+ // 측정 대상은 속도가 아니라 언어 렌더이므로 넉넉히 준다.
7171
+ const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 300000, cwd: d });
7172
+ const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 300000, cwd: d });
7169
7173
  const docEn = out(docEnR), docKo = out(docKoR);
7170
7174
  const doctorOk = docEnR.status === 0 && docKoR.status === 0
7171
7175
  && /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
@@ -12315,14 +12319,29 @@ total++;
12315
12319
  // *다른 프로젝트용* CI 를 생성하는 코드라 `npm run test:smoke` 같은 문자열을 담고 있고,
12316
12320
  // CI 가 그 파일을 직접 실행하므로 러너로 수집된다(정상 프로젝트에선 그게 맞는 판단이다).
12317
12321
  // 그래서 기준값은 **우리 CLI 소스를 뺀 입력**으로 잰다 — 탐지기가 망가지면 여기서 잡힌다.
12318
- // ⚠ `test:core`·`test:smoke` 실제로 어떤 러너도 부르지 않는다(ci.yml 은 `test:fast` 만 돌린다).
12319
- // 나중에 CI 배선하면 단언을 **의도적으로** 갱신하라.
12322
+ // ⚠ (1.36.127) 위 주석은 "`test:core`·`test:smoke` 아무도 부른다 배선하면 **의도적으로**
12323
+ // 단언을 갱신하라" 적어 두었다. 이번 라운드에 실제로 배선했다(`e2e-core.js` → npm test + CI),
12324
+ // 그리고 `test:smoke` 는 그 파일이 `test:fast` 로 이미 도는 **별칭**이라 면제 대상이 됐다.
12325
+ // 그래서 기대값을 0건으로 바꾼다 — 다만 **0건을 그냥 단언하면 탐지기가 죽어도 통과한다**.
12326
+ // 실제 수집 입력에 **가짜 잠든 가드를 심어** 잡히는지 함께 본다(계측이 살아 있다는 증거).
12320
12327
  const selfNoBin = Object.assign({}, selfInp, { runners: selfInp.runners.filter(r => !/bin[\\/]leerness\.js$/.test(r.file)) });
12321
12328
  const selfBase = PU._detectOrphanGuards(selfNoBin);
12322
- for (const expect of ['test:smoke', 'test:core']) {
12323
- if (!selfBase.orphanScripts.includes(expect)) bad.push(`자기기준:${expect}가고아목록에없음(${selfBase.orphanScripts.join(',') || '없음'})`);
12324
- }
12329
+ if (selfBase.orphanScripts.length) bad.push(`자기기준:이제0이어야(${selfBase.orphanScripts.join(',')})`);
12325
12330
  if (selfBase.orphanScripts.includes('test:fast')) bad.push('자기기준:CI가도는test:fast를오탐');
12331
+ {
12332
+ // ⚠ 자기참조 7회차: 심을 이름을 **소스에 적으면** 이 파일(scripts/e2e.js)이 러너로 수집되면서
12333
+ // 그 이름이 '덮인 것'이 돼 계측이 조용히 죽는다(실측으로 재현했다). 리터럴 쪼개기는 다음 사람이
12334
+ // 이름을 적는 순간 또 뚫린다 — 그래서 이름 자체를 **실행 시 생성**한다(소스에 존재할 수 없음).
12335
+ const uniq = `check-${process.pid.toString(36)}${(selfNoBin.scriptFiles || []).length.toString(36)}.js`;
12336
+ const uniqPath = `scripts/${uniq}`;
12337
+ const planted = Object.assign({}, selfNoBin, {
12338
+ packageScripts: Object.assign({}, selfNoBin.packageScripts, { 'test:planted': `node ${uniqPath}` }),
12339
+ scriptFiles: (selfNoBin.scriptFiles || []).concat(uniqPath),
12340
+ });
12341
+ const pr = PU._detectOrphanGuards(planted);
12342
+ if (!pr.orphanScripts.includes('test:planted')) bad.push('자기기준:심은잠든가드를못잡음(탐지기죽음)');
12343
+ if (!pr.orphanFiles.includes(uniqPath)) bad.push(`자기기준:심은파일을못잡음(${uniqPath})`);
12344
+ }
12326
12345
  // 그리고 **불변식**으로 고정한다: 이 테스트 파일 자체가 판정을 바꾸면 안 된다.
12327
12346
  // 리터럴 쪼개기로 막으면 다음에 누가 이름을 적는 순간 또 뚫린다 — 값이 아니라 성질을 단언한다.
12328
12347
  const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfNoBin,
@@ -12578,6 +12597,117 @@ total++;
12578
12597
  if (!ok) failed++;
12579
12598
  }
12580
12599
 
12600
+ // 1.36.127 (T-0105/T-0106 도그푸딩): 고아 가드 탐지기가 **자기 저장소에서 눈이 멀어 있었다**.
12601
+ // CI 가 `bin/leerness.js` 를 실행하므로 그 파일이 러너로 잡히고, 그 안의 selftest 단언 문자열
12602
+ // (`'test:core'`·`e2e-core.js`)이 호출로 읽혀 leerness 자신의 진짜 고아가 0건으로 보고됐다.
12603
+ // ① audit 이 그 가림을 **사용자에게 말하는가** ② 대조군은 조용한가 ③ 그리고 우리 자신이 실제로 배선했는가.
12604
+ total++;
12605
+ {
12606
+ let ok = false; const bad = []; const dbg = {};
12607
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mask127-'));
12608
+ const envM = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
12609
+ for (const k of ['LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envM[k];
12610
+ try {
12611
+ // ① 피해자 픽스처 — 자기 진입점이 잠든 가드 이름을 **문자열로만** 담는다
12612
+ const d = path.join(sb, 'p'); fs.mkdirSync(path.join(d, 'scripts'), { recursive: true });
12613
+ fs.mkdirSync(path.join(d, '.github', 'workflows'), { recursive: true });
12614
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({
12615
+ name: 'p', version: '0.1.0', bin: { p: 'bin/p.js' },
12616
+ scripts: { test: 'node scripts/check-a.js', 'test:dead': 'node scripts/check-dead.js' },
12617
+ }, null, 2));
12618
+ fs.mkdirSync(path.join(d, 'bin'), { recursive: true });
12619
+ // 호출이 아니라 **문서 문자열** — 사람 눈에는 명백히 호출이 아니지만 탐지기는 구분하지 못한다(0-deps 파서 없음).
12620
+ fs.writeFileSync(path.join(d, 'bin', 'p.js'), 'const help = "run test:dead (scripts/check-dead.js) manually";\nconsole.log(help);\n');
12621
+ fs.writeFileSync(path.join(d, 'scripts', 'check-a.js'), 'process.exit(0);\n');
12622
+ fs.writeFileSync(path.join(d, 'scripts', 'check-dead.js'), 'process.exit(0);\n');
12623
+ fs.writeFileSync(path.join(d, '.github', 'workflows', 'ci.yml'), 'jobs:\n t:\n steps:\n - run: npm test\n - run: node ./bin/p.js\n');
12624
+ const ri = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 600000, env: envM });
12625
+ if (ri.status !== 0) bad.push(`①init실패(${ri.status})`);
12626
+ const au = cp.spawnSync(process.execPath, [CLI, 'audit', '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 600000, env: envM, maxBuffer: 32 * 1024 * 1024 });
12627
+ let aj = null; try { aj = JSON.parse(au.stdout); } catch { bad.push('①auditJSON아님'); }
12628
+ const fs2 = aj && Array.isArray(aj.findings) ? aj.findings : [];
12629
+ const mask = fs2.find(x => x.kind === 'orphan_guard_masked_by_own_entry');
12630
+ if (!mask) bad.push('①가림_미보고');
12631
+ else {
12632
+ if (!(mask.maskedScripts || []).includes('test:dead')) bad.push(`①가림목록=${JSON.stringify(mask.maskedScripts)}`);
12633
+ if (!(mask.maskedFiles || []).includes('scripts/check-dead.js')) bad.push(`①가림파일=${JSON.stringify(mask.maskedFiles)}`);
12634
+ }
12635
+ // 판정 자체는 종전대로 조용해야 한다(오탐 편향 유지 — 가림 보고는 **추가**이지 판정 변경이 아니다)
12636
+ if (fs2.some(x => x.kind === 'orphan_guard')) bad.push('①판정이바뀜(오탐편향깨짐)');
12637
+ if (!/자신의 진입점 텍스트에 가려질 수 있습니다/.test(
12638
+ cp.spawnSync(process.execPath, [CLI, 'audit', '--path', d], { cwd: d, encoding: 'utf8', timeout: 600000, env: envM }).stdout || '')) bad.push('①사람용문구없음');
12639
+ dbg.masked = mask ? { s: mask.maskedScripts, f: mask.maskedFiles } : null;
12640
+
12641
+ // ② 대조군 — 진입점이 그 이름을 담지 않으면 가림 보고는 없고, 진짜 고아가 그대로 잡힌다
12642
+ const d2 = path.join(sb, 'q'); fs.mkdirSync(path.join(d2, 'scripts'), { recursive: true });
12643
+ fs.mkdirSync(path.join(d2, '.github', 'workflows'), { recursive: true });
12644
+ fs.mkdirSync(path.join(d2, 'bin'), { recursive: true });
12645
+ fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({
12646
+ name: 'q', version: '0.1.0', bin: { q: 'bin/q.js' },
12647
+ scripts: { test: 'node scripts/check-a.js', 'test:dead': 'node scripts/check-dead.js' },
12648
+ }, null, 2));
12649
+ fs.writeFileSync(path.join(d2, 'bin', 'q.js'), 'console.log("nothing to see");\n');
12650
+ fs.writeFileSync(path.join(d2, 'scripts', 'check-a.js'), 'process.exit(0);\n');
12651
+ fs.writeFileSync(path.join(d2, 'scripts', 'check-dead.js'), 'process.exit(0);\n');
12652
+ fs.writeFileSync(path.join(d2, '.github', 'workflows', 'ci.yml'), 'jobs:\n t:\n steps:\n - run: npm test\n - run: node ./bin/q.js\n');
12653
+ cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes'], { cwd: d2, encoding: 'utf8', timeout: 600000, env: envM });
12654
+ let aj2 = null;
12655
+ try { aj2 = JSON.parse(cp.spawnSync(process.execPath, [CLI, 'audit', '--path', d2, '--json'], { cwd: d2, encoding: 'utf8', timeout: 600000, env: envM, maxBuffer: 32 * 1024 * 1024 }).stdout); } catch { bad.push('②auditJSON아님'); }
12656
+ const f2 = aj2 && Array.isArray(aj2.findings) ? aj2.findings : [];
12657
+ if (f2.some(x => x.kind === 'orphan_guard_masked_by_own_entry')) bad.push('②대조군에가림보고');
12658
+ const og = f2.find(x => x.kind === 'orphan_guard');
12659
+ if (!og) bad.push('②진짜고아_미탐지(계측붕괴)');
12660
+ else if (!(og.orphanScripts || []).includes('test:dead')) bad.push(`②고아목록=${JSON.stringify(og.orphanScripts)}`);
12661
+ dbg.control = { masked: f2.some(x => x.kind === 'orphan_guard_masked_by_own_entry'), orphan: !!og };
12662
+
12663
+ // ②-b (검수 P1) 경로 표기가 조금만 달라도 가림 경고가 통째로 빠지면 안 된다 —
12664
+ // CI 가 `node ./bin/./p.js` 로 적으면 수집 파일명은 `bin/./p.js`, 선언은 `bin/p.js` 라
12665
+ // 정확 일치 비교가 어긋나 `ownEntry:false` 가 되고 **조용한 0건이 그대로 재발**했다(실측 재현).
12666
+ {
12667
+ const d3 = path.join(sb, 'r'); fs.mkdirSync(path.join(d3, 'scripts'), { recursive: true });
12668
+ fs.mkdirSync(path.join(d3, '.github', 'workflows'), { recursive: true });
12669
+ fs.mkdirSync(path.join(d3, 'bin'), { recursive: true });
12670
+ fs.writeFileSync(path.join(d3, 'package.json'), JSON.stringify({
12671
+ name: 'r', version: '0.1.0', bin: 'bin/r.js',
12672
+ scripts: { test: 'node scripts/check-a.js', 'test:dead': 'node scripts/check-dead.js' },
12673
+ }, null, 2));
12674
+ fs.writeFileSync(path.join(d3, 'bin', 'r.js'), 'const help = "run test:dead (scripts/check-dead.js) manually";\n');
12675
+ fs.writeFileSync(path.join(d3, 'scripts', 'check-a.js'), 'process.exit(0);\n');
12676
+ fs.writeFileSync(path.join(d3, 'scripts', 'check-dead.js'), 'process.exit(0);\n');
12677
+ // ⚠ 중간 dot-segment — 이 표기 하나가 예전엔 경고를 통째로 없앴다
12678
+ fs.writeFileSync(path.join(d3, '.github', 'workflows', 'ci.yml'), 'jobs:\n t:\n steps:\n - run: npm test\n - run: node ./bin/./r.js\n');
12679
+ cp.spawnSync(process.execPath, [CLI, 'init', d3, '--yes'], { cwd: d3, encoding: 'utf8', timeout: 600000, env: envM });
12680
+ let aj3 = null;
12681
+ try { aj3 = JSON.parse(cp.spawnSync(process.execPath, [CLI, 'audit', '--path', d3, '--json'], { cwd: d3, encoding: 'utf8', timeout: 600000, env: envM, maxBuffer: 32 * 1024 * 1024 }).stdout); } catch { bad.push('②b auditJSON아님'); }
12682
+ const f3 = aj3 && Array.isArray(aj3.findings) ? aj3.findings : [];
12683
+ const m3 = f3.find(x => x.kind === 'orphan_guard_masked_by_own_entry');
12684
+ if (!m3) bad.push('②b dot-segment 표기에서 가림보고 사라짐');
12685
+ else if (!(m3.maskedScripts || []).includes('test:dead')) bad.push(`②b가림목록=${JSON.stringify(m3.maskedScripts)}`);
12686
+ dbg.dotSeg = { masked: !!m3 };
12687
+ }
12688
+
12689
+ // ③ 우리 자신 — 도구가 알려준 것을 실제로 고쳤는가(66초짜리 42블록이 안 돌고 있었다)
12690
+ // (검수 P2) 단순 부분문자열이면 `echo scripts/e2e-core.js` 로도 통과한다 — **실행 형태**를 요구한다:
12691
+ // npm test 본문에서 `node …e2e-core.js` 가 명령 경계(시작 또는 `&&`)에 있어야 하고,
12692
+ // CI 는 `run:` 줄의 **명령 첫 토큰**이 node 여야 한다. 완전한 실행 의미 검증은 아니지만 echo 위조는 막는다.
12693
+ const ownPkg = JSON.parse(fs.readFileSync(path.resolve(path.dirname(CLI), '..', 'package.json'), 'utf8'));
12694
+ const testBody = String(ownPkg.scripts && ownPkg.scripts.test || '');
12695
+ const inNpmTest = /(^|&&)\s*node\s+\.?\/?scripts\/e2e-core\.js(\s|$|&)/.test(testBody);
12696
+ if (!inNpmTest) bad.push(`③e2e-core가 npm test 에 실행형태로 미배선(${testBody.slice(0, 80)})`);
12697
+ const ciText = fs.readFileSync(path.resolve(path.dirname(CLI), '..', '.github', 'workflows', 'ci.yml'), 'utf8');
12698
+ const inCi = ciText.split('\n').some(l => /^\s*(-\s*)?run:\s*node\s+\.?\/?scripts\/e2e-core\.js\s*$/.test(l));
12699
+ if (!inCi) bad.push('③e2e-core가 CI 에 실행형태로 미배선');
12700
+ // 위조 대조군 — 같은 단언이 `echo …` 를 **거부하는지** 함께 확인한다(단언이 살아 있다는 증거).
12701
+ if (/(^|&&)\s*node\s+\.?\/?scripts\/e2e-core\.js(\s|$|&)/.test('echo scripts/e2e-core.js')) bad.push('③단언이 echo 위조를 통과시킴');
12702
+ dbg.self = { inNpmTest, inCi };
12703
+ ok = bad.length === 0;
12704
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
12705
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
12706
+ console.log(ok ? `✓ W(1.36.127/T-0105) 고아 가드 가림: 자기 진입점 문자열이 판정을 가리면 **후보를 이름으로** 보고(판정은 불변) · 대조군은 조용하고 진짜 고아는 그대로 · 우리 자신도 e2e-core 배선 완료 ${JSON.stringify(dbg)}`
12707
+ : '✗ 1.36.127 고아 가드 가림 보고 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
12708
+ if (!ok) failed++;
12709
+ }
12710
+
12581
12711
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
12582
12712
  if (failed > 0) process.exit(1);
12583
12713