leerness 1.36.116 → 1.36.118

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.116 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.118 하네스를 사용합니다. 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.116는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.118는 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.116는 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.116 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.118 릴리스 태그 이력** (수백 라운드) · _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.116: 2026-08-13
238
+ Last synced by Leerness v1.36.118: 2026-08-14
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.116';
37
+ const VERSION = '1.36.118';
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') 시 호스트 프로세스 오염.
@@ -1581,7 +1581,8 @@ async function install(root, opts = {}) {
1581
1581
  // (검수 P2-A): 여기서 예외를 warning 으로만 삼키면 MCP 미등록인데 설치는 성공으로 끝난다 — 눈에 띄게 알린다.
1582
1582
  try {
1583
1583
  const m = _mergeMcpJson(root);
1584
- if (m.preserved) log(` = .mcp.json 의 기존 leerness 항목을 그대로 두었습니다(사용자 설정으로 판단)`);
1584
+ if (m.broken) warn(`.mcp.json 의 leerness 항목에 command 가 없어 서버가 뜨지 않습니다 — 사용자 설정이라 보존했습니다. 직접 고치거나 항목을 지운 뒤 다시 실행하세요`);
1585
+ else if (m.preserved) log(` = .mcp.json 의 기존 leerness 항목을 그대로 두었습니다(사용자 설정으로 판단)`);
1585
1586
  else log(` ✓ .mcp.json ${m.action} — Claude Code 가 leerness MCP 를 인식합니다`);
1586
1587
  } catch (e) { warn(`✗ .mcp.json 에 MCP 를 등록하지 못했습니다 — leerness 도구가 보이지 않습니다: ${(e && e.message) || e}`); }
1587
1588
  }
@@ -23740,16 +23741,27 @@ function _mergeMcpJson(root, rel) {
23740
23741
  }
23741
23742
  }
23742
23743
  obj.mcpServers = obj.mcpServers || {};
23743
- const prev = obj.mcpServers.leerness;
23744
- const already = !!prev;
23745
23744
  // 1.36.116 (검수 P1): 기존 `leerness` 항목을 무조건 덮어써서 사용자의 래퍼·버전 고정·env 가 유실됐다.
23746
- // 이 저장소의 계약은 false-DROP 이 버그다. **우리가 만든 형태**(npx + leerness mcp serve)일 때만 갱신하고,
23747
- // 밖은 사용자 설정으로 보고 보존한다. 그래야 `-y` 같은 마이그레이션은 되면서 남의 설정은 안 건드린다.
23748
- const _ours = (e) => !!e && typeof e === 'object' && !Array.isArray(e) && e.command === 'npx'
23745
+ // 이 저장소의 계약은 false-DROP 이 버그다. **우리가 만든 형태**일 때만 갱신하고 밖은 보존한다.
23746
+ // 1.36.117 (검수 P1 2건 추가):
23747
+ // ① `!e.env && !e.cwd` 로만 걸렀더니 `envFile`·`disabled` 같은 **모르는 키**가 붙은 항목을 우리 것으로 보고
23748
+ // 통째로 갈아치워 그 키들이 사라졌다. 우리 것으로 인정하려면 `command`/`args` **외의 키가 없어야** 한다.
23749
+ // ② 존재 판정을 `!!prev` 로 했더니 `{"leerness": false}` / `null` / `""` 처럼 **명시적으로 적힌 값**을
23750
+ // '없음' 으로 보고 덮어쓰면서 `created` 라고 보고했다. 존재는 키 유무로 판단한다.
23751
+ const hasEntry = Object.prototype.hasOwnProperty.call(obj.mcpServers, 'leerness');
23752
+ const prev = hasEntry ? obj.mcpServers.leerness : undefined;
23753
+ const _isObj = (e) => !!e && typeof e === 'object' && !Array.isArray(e);
23754
+ const _oursExact = (e) => _isObj(e) && e.command === 'npx'
23749
23755
  && Array.isArray(e.args) && e.args.filter(a => a !== '-y').join(' ') === 'leerness mcp serve'
23750
- && !e.env && !e.cwd;
23756
+ && Object.keys(e).every(k => k === 'command' || k === 'args');
23757
+ const _runnable = (e) => _isObj(e) && typeof e.command === 'string' && !!e.command.trim();
23751
23758
  const _rel = String(rel || '.mcp.json');
23752
- if (already && !_ours(prev)) return { file: _rel, action: 'preserved', preserved: true };
23759
+ if (hasEntry && !_oursExact(prev)) {
23760
+ // 보존하되, **실행 불가능한 항목이면 성공이라고 말하지 않는다** — 그건 조용한 거짓 성공이다.
23761
+ const broken = !_runnable(prev);
23762
+ return { file: _rel, action: broken ? 'preserved-broken' : 'preserved', preserved: true, broken };
23763
+ }
23764
+ const already = hasEntry;
23753
23765
  obj.mcpServers.leerness = { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'] };
23754
23766
  mkdirp(path.dirname(f));
23755
23767
  writeUtf8(f, JSON.stringify(obj, null, 2) + '\n');
@@ -23860,8 +23872,24 @@ function adapterCmd(root, tool, opts = {}) {
23860
23872
  // 1.36.116 (검수 #11): adapter 가 manifest 의 설치 등급을 **안 읽고** 항상 standard 템플릿을 만들었다 —
23861
23873
  // `init --minimal` 로 232B 로 줄여 놓은 CLAUDE.md 가 adapter 한 번에 1099B standard 로 되돌아간다.
23862
23874
  // 사용자가 고른 침투성 등급을 다른 명령이 조용히 뒤집으면 그 설정은 의미가 없다.
23875
+ // ⚠ 손상 manifest 를 조용히 삼키면 minimal 설치가 standard 로 **되돌아간다**(사용자가 고른 등급을 소리 없이 뒤집는다).
23876
+ // 이 저장소의 손상-스토어 계약대로 원본 보존 + 중단이다(검수 P2).
23863
23877
  let _instMode = null;
23864
- try { _instMode = (JSON.parse(read(path.join(root, '.harness', 'manifest.json'))) || {}).mode || null; } catch {}
23878
+ {
23879
+ const _mf = path.join(root, '.harness', 'manifest.json');
23880
+ if (exists(_mf)) {
23881
+ _assertStoreParsable(_mf, '.harness/manifest.json');
23882
+ // 1.36.118 (검수 P1): `_assertStoreParsable` 은 **문법**만 본다. `null`·`[]`·`false`·`"x"` 는 유효 JSON 이라
23883
+ // 통과한 뒤 `.mode` 가 없어 조용히 standard 로 떨어졌다 — minimal 설치가 다시 뒤집힌다.
23884
+ // "손상이면 중단" 계약은 형상까지 봐야 지켜진다.
23885
+ let _mo = null;
23886
+ try { _mo = JSON.parse(read(_mf)); } catch { _mo = undefined; }
23887
+ if (!_mo || typeof _mo !== 'object' || Array.isArray(_mo)) {
23888
+ throw Object.assign(new Error(`.harness/manifest.json 이 객체가 아님(${Array.isArray(_mo) ? 'array' : typeof _mo}) — 설치 등급을 읽을 수 없어 중단합니다: ${_mf}`), { code: 'E_STORE_CORRUPT', file: _mf });
23889
+ }
23890
+ _instMode = _mo.mode || null;
23891
+ }
23892
+ }
23865
23893
  // ⚠ 옵션이 둘로 갈린다: `minimal:true` 는 **어떤 키를 만들지**, `mode:'minimal'` 은 **템플릿을 줄일지** 다.
23866
23894
  // 처음에 `minimal:true` 만 넘겨서 이 수정이 무동작이었다(크기 그대로 1099B).
23867
23895
  // 그 다음엔 둘 다 넘겼다가 검수가 모순을 짚었다: `MINIMAL_SKIP_KEYS` 에 이 어댑터의 SKILL.md 가 들어 있어
@@ -23893,7 +23921,18 @@ function adapterCmd(root, tool, opts = {}) {
23893
23921
  if (json) { log(JSON.stringify({ adapter: tool, files: results }, null, 2)); return; }
23894
23922
  ok(`adapter ${tool} (${a.label}) 적용 — ${results.length}개 파일`);
23895
23923
  for (const r of results) log(` ${r.action}: ${r.file}`);
23896
- if (a.mcp) log(` ℹ MCP 등록 도구가 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`);
23924
+ // 1.36.117 (검수 P1): 깨진 항목을 보존했을 때도 "직접 호출 가능" 이라고 말하고 있었다 — 그건 거짓 성공이다.
23925
+ const _brk = results.filter(r => r.broken);
23926
+ if (_brk.length) _brk.forEach(r => warn(`${r.file} 의 leerness 항목에 command 가 없어 서버가 뜨지 않습니다 — 사용자 설정이라 보존했습니다`));
23927
+ // 1.36.118 (자체 헌트): **보존**했을 때도 "leerness MCP verb 를 직접 호출 가능" 이라고 말하고 있었다.
23928
+ // 보존한 항목이 leerness 를 띄우는지는 우리가 확인하지 않았다(사용자가 다른 서버를 적어 뒀을 수도 있다) —
23929
+ // 우리가 쓴 항목이 하나라도 있을 때만 그렇게 말한다. 그 밖에는 무엇을 보존했는지만 알린다.
23930
+ const _wrote = results.filter(r => r.action === 'created' || r.action === 'updated');
23931
+ const _kept = results.filter(r => r.preserved && !r.broken);
23932
+ if (a.mcp && _wrote.length) log(` ℹ MCP 등록 — 이 도구가 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`);
23933
+ _kept.forEach(r => log(` ℹ ${r.file} 의 기존 leerness 항목을 그대로 두었습니다 — 그 항목이 leerness MCP 를 띄우는지는 확인하지 않았습니다`));
23934
+ // 보존은 안전하지만 **막다른 길이면 안 된다** — 우리 것이 망가졌을 때 되돌릴 방법을 알려 준다(자체 헌트).
23935
+ if (_kept.length || _brk.length) log(` → 표준 설정으로 되돌리려면 그 파일의 "leerness" 항목을 지우고 이 명령을 다시 실행하세요`);
23897
23936
  return;
23898
23937
  }
23899
23938
 
@@ -26764,11 +26803,16 @@ async function main() {
26764
26803
  const snippet = { mcpServers: { leerness: entry } };
26765
26804
  // 1.36.116 (검수 P1): `!!leerness` 만 보면 `{"leerness":{}}` 처럼 **실행 불가능한 껍데기**도 "이미 등록됨" 이 된다 —
26766
26805
  // Desktop 은 그걸로 아무것도 못 띄우는데 사용자는 다 됐다고 믿는다. 실행 가능한 형태인지까지 본다.
26806
+ // 1.36.117 (검수 P2): `[]`·`false` 처럼 **적혀는 있으나 실행 불가능한** 값이 'none'(미등록)으로 분류돼
26807
+ // "추가하세요" 라고만 안내했고, `command: " "` 는 'ok'(녹색)로 나왔다. 존재는 키 유무로, 유효는 trim 으로 본다.
26767
26808
  const _regState = () => {
26768
26809
  try {
26769
- const e = (JSON.parse(read(cfg)).mcpServers || {}).leerness;
26770
- if (!e || typeof e !== 'object' || Array.isArray(e)) return 'none';
26771
- return (typeof e.command === 'string' && e.command) ? 'ok' : 'broken';
26810
+ const ms = JSON.parse(read(cfg)).mcpServers;
26811
+ if (!ms || typeof ms !== 'object' || Array.isArray(ms)) return ms === undefined ? 'none' : 'broken';
26812
+ if (!Object.prototype.hasOwnProperty.call(ms, 'leerness')) return 'none';
26813
+ const e = ms.leerness;
26814
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return 'broken';
26815
+ return (typeof e.command === 'string' && e.command.trim()) ? 'ok' : 'broken';
26772
26816
  } catch { return 'unreadable'; }
26773
26817
  };
26774
26818
  if (_mj) { const st = exists(cfg) ? _regState() : 'none'; log(JSON.stringify({ ok: true, configPath: cfg, exists: exists(cfg), registered: st === 'ok', registeredState: st, snippet, entry }, null, 2)); return; }
@@ -26801,6 +26845,11 @@ async function main() {
26801
26845
  log('');
26802
26846
  ('"leerness": ' + JSON.stringify(entry, null, 2)).split('\n').forEach(l => log(' ' + l));
26803
26847
  log('');
26848
+ // 1.36.117 (검수 P2): 파일에 내용은 있는데 `mcpServers` 키 자체가 없는 흔한 경우를 ⓐ·ⓑ 어느 쪽도 안 다뤘다.
26849
+ // ⓐ 를 따르면 기존 설정이 날아가고, ⓑ 는 넣을 자리가 없다.
26850
+ log(_en ? ` (If the file has content but no ${'"mcpServers"'} key, add ${'"mcpServers": { … }'} at the top level with the entry above inside it.)`
26851
+ : ` (내용은 있는데 ${'"mcpServers"'} 키가 없다면, 최상위에 ${'"mcpServers": { … }'} 를 만들고 그 안에 위 항목을 넣으세요.)`);
26852
+ log('');
26804
26853
  log(dm(_en ? ` ⓘ This command never edits the app config for you — it is a system setting, so you apply it yourself.`
26805
26854
  : ` ⓘ 이 명령은 앱 설정을 자동으로 바꾸지 않습니다 — 시스템 설정이라 직접 넣으시는 편이 안전합니다.`));
26806
26855
  log(dm(_en ? ` ⓘ Claude Code (terminal/IDE) reads the project's .mcp.json, so it needs no separate registration.`
package/lib/audit.js CHANGED
@@ -34,9 +34,11 @@ function _collectGuardInputs(root) {
34
34
  };
35
35
  let packageScripts = {};
36
36
  let ownEntries = new Set();
37
+ let workspaces = false;
37
38
  try {
38
39
  const pkg = JSON.parse(rd(path.join(root, 'package.json')) || '{}');
39
40
  packageScripts = pkg.scripts || {};
41
+ workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces.length > 0 : !!(pkg.workspaces && pkg.workspaces.packages);
40
42
  // 1.36.116 (실측): 패키지 **자신의 진입점**(bin/main)은 러너가 아니라 제품이다. leerness 에서 실제로 물렸다 —
41
43
  // `bin/leerness.js` 가 러너로 수집되고, 그 안의 *남의 프로젝트용* 생성 코드에 `test:smoke` 문자열이 있어서
42
44
  // 자기 자신의 진짜 고아가 '누군가 부른다' 로 뒤집혔다(자기참조 4회차).
@@ -45,13 +47,28 @@ function _collectGuardInputs(root) {
45
47
  else if (pkg.bin && typeof pkg.bin === 'object') ent.push(...Object.values(pkg.bin));
46
48
  if (typeof pkg.main === 'string') ent.push(pkg.main);
47
49
  ownEntries = new Set(ent.filter(x => typeof x === 'string').map(x => x.replace(/^\.\//, '').replace(/\\/g, '/')));
48
- } catch {}
50
+ } catch { readErrors++; } // package.json 이 깨져 있으면 '스크립트 0개' 가 아니라 **못 읽었다** 고 말해야 한다(검수 P2)
49
51
  // `viaScript` = 이 파일을 러너로 담게 만든 npm 스크립트 이름(CI 설정 파일이면 null).
50
52
  // 1.36.116 (검수 P2): 아무도 안 부르는 wrapper 가 참조하는 파일도 러너로 담기고, 그 텍스트에 잎 가드 이름이
51
53
  // 있으면 잎이 루트가 됐다 — BFS 재작성이 막으려던 바로 그 우회로가 한 단계 밖에 그대로 있었다.
52
54
  // 판정기가 "그 wrapper 가 실제로 도달 가능한가" 를 따질 수 있도록 출처를 같이 준다.
55
+ // ⚠ 출처는 **여럿**일 수 있다. 파일 단위로 하나만 기억했더니 `package.json` 의 **선언 순서**가 판정을 바꿨다:
56
+ // 죽은 wrapper 가 `test` 보다 위에 있으면 둘이 같은 파일을 불러도 '죽은 참조' 로 고정돼
57
+ // 그 파일이 부르는 정상 가드가 고아로 나왔다(실측 재현). 참조한 스크립트를 전부 모은다.
53
58
  const runners = [];
54
- const push = (file, viaScript) => { const t = rd(path.join(root, file)); if (t != null && !runners.some(r => r.file === file)) runners.push({ file, text: t, viaScript: viaScript || null }); };
59
+ // ⚠ 출처가 **무조건**(CI 설정이 직접 부름) 사실을 따로 기록한다. `viaScripts` 배열만 두면,
60
+ // CI 가 부르는 파일을 죽은 스크립트도 참조할 때 그 스크립트가 배열에 붙으면서 **무조건 출처가 사라지고**
61
+ // 러너 전체가 그 죽은 스크립트에 종속된다 → 정상 배선이 고아로 뒤집힌다(검수 지적, 실측 재현).
62
+ const push = (file, viaScript) => {
63
+ const found = runners.find(r => r.file === file);
64
+ if (found) {
65
+ if (!viaScript) found.unconditional = true;
66
+ else if (!found.viaScripts.includes(viaScript)) found.viaScripts.push(viaScript);
67
+ return;
68
+ }
69
+ const t = rd(path.join(root, file));
70
+ if (t != null) runners.push({ file, text: t, viaScript: viaScript || null, viaScripts: viaScript ? [viaScript] : [], unconditional: !viaScript });
71
+ };
55
72
  try { for (const f of fs.readdirSync(path.join(root, '.github', 'workflows'))) if (/\.ya?ml$/.test(f)) push(`.github/workflows/${f}`); } catch {}
56
73
  // 1.36.116 (검수 #5): 정확히 `Makefile` 과 훅 2개만 읽었다 — `GNUmakefile`·소문자 `makefile`·`commit-msg` 를
57
74
  // 놓치면 그 러너가 부르는 정상 가드가 전부 '아무도 안 부름' 으로 뒤집힌다(미수집은 곧 오탐이다).
@@ -62,14 +79,27 @@ function _collectGuardInputs(root) {
62
79
  // 러너가 부르는 스크립트 파일도 러너다 — 동적 열거자가 대개 거기 있다(그걸 못 보면 오탐이 폭발한다).
63
80
  // 1.36.116 (검수 #5): 경로에 `scripts/` 가 있어야만 수집했다 — `tools/run-ci.mjs`·`ci/run.mjs`·`bin/run-checks.js`
64
81
  // 같은 흔한 배치를 놓치고, 그 안의 동적 열거자를 못 보면 다시 오탐이 폭발한다. 디렉터리 이름을 넓힌다.
65
- const _FILE_RE = /[\w./-]*(?:scripts|tools|ci|bin|tasks)\/[\w.-]+\.(?:m?js|cjs|ts|sh)/g;
82
+ // 경로 구분자는 `/`·`\` 둘 다 받는다 — Windows CI 는 `.\bin\cli.js` 로 적는다(검수 P2 미탐).
83
+ const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\][\w.-]+\.(?:m?js|cjs|ts|sh)/g;
84
+ const _norm = (x) => String(x).replace(/\\/g, '/').replace(/^\.\//, '');
66
85
  // CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
67
- for (const r of runners.slice()) for (const m of String(r.text).matchAll(_FILE_RE)) {
68
- const rel = m[0].replace(/^\.\//, '');
69
- if (!ownEntries.has(rel)) push(rel, null);
86
+ // ⚠ 자기 진입점 제외는 **CI 설정이 직접 부르는 경우까지** 막으면 안 된다(검수 P2) — 그 파일이 실제로
87
+ // 가드를 배선하고 있으면 그걸 못 봐서 정상 가드가 고아로 뒤집힌다. CI 가 부르면 그건 러너가 맞다.
88
+ // ⚠ 단 **주석 줄에서 수집하면 안 된다**: `# debug: node bin/cli.js` 줄로 제품 진입점이 러너가 되고
89
+ // 그 안의 문자열이 진짜 고아를 덮는다(검수 P2 — 자기참조가 주석 경로로 되살아난다).
90
+ const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|--|;)/.test(l)).join('\n');
91
+ for (const r of runners.slice()) {
92
+ const txt = _noComment(r.text);
93
+ for (const m of txt.matchAll(_FILE_RE)) push(_norm(m[0]), null);
94
+ // 디렉터리 접두가 없는 진입점(`"main": "index.js"`)은 위 정규식이 못 잡는다 — 선언된 bin/main 만 이름으로 확인한다.
95
+ for (const own of ownEntries) {
96
+ if (/[/\\]/.test(own)) continue;
97
+ const esc = own.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ if (new RegExp(`(^|[^\\w.\\-/\\\\])\\.?[/\\\\]?${esc}([^\\w.-]|$)`, 'm').test(txt)) push(own, null);
99
+ }
70
100
  }
71
101
  for (const [name, body] of Object.entries(packageScripts)) for (const m of String(body || '').matchAll(_FILE_RE)) {
72
- const rel = m[0].replace(/^\.\//, '');
102
+ const rel = _norm(m[0]);
73
103
  if (!ownEntries.has(rel)) push(rel, name); // 자기 진입점은 러너가 아니다 (위 주석)
74
104
  }
75
105
  // scripts/ 아래 가드처럼 보이는 파일(2단계까지)
@@ -87,7 +117,7 @@ function _collectGuardInputs(root) {
87
117
  }
88
118
  };
89
119
  walk(path.join(root, 'scripts'), 'scripts', 0);
90
- return { packageScripts, runners, scriptFiles, readErrors, skippedLarge };
120
+ return { packageScripts, runners, scriptFiles, readErrors, skippedLarge, workspaces };
91
121
  }
92
122
  // 1.36.108 (T-0097): 락은 harness 가 주입한다(clarify/referee/routing 과 같은 관례) — lib 에 복제본을 두지 않는다.
93
123
  // 주입이 없으면 그냥 실행한다(하위호환). 주입 누락은 e2e 락 가드가 실행으로 잡는다.
@@ -192,16 +222,20 @@ function audit(root, opts = {}, deps = {}) {
192
222
  log(` → CI 워크플로나 집계 스크립트(예: test:ci)에 배선하거나, 더 이상 안 쓰면 지우세요`);
193
223
  _finding('orphan_guard', 'warn', 'checks exist but no runner invokes them', {
194
224
  orphanScripts: g.orphanScripts, orphanFiles: g.orphanFiles, scanIncomplete: g.scanIncomplete,
225
+ // 1.36.118 (검수 P3): 고아가 **있을 때**도 범위 정보가 필요하다 — 0건 분기에만 넣어 계약이 갈렸다.
226
+ monorepoOutOfScope: g.monorepoOutOfScope, deferred: g.deferred,
195
227
  guardCount: g.guardCount, dynamicRunners: g.dynamicRunners, enumPrefixes: g.enumPrefixes, reason: g.reason,
196
228
  });
197
- } else if (g.scanIncomplete) {
229
+ } else if (g.scanIncomplete || g.deferred || g.monorepoOutOfScope) {
198
230
  // 1.36.116 (검수 P2): 고아가 0이면 발견을 아예 안 내보내서, 사용자 입장에서 **'깨끗함'과 '일부를 못 읽음'이
199
231
  // 같은 출력**이었다. 스캔이 불완전했다는 사실 자체가 보고 대상이다(그래야 0을 믿을지 정할 수 있다).
232
+ // 1.36.117: 판정 **보류**(turbo/nx)와 monorepo 범위 밖도 같은 클래스다 — 조용한 0건이면 안 된다.
200
233
  warnings++;
201
234
  warn(`가드 배선 점검이 불완전합니다 — 0건이라는 결과를 그대로 믿지 마세요`);
202
235
  log(` 근거: ${g.reason}`);
203
- _finding('orphan_guard_scan_incomplete', 'warn', 'guard wiring scan could not read everything', {
204
- scanIncomplete: g.scanIncomplete, guardCount: g.guardCount, reason: g.reason,
236
+ _finding('orphan_guard_scan_incomplete', 'warn', 'guard wiring scan could not cover everything', {
237
+ scanIncomplete: g.scanIncomplete, deferred: g.deferred, monorepoOutOfScope: g.monorepoOutOfScope,
238
+ guardCount: g.guardCount, reason: g.reason,
205
239
  });
206
240
  }
207
241
  } catch {}
package/lib/pure-utils.js CHANGED
@@ -1082,22 +1082,34 @@ function _detectOrphanGuards(input) {
1082
1082
  // "이 패키지의 스크립트 이름에 실제로 붙는가" 다. 길이는 1글자만 막는다.
1083
1083
  const _plausible = (p) => !!p && (/[:._-]$/.test(p) || p.length >= 2)
1084
1084
  && _scriptNames.some(n => n !== p && n.startsWith(p));
1085
- for (const r of runners) {
1086
- const raw = String(r.text || '');
1087
- const t = raw;
1088
- if (!(_READS_PKG_RE.test(raw) && _ENUMS_SCRIPTS_RE.test(t))) continue;
1089
- // 접두는 **원문**에서 뽑는다(가린 텍스트에는 문자열이 지워져 있다)
1090
- let m; const re = new RegExp(_ENUM_PREFIX_RE.source, 'g');
1091
- const rawFound = [], valid = [];
1092
- while ((m = re.exec(raw))) { const p = m[1] || m[2] || m[3]; if (p) { rawFound.push(p); if (_plausible(p)) valid.push(p); } }
1093
- // 접두 후보는 많은데 패키지의 스크립트와 하나도 안 맞으면, 그건 열거자의 필터가 아니라 남의 코드다 —
1094
- // "전부 덮는다" 올리면 이 경고가 통째로 죽으므로 **열거자로 치지 않는다**.
1095
- if (rawFound.length && !valid.length) continue;
1096
- dynamicRunners.push(r.file);
1097
- for (const p of valid) coveredPrefixes.add(p);
1098
- }
1099
- // 접두를 뽑았으면 "전부 덮는다" 로 본다 — 편향은 false-PASS 다(오탐이 이 경고를 죽인다).
1100
- const enumCoversAll = dynamicRunners.length > 0 && coveredPrefixes.size === 0;
1085
+ // 열거자 판정도 **실제로 도는 러너**에서만 해야 한다. 아무도 안 부르는 스크립트가 참조하는 열거자 파일이
1086
+ // "전부 태운다" 잡히면 경고 전체가 꺼진다 — turbo 우회로와 같은 클래스다(검수·자체 헌트 양쪽에서 재현).
1087
+ // liveness 도달성에 의존하고 도달성은 열거 범위에 의존하므로, 아래 고정점 반복 안에서 함께 돌린다.
1088
+ // 두 집합 모두 **커지기만** 하므로 수렴한다.
1089
+ let enumCoversAll = false;
1090
+ const _scannedRunners = new Set();
1091
+ const _scanEnumerators = (isLiveRunner) => {
1092
+ let added = false;
1093
+ for (const r of runners) {
1094
+ if (_scannedRunners.has(r.file) || !isLiveRunner(r)) continue;
1095
+ _scannedRunners.add(r.file);
1096
+ const raw = String(r.text || '');
1097
+ if (!(_READS_PKG_RE.test(raw) && _ENUMS_SCRIPTS_RE.test(raw))) continue;
1098
+ // 접두는 **원문**에서 뽑는다(가린 텍스트에는 문자열이 지워져 있다)
1099
+ let m; const re = new RegExp(_ENUM_PREFIX_RE.source, 'g');
1100
+ const rawFound = [], valid = [];
1101
+ while ((m = re.exec(raw))) { const p = m[1] || m[2] || m[3]; if (p) { rawFound.push(p); if (_plausible(p)) valid.push(p); } }
1102
+ // 접두 후보는 많은데 이 패키지의 스크립트와 하나도 안 맞으면, 그건 열거자의 필터가 아니라 남의 코드다 —
1103
+ // "전부 덮는다" 로 올리면 이 경고가 통째로 죽으므로 **열거자로 치지 않는다**.
1104
+ if (rawFound.length && !valid.length) continue;
1105
+ dynamicRunners.push(r.file);
1106
+ for (const p of valid) coveredPrefixes.add(p);
1107
+ // 접두를 못 뽑았으면 "전부 덮는다" 로 본다 — 편향은 false-PASS 다(오탐이 이 경고를 죽인다).
1108
+ if (!valid.length) enumCoversAll = true;
1109
+ added = true;
1110
+ }
1111
+ return added;
1112
+ };
1101
1113
 
1102
1114
  // ② 이름 언급 — 호출 문법을 파싱하지 않고 **이름이 러너 텍스트에 나오는가**로 본다.
1103
1115
  // `spawn("npm",["run","test:unit"])` 같은 배열 형태를 문법 파싱으로 잡으려다 실측에서 놓쳤다.
@@ -1173,10 +1185,16 @@ function _detectOrphanGuards(input) {
1173
1185
  for (const p of ['pre' + n, 'post' + n]) if (Object.prototype.hasOwnProperty.call(scripts, p) && !reachable.has(p)) queue.push(p);
1174
1186
  }
1175
1187
  };
1176
- const _liveRunner = (r) => !r.viaScript || reachable.has(r.viaScript) || coveredByEnum(r.viaScript);
1177
- for (let pass = 0; pass <= runners.length + 1; pass++) {
1188
+ // 출처가 여럿이면 **하나라도 살아 있으면** 러너는 실제로 돈다. 하나만 보면 선언 순서가 판정을 바꾼다(실측).
1189
+ const _liveRunner = (r) => {
1190
+ if (r.unconditional) return true; // CI 설정이 직접 부르는 파일 — 어떤 스크립트에도 종속되지 않는다
1191
+ const via = Array.isArray(r.viaScripts) && r.viaScripts.length ? r.viaScripts : (r.viaScript ? [r.viaScript] : []);
1192
+ return via.length === 0 || via.some(n => reachable.has(n) || coveredByEnum(n));
1193
+ };
1194
+ for (let pass = 0; pass <= runners.length + 2; pass++) {
1178
1195
  _bfs();
1179
- let grew = false;
1196
+ // 열거 범위가 넓어지면 더 많은 스크립트가 live 가 되고, 그러면 더 많은 러너가 유효해진다 — 같은 반복 안에서 돈다.
1197
+ let grew = _scanEnumerators(_liveRunner);
1180
1198
  for (const r of runners) {
1181
1199
  if (usedRunners.has(r.file) || !_liveRunner(r)) continue;
1182
1200
  usedRunners.add(r.file);
@@ -1186,8 +1204,26 @@ function _detectOrphanGuards(input) {
1186
1204
  // 리터럴을 또 쪼개는 것은 이 함정이 재발할 자리를 남기므로, **호출 문맥이 있는 줄**만 본다.
1187
1205
  // 줄 단위라 거리 가정(예전에 85건 오탐을 낸 근접 창)은 쓰지 않고, `spawn("npm",["run","x"])` 같은
1188
1206
  // 배열 형태도 같은 줄에 `npm` 이 있어 그대로 잡힌다.
1189
- const t = _dropCommentLines(String(r.text || ''))
1190
- .split('\n').filter(l => /\b(?:npm|npx|yarn|pnpm|make|run|exec)\b/.test(l)).join('\n');
1207
+ // ⚠ 필터를 **CI 설정 파일에까지** 걸면 matrix/여러 줄 배선을 놓친다(검수 P2):
1208
+ // `script: [check:a, check:b]` 은 `npm` 이 다른 줄에 있다. CI 설정은 그 자체가 배선이므로 전문을 본다.
1209
+ // 줄 필터는 **코드 파일**에만 건다 — 거기서만 이름이 데이터로 등장한다(우리 e2e.js 가 그랬다).
1210
+ // ⚠ CI 설정을 전문으로 보되 **설명용 필드는 뺀다**: `- name: TODO check:leaf 를 붙이자`,
1211
+ // `env: { FOO: check:x }`, artifact 이름 같은 것이 그것만으로 '호출됨' 이 됐다(자체 헌트 + 검수 재현).
1212
+ // 금지 목록은 유계가 아니므로(라벨 키는 얼마든지 새로 생긴다) **허용 목록**으로 뒤집는다:
1213
+ // `key: value` 꼴은 key 가 실행 키일 때만 보고, 키가 없는 줄(목록 항목·연속 줄)과
1214
+ // 실행 토큰이 있는 줄은 그대로 본다. 진짜 배선이면 `run:` 줄이 반드시 있다.
1215
+ const _KEY_RE = /^\s*-?\s*([A-Za-z_][\w.-]*)\s*:\s/;
1216
+ const _RUN_KEYS = /^(?:run|cmd|command|commands|entrypoint|args|script|scripts|shell|uses|with)$/i;
1217
+ const _INVOKE = /\b(?:npm|npx|yarn|pnpm|make|exec)\b/;
1218
+ const _configLineOk = (l) => {
1219
+ const m = _KEY_RE.exec(l);
1220
+ if (!m) return true; // 목록 항목(`- check:a`)·연속 줄
1221
+ return _RUN_KEYS.test(m[1]) || _INVOKE.test(l);
1222
+ };
1223
+ const _raw = _dropCommentLines(String(r.text || ''));
1224
+ const t = _isConfigRunner(r.file)
1225
+ ? _raw.split('\n').filter(_configLineOk).join('\n')
1226
+ : _raw.split('\n').filter(l => /\b(?:npm|npx|yarn|pnpm|make|run|exec)\b/.test(l)).join('\n');
1191
1227
  for (const n of names) if (!roots.has(n) && _has(t, n)) { roots.add(n); grew = true; }
1192
1228
  grew = true;
1193
1229
  }
@@ -1197,8 +1233,9 @@ function _detectOrphanGuards(input) {
1197
1233
  const isLive = (n) => reachable.has(n) || coveredByEnum(n);
1198
1234
  // 오케스트레이터 판정도 **실행되는 스크립트**에서만 본다 — 죽은 `"note":"echo turbo run test"` 한 줄로
1199
1235
  // 경고 전체를 끌 수 있으면 그건 우회로다(검수 P2).
1236
+ // CI 설정 쪽도 **주석은 빼고** 본다 — "# turbo 로 옮길지 검토중" 한 줄로 경고 전체가 꺼지면 안 된다(검수 P2).
1200
1237
  const unknownOrchestrator = Object.entries(scripts).some(([n, b]) => isLive(n) && _ORCH_RE.test(String(b || '')))
1201
- || runners.some(r => _isConfigRunner(r.file) && _ORCH_RE.test(String(r.text || '')));
1238
+ || runners.some(r => _isConfigRunner(r.file) && _ORCH_RE.test(_dropCommentLines(String(r.text || ''))));
1202
1239
  const orphanScripts = unknownOrchestrator ? [] : guardNames.filter(n => !isLive(n));
1203
1240
  void mentions;
1204
1241
 
@@ -1209,17 +1246,42 @@ function _detectOrphanGuards(input) {
1209
1246
  const liveScripts = Object.entries(scripts).filter(([n]) => isLive(n));
1210
1247
  const allText = runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1211
1248
  + liveScripts.map(([, b]) => String(b || '')).join('\n');
1249
+ // ⚠ basename 만으로 덮였다고 보면 **동명 파일이 서로를 살린다**(검수 P2): `a/check.js` 를 부르는 배선이
1250
+ // `b/check.js` 까지 덮어 버린다. basename 은 그 이름이 유일할 때만 근거로 쓴다.
1251
+ const _baseCount = new Map();
1252
+ for (const f of scriptFiles) { const b = String(f).split(/[\\/]/).pop(); _baseCount.set(b, (_baseCount.get(b) || 0) + 1); }
1253
+ // ⚠ 그런데 유일성만으로 끄면 **해석 가능한 상대 호출**까지 잃는다(검수 P2): `scripts/a/run.js` 안의
1254
+ // `require('./check.js')` 는 `scripts/a/check.js` 를 가리키는데, 동명 파일이 있다는 이유로 그것까지 고아가 됐다.
1255
+ // 참조한 러너의 디렉터리를 기준으로 상대 경로를 **해석해서** 덮인 집합에 넣는다.
1256
+ const _resolved = new Set();
1257
+ for (const r of runners.filter(_liveRunner)) {
1258
+ const dir = String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
1259
+ for (const m of String(r.text || '').matchAll(/(\.{1,2}(?:\/[\w.-]+)+\.(?:m?js|cjs|ts|sh))/g)) {
1260
+ const segs = dir.slice();
1261
+ for (const s of m[1].split('/')) {
1262
+ if (s === '.' || s === '') continue;
1263
+ if (s === '..') segs.pop(); else segs.push(s);
1264
+ }
1265
+ _resolved.add(segs.join('/'));
1266
+ }
1267
+ }
1212
1268
  const orphanFiles = unknownOrchestrator ? [] : scriptFiles.filter(f => {
1213
- const base = String(f).split(/[\\/]/).pop();
1214
- return !allText.includes(base) && !allText.includes(String(f).replace(/\\/g, '/'));
1269
+ const full = String(f).replace(/\\/g, '/');
1270
+ if (allText.includes(full) || _resolved.has(full)) return false;
1271
+ const base = full.split('/').pop();
1272
+ if (_baseCount.get(base) === 1 && allText.includes(base)) return false;
1273
+ return true;
1215
1274
  });
1216
1275
 
1217
1276
  // 1.36.116 (검수 #9): 수집이 불완전했으면 그 사실을 결과에 실어야 한다 — 읽기 실패는 곧 **오탐**이 되고
1218
1277
  // (그 러너가 부르던 정상 가드가 '아무도 안 부름' 이 된다), 사용자는 근거를 봐야 그 목록을 믿을지 정한다.
1219
1278
  const scanIncomplete = (Number(input && input.readErrors) || 0) + (Number(input && input.skippedLarge) || 0);
1220
- const _incompleteNote = scanIncomplete
1279
+ // monorepo 루트 package.json 만 보므로 **범위 밖**이다. 조용히 0건이라고 하면 안 되고, 범위를 밝혀야 한다(검수 P2).
1280
+ const monorepoOutOfScope = !!(input && input.workspaces);
1281
+ const _incompleteNote = (scanIncomplete
1221
1282
  ? ` ⚠ 수집 불완전(읽기실패 ${Number(input.readErrors) || 0} · 대용량 제외 ${Number(input.skippedLarge) || 0}) — 이 목록은 과다보고일 수 있다`
1222
- : '';
1283
+ : '')
1284
+ + (monorepoOutOfScope ? ` ⚠ workspaces 저장소 — 루트 package.json 만 봤다(하위 패키지는 범위 밖)` : '');
1223
1285
  return {
1224
1286
  guardCount: guardNames.length,
1225
1287
  dynamicRunners,
@@ -1227,6 +1289,8 @@ function _detectOrphanGuards(input) {
1227
1289
  orphanScripts,
1228
1290
  orphanFiles,
1229
1291
  scanIncomplete,
1292
+ monorepoOutOfScope,
1293
+ deferred: unknownOrchestrator,
1230
1294
  // 판정 근거를 함께 돌려준다 — 숫자만 보고 못 믿는 일이 없도록(사용자가 근거 없이 목록을 받으면 그냥 끈다)
1231
1295
  reason: (unknownOrchestrator
1232
1296
  ? 'turbo/nx/lerna 배선은 설정 파일에 있어 스크립트 그래프로 볼 수 없다 — 판정 보류(보고 안 함)'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.116",
3
+ "version": "1.36.118",
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
@@ -11305,6 +11305,93 @@ total++;
11305
11305
  fs.writeFileSync(path.join(dc2, '.cursor', 'mcp.json'), brokenC);
11306
11306
  cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc2], { cwd: dc2, encoding: 'utf8', timeout: 300000, env: ENV });
11307
11307
  if (fs.readFileSync(path.join(dc2, '.cursor', 'mcp.json'), 'utf8') !== brokenC) bad.push('cursor:손상파일덮어씀');
11308
+ // 새 경로에도 **사용자 설정 보존** 계약이 걸리는지 — 경로만 바꾸고 계약을 안 가져오면 그게 데이터 손실이다.
11309
+ const dc3 = mk();
11310
+ fs.mkdirSync(path.join(dc3, '.cursor'), { recursive: true });
11311
+ fs.writeFileSync(path.join(dc3, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'x' }, leerness: { command: 'node', args: ['./w.js'] } } }, null, 2));
11312
+ cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc3], { cwd: dc3, encoding: 'utf8', timeout: 300000, env: ENV });
11313
+ let jc3 = null; try { jc3 = JSON.parse(fs.readFileSync(path.join(dc3, '.cursor', 'mcp.json'), 'utf8')); } catch {}
11314
+ if (!jc3 || jc3.mcpServers.leerness.command !== 'node' || !jc3.mcpServers.other) bad.push('cursor:사용자설정미보존');
11315
+ }
11316
+ // ⑦ (검수 P1) `_ours` 경계 — 우리가 만든 **정확한 형태**만 갱신하고, 모르는 키가 하나라도 붙으면 보존한다.
11317
+ // 그리고 명시적으로 적힌 falsy 값을 '없음' 으로 오인해 덮어쓰면 안 된다.
11318
+ {
11319
+ const cases = [
11320
+ ['모르는키', { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'], envFile: '.env.x' }, 'preserve'],
11321
+ ['비활성화', { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'], disabled: true }, 'preserve'],
11322
+ ['우리형태', { command: 'npx', args: ['leerness', 'mcp', 'serve'] }, 'update'],
11323
+ ['false', false, 'preserve'],
11324
+ ['null', null, 'preserve'],
11325
+ ['빈객체', {}, 'preserve'],
11326
+ ];
11327
+ for (const [label, val, expect] of cases) {
11328
+ const d = mk();
11329
+ fs.writeFileSync(path.join(d, '.mcp.json'), JSON.stringify({ mcpServers: { leerness: val } }, null, 2));
11330
+ const r = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
11331
+ let j = null; try { j = JSON.parse(fs.readFileSync(path.join(d, '.mcp.json'), 'utf8')); } catch {}
11332
+ const same = j && JSON.stringify(j.mcpServers.leerness) === JSON.stringify(val);
11333
+ if (expect === 'preserve' && !same) bad.push(`_ours:${label}을_덮어씀`);
11334
+ if (expect === 'update' && (!j || (j.mcpServers.leerness.args || [])[0] !== '-y')) bad.push(`_ours:${label}을_갱신안함`);
11335
+ // 실행 불가능한 항목을 보존했으면 **성공이라고 말하지 않는다**
11336
+ const out = String(r.stdout || '') + String(r.stderr || '');
11337
+ const runnable = !!(val && typeof val === 'object' && val.command);
11338
+ if (!runnable && expect === 'preserve' && !/command 가 없어/.test(out)) bad.push(`_ours:${label}에_경고없음`);
11339
+ if (!runnable && expect === 'preserve' && /직접 호출 가능/.test(out)) bad.push(`_ours:${label}에_거짓성공`);
11340
+ // (자체 헌트) **보존**했을 때도 "leerness MCP verb 를 직접 호출 가능" 이라고 말하고 있었다 —
11341
+ // 보존한 항목이 leerness 를 띄우는지 우리는 확인하지 않았다. 우리가 쓴 항목이 있을 때만 그렇게 말한다.
11342
+ if (expect === 'preserve' && /직접 호출 가능/.test(out)) bad.push(`_ours:${label}_보존인데_호출가능주장`);
11343
+ if (expect === 'preserve' && !/되돌리려면/.test(out)) bad.push(`_ours:${label}_복구경로_안내없음`);
11344
+ if (expect === 'update' && !/직접 호출 가능/.test(out)) bad.push(`_ours:${label}_갱신인데_안내없음`);
11345
+ }
11346
+ }
11347
+ // ⑧ (검수 P3) Desktop 상태·영문 표면을 **실제로** 검증한다 — 지금까지는 필드 존재만 봤다.
11348
+ {
11349
+ const de = mk(['--language', 'en']);
11350
+ const hEn = String(cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', de], { cwd: de, encoding: 'utf8', timeout: 120000, env: ENV }).stdout || '');
11351
+ const ko = mk();
11352
+ const hKo = String(cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko], { cwd: ko, encoding: 'utf8', timeout: 120000, env: ENV }).stdout || '');
11353
+ if (/[가-힣]/.test(hEn)) bad.push(`desktop:영문표면에한국어(${(hEn.match(/[가-힣]+/g) || [])[0]})`);
11354
+ if (!/[가-힣]/.test(hKo)) bad.push('desktop:한국어표면이비어있음');
11355
+ // 한국어 없음만 보면 **영문 안내를 비워도** 통과한다(검수 P3) — 영문 문구가 실제로 있는지 본다.
11356
+ if (!/Config file/.test(hEn) || !/mcpServers/.test(hEn) || !/save the whole file|add just this one entry/i.test(hEn)) bad.push('desktop:영문안내_내용없음');
11357
+ // `mcpServers` 키가 없는 파일을 위한 안내가 있어야 한다(ⓐ 를 따르면 기존 설정이 날아간다)
11358
+ if (!/mcpServers[^\n]*(키가 없|no .*key)/i.test(hKo)) bad.push('desktop:mcpServers키없는경우_안내없음');
11359
+ // `registeredState` **네 상태**를 격리해서 실제로 잰다(검수 P3: 지금까지는 필드 존재만 봤다 —
11360
+ // `_regState` 를 상수 'none' 으로 바꾸는 변이가 생존했다). 사용자의 진짜 설정은 건드리지 않는다:
11361
+ // Windows 는 APPDATA, Linux 는 XDG_CONFIG_HOME 으로 앱 설정 경로를 임시 디렉터리에 묶는다.
11362
+ const _cfgVar = process.platform === 'win32' ? 'APPDATA' : (process.platform === 'linux' ? 'XDG_CONFIG_HOME' : null);
11363
+ // macOS 는 경로가 homedir 고정이라 격리할 수 없다 → 그 플랫폼에선 이 깊은 검사가 통째로 건너뛰어진다(검수 P3).
11364
+ // 건너뛸 때도 **공허하게 통과하지는 않도록** 최소 계약(4상태 중 하나)은 잰다.
11365
+ if (!_cfgVar) {
11366
+ const rr = cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko, '--json'], { cwd: ko, encoding: 'utf8', timeout: 120000, env: ENV });
11367
+ let jj = null; try { jj = JSON.parse(String(rr.stdout || '').trim()); } catch {}
11368
+ if (!jj || !['ok', 'broken', 'none', 'unreadable'].includes(jj.registeredState)) bad.push('desktop:상태값이_계약밖');
11369
+ }
11370
+ if (_cfgVar) {
11371
+ const states = [
11372
+ [{ mcpServers: { leerness: { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'] } } }, 'ok'],
11373
+ [{ mcpServers: { leerness: {} } }, 'broken'],
11374
+ [{ mcpServers: { leerness: [] } }, 'broken'],
11375
+ [{ mcpServers: { leerness: { command: ' ' } } }, 'broken'],
11376
+ [{ mcpServers: [] }, 'broken'], // 컨테이너가 배열 — 'none' 으로 새면 안 된다(검수 P3)
11377
+ [{ mcpServers: false }, 'broken'],
11378
+ [{ mcpServers: { other: { command: 'x' } } }, 'none'],
11379
+ [{}, 'none'],
11380
+ ['{ broken', 'unreadable'],
11381
+ ];
11382
+ for (const [content, expect] of states) {
11383
+ const home = fs.mkdtempSync(path.join(sb, 'cfg-'));
11384
+ fs.mkdirSync(path.join(home, 'Claude'), { recursive: true });
11385
+ fs.writeFileSync(path.join(home, 'Claude', 'claude_desktop_config.json'),
11386
+ typeof content === 'string' ? content : JSON.stringify(content, null, 2));
11387
+ const env3 = Object.assign({}, ENV); env3[_cfgVar] = home;
11388
+ const rr = cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko, '--json'], { cwd: ko, encoding: 'utf8', timeout: 120000, env: env3 });
11389
+ let jj = null; try { jj = JSON.parse(String(rr.stdout || '').trim()); } catch {}
11390
+ if (!jj) { bad.push(`desktop:상태검사_json아님(${expect})`); continue; }
11391
+ if (jj.registeredState !== expect) bad.push(`desktop:상태오분류(기대 ${expect} · 실제 ${jj.registeredState})`);
11392
+ if (jj.registered !== (expect === 'ok')) bad.push(`desktop:registered불일치(${expect}→${jj.registered})`);
11393
+ }
11394
+ }
11308
11395
  }
11309
11396
  // ④ Desktop 안내 — 앱 설정을 **자동으로 쓰지 않고** 경로와 스니펫을 준다
11310
11397
  {
@@ -11585,7 +11672,26 @@ total++;
11585
11672
  const wInc = PU._detectOrphanGuards(Object.assign({}, inp, { readErrors: 2 }));
11586
11673
  if (!wInc.scanIncomplete) bad.push('불완전_미전파');
11587
11674
  if (!/수집 불완전/.test(wInc.reason || '')) bad.push('불완전_근거미표기');
11675
+ // (검수 P2) monorepo 는 범위 밖이라는 사실을 표시해야 한다 — 조용한 0건이면 사용자가 커버됐다고 믿는다
11676
+ const wMono = PU._detectOrphanGuards(Object.assign({}, inp, { workspaces: true }));
11677
+ if (!wMono.monorepoOutOfScope || !/범위 밖/.test(wMono.reason || '')) bad.push('monorepo_범위미표시');
11588
11678
  } finally { try { fs.rmSync(sb2, { recursive: true, force: true }); } catch {} }
11679
+ // (검수 P2) **audit 의 실제 보고**를 고정한다 — 순수 함수에 필드가 있어도 CLI 가 침묵하면 사용자는 모른다.
11680
+ {
11681
+ const sbA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-defer116-'));
11682
+ try {
11683
+ const dA = path.join(sbA, 'a'); fs.mkdirSync(dA, { recursive: true });
11684
+ fs.writeFileSync(path.join(dA, 'package.json'), JSON.stringify({ name: 'a', version: '0.1.0', scripts: { test: 'turbo run build', 'check:x': 'node x.js' } }));
11685
+ cp.spawnSync(process.execPath, [CLI, 'init', dA, '--yes'], { cwd: dA, encoding: 'utf8', timeout: 300000, env: ENV2 });
11686
+ const rA = cp.spawnSync(process.execPath, [CLI, 'audit', '--path', dA, '--json'], { cwd: dA, encoding: 'utf8', timeout: 300000, env: ENV2 });
11687
+ let jA = null; try { jA = JSON.parse(String(rA.stdout || '').trim()); } catch {}
11688
+ const fA = jA && (jA.findings || []).find(x => x.kind === 'orphan_guard_scan_incomplete');
11689
+ if (!fA) bad.push('보류인데_audit이_침묵');
11690
+ else if (!fA.deferred) bad.push('보류사유_미표기');
11691
+ const hA = String(cp.spawnSync(process.execPath, [CLI, 'audit', '--path', dA], { cwd: dA, encoding: 'utf8', timeout: 300000, env: ENV2 }).stdout || '');
11692
+ if (!/불완전/.test(hA)) bad.push('보류가_사람용출력에_없음');
11693
+ } finally { try { fs.rmSync(sbA, { recursive: true, force: true }); } catch {} }
11694
+ }
11589
11695
  }
11590
11696
  // ⑬ (검수 #10) 대용량 파일은 통째로 읽지 않는다 — 일반 예외와 달리 OOM/장시간 정지는 바깥 try/catch 로 회복되지 않는다.
11591
11697
  {
@@ -11621,6 +11727,46 @@ total++;
11621
11727
  scriptFiles: [],
11622
11728
  });
11623
11729
  if (r16b.orphanScripts.length) bad.push('산_러너파일의_잎을_오탐:' + r16b.orphanScripts.join(','));
11730
+ // 출처가 여럿인 파일 — **하나라도 살아 있으면** 그 러너는 돈다. 파일당 출처를 하나만 기억하면
11731
+ // package.json 의 **선언 순서**가 판정을 바꾼다(죽은 wrapper 가 위에 있으면 정상 가드가 고아로 나왔다, 실측).
11732
+ const r16c = PU._detectOrphanGuards({
11733
+ packageScripts: { 'dead-wrapper': 'node tools/x.js', test: 'node tools/x.js', 'check:leaf': 'node l.js' },
11734
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [] },
11735
+ { file: 'tools/x.js', text: 'sh("npm run check:leaf")', viaScript: 'dead-wrapper', viaScripts: ['dead-wrapper', 'test'] }],
11736
+ scriptFiles: [],
11737
+ });
11738
+ if (r16c.orphanScripts.length) bad.push('출처_선언순서에_판정이_의존:' + r16c.orphanScripts.join(','));
11739
+ // 그리고 **실제 수집기**가 출처를 모으는지 — 순수 함수만 재면 이 결함은 안 보인다(수집기가 하나만 담고 있었다)
11740
+ {
11741
+ const sb4 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-via116-'));
11742
+ try {
11743
+ const d4 = path.join(sb4, 'o');
11744
+ fs.mkdirSync(path.join(d4, 'tools'), { recursive: true });
11745
+ fs.mkdirSync(path.join(d4, '.github', 'workflows'), { recursive: true });
11746
+ fs.writeFileSync(path.join(d4, 'package.json'), JSON.stringify({ name: 'o', version: '0.1.0',
11747
+ scripts: { 'dead-wrapper': 'node tools/x.js', test: 'node tools/x.js', 'check:leaf': 'node l.js' } }));
11748
+ fs.writeFileSync(path.join(d4, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm test\n');
11749
+ fs.writeFileSync(path.join(d4, 'tools', 'x.js'), 'sh("npm run check:leaf")\n');
11750
+ const vi = AUD._collectGuardInputs(d4);
11751
+ const rr = (vi.runners.find(r => /tools[\\/]x\.js$/.test(r.file)) || {});
11752
+ if (!(rr.viaScripts || []).includes('test')) bad.push('수집기가_출처를_하나만_담음');
11753
+ if (PU._detectOrphanGuards(vi).orphanScripts.length) bad.push('실측_선언순서_오탐');
11754
+ // (검수) CI 설정이 **직접 부르는** 파일은 어떤 스크립트에도 종속되지 않아야 한다.
11755
+ // `viaScripts` 배열만 두면 죽은 스크립트가 그 배열에 붙으면서 무조건 출처가 사라져
11756
+ // 정상 배선이 고아로 뒤집혔다(오탐).
11757
+ const d5 = path.join(sb4, 'ci');
11758
+ fs.mkdirSync(path.join(d5, 'tools'), { recursive: true });
11759
+ fs.mkdirSync(path.join(d5, '.github', 'workflows'), { recursive: true });
11760
+ fs.writeFileSync(path.join(d5, 'package.json'), JSON.stringify({ name: 'c', version: '0.1.0',
11761
+ scripts: { 'dead-wrapper': 'node tools/ci.js', test: 'node t.js', 'check:leaf': 'node l.js' } }));
11762
+ fs.writeFileSync(path.join(d5, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: node tools/ci.js\n');
11763
+ fs.writeFileSync(path.join(d5, 'tools', 'ci.js'), 'sh("npm run check:leaf")\n');
11764
+ const ci = AUD._collectGuardInputs(d5);
11765
+ const cr = ci.runners.find(r => /tools[\\/]ci\.js$/.test(r.file)) || {};
11766
+ if (!cr.unconditional) bad.push('CI직접호출_출처_유실');
11767
+ if (PU._detectOrphanGuards(ci).orphanScripts.length) bad.push('CI직접호출인데_고아로_오탐');
11768
+ } finally { try { fs.rmSync(sb4, { recursive: true, force: true }); } catch {} }
11769
+ }
11624
11770
  // ⑰ (검수 P2) 와일드카드가 있다고 실행은 아니다 — `echo "test:*"` 는 아무것도 안 돌린다.
11625
11771
  const r17 = PU._detectOrphanGuards({
11626
11772
  packageScripts: { test: 'echo "test:*"', 'test:secrets': 'node s.js' },
@@ -11648,12 +11794,55 @@ total++;
11648
11794
  runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null }], scriptFiles: [],
11649
11795
  });
11650
11796
  if (r19b.orphanScripts.length) bad.push('산_turbo에서_보류_미작동');
11797
+ // 같은 우회로가 **동적 열거자**에도 있었다: 아무도 안 부르는 스크립트가 참조하는 열거자 파일이
11798
+ // "전부 태운다" 로 잡히면 경고 전체가 꺼진다. 열거자 판정도 live 러너에서만 한다.
11799
+ const r19c = PU._detectOrphanGuards({
11800
+ packageScripts: { test: 'node t.js', 'dead-enum': 'node scripts/enum.mjs', 'check:leaf': 'node l.js' },
11801
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [] },
11802
+ { file: 'scripts/enum.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) run(n);`, viaScript: 'dead-enum', viaScripts: ['dead-enum'] }],
11803
+ scriptFiles: [],
11804
+ });
11805
+ if (!r19c.orphanScripts.includes('check:leaf')) bad.push('죽은_열거자가_전체를_덮음');
11806
+ // 판별력 — **도달 가능한** 스크립트가 부르는 열거자는 여전히 전체를 덮어야 한다(정상 저장소를 시끄럽게 만들면 안 된다)
11807
+ const r19d = PU._detectOrphanGuards({
11808
+ packageScripts: { test: 'node scripts/enum.mjs', 'check:leaf': 'node l.js' },
11809
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [] },
11810
+ { file: 'scripts/enum.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) run(n);`, viaScript: 'test', viaScripts: ['test'] }],
11811
+ scriptFiles: [],
11812
+ });
11813
+ if (r19d.orphanScripts.length) bad.push('산_열거자를_무시:' + r19d.orphanScripts.join(','));
11814
+ // 고정점의 **다단 전파**: 열거자 A 가 붙여 준 접두 덕에 열거자 B 가 나중에 live 가 되는 순서.
11815
+ // 한 번만 훑는 구현으로 바꾸는 변이가 여기서 죽는다(검수 P3: 지금까지는 1단계만 봤다).
11816
+ const r19e = PU._detectOrphanGuards({
11817
+ packageScripts: { test: 'node scripts/enum-a.mjs', 'check:enum-b': 'node scripts/enum-b.mjs', 'verify:leaf': 'node v.js', 'scan:dead': 'node d.js' },
11818
+ runners: [
11819
+ { file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11820
+ { file: 'scripts/enum-a.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) { if (n.startsWith("check:")) run(n); }`, viaScript: 'test', viaScripts: ['test'] },
11821
+ { file: 'scripts/enum-b.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) { if (n.startsWith("verify:")) run(n); }`, viaScript: 'check:enum-b', viaScripts: ['check:enum-b'] },
11822
+ ],
11823
+ scriptFiles: [],
11824
+ });
11825
+ if (r19e.orphanScripts.includes('verify:leaf')) bad.push('다단_열거자_전파_안됨');
11826
+ if (!r19e.enumPrefixes.includes('verify:')) bad.push('2단계_열거자_접두_미수집');
11827
+ // 판별력 — 어느 열거자도 안 태우는 것은 여전히 고아여야 한다(전파가 전부를 덮으면 안 된다)
11828
+ if (!r19e.orphanScripts.includes('scan:dead')) bad.push('다단전파가_범위밖까지_덮음');
11651
11829
  // ⑳ (검수 P2) npm 이 스스로 돌리는 생명주기 스크립트(postinstall 등)는 루트다 — 그 잎을 고아라 하면 오탐이다.
11652
11830
  const r20 = PU._detectOrphanGuards({
11653
11831
  packageScripts: { postinstall: 'npm run check:generated', 'check:generated': 'node g.js' },
11654
11832
  runners: [], scriptFiles: [],
11655
11833
  });
11656
11834
  if (r20.orphanScripts.length) bad.push('생명주기_잎_오탐:' + r20.orphanScripts.join(','));
11835
+ // ㉑ (자체 헌트) CI 설정을 **전문**으로 보게 바꾼 대가 — 사람이 읽는 필드(`name:`)에 이름이 있으면
11836
+ // 그것만으로 '호출됨' 이 됐다. 설명용 키는 빼되, 진짜 배선(`run:`·matrix)은 여전히 잡혀야 한다.
11837
+ for (const [label, text, expectOrphan] of [
11838
+ ['산문만', 'jobs:\n a:\n steps:\n - name: TODO check:leaf 붙이기\n run: echo skip\n', true],
11839
+ ['name 뒤 실제 run', 'jobs:\n a:\n steps:\n - name: Run check:leaf\n run: npm run check:leaf\n', false],
11840
+ ['matrix', 'jobs:\n a:\n strategy:\n matrix:\n script: [check:leaf]\n steps:\n - run: npm run ${{ matrix.script }}\n', false],
11841
+ ]) {
11842
+ const rr = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
11843
+ runners: [{ file: '.github/workflows/ci.yml', text, viaScript: null, viaScripts: [] }], scriptFiles: [] });
11844
+ if (rr.orphanScripts.includes('check:leaf') !== expectOrphan) bad.push(`CI설명키:${label}(고아=${!expectOrphan})`);
11845
+ }
11657
11846
  // ⑮ 자기참조 재발 가드. 이 저장소 **자신**을 수집해 보면 `scripts/e2e.js` 가 러너로 담긴다.
11658
11847
  // 그 안에 열거자 모양의 픽스처 문자열을 그대로 쓰면 leerness 가 자기 테스트를 진짜 열거자로 읽고
11659
11848
  // 자기 자신의 고아 판정을 통째로 무력화한다(이번 라운드에 두 번 발생). 리터럴 쪼개기가 유지되는지 실측으로 잰다.
@@ -11663,22 +11852,41 @@ total++;
11663
11852
  const selfRes = PU._detectOrphanGuards(selfInp);
11664
11853
  if (!selfInp.runners.some(r => /e2e\.js$/.test(r.file))) bad.push('자기수집:e2e.js가러너에없음'); // 셋업 유효성
11665
11854
  if (selfRes.dynamicRunners.some(f => /e2e\.js$|e2e-core\.js$/.test(f))) bad.push(`자기참조:테스트픽스처가열거자로읽힘(${selfRes.dynamicRunners.join(',')})`);
11666
- if (selfInp.runners.some(r => /bin[\\/]leerness\.js$/.test(r.file))) bad.push('자기참조:자기CLI가러너로수집됨');
11667
- // 기준값을 **값으로** 고정한다(검수 P2): dynamicRunners 보면, 비주석 코드에 `test:smoke` 적는
11668
- // 변이로 저장소의 실측 기준이 조용히 사라져도 통과한다. 둘은 실제로 어떤 러너도 부르지 않는다 —
11669
- // ci.yml `test:fast` 돌리고, `test:core` 자기가 실행하는 파일에서만 언급된다.
11670
- // ⚠ 나중에 이것들을 CI 배선하면 단언을 **의도적으로** 갱신하라(자동으로 넘어가면 안 된다).
11855
+ // 기준값은 **탐지기**를 재는 것이지 수집 정책을 재는 것이 아니다. 이 저장소의 `bin/leerness.js`
11856
+ // *다른 프로젝트용* CI 생성하는 코드라 `npm run test:smoke` 같은 문자열을 담고 있고,
11857
+ // CI 파일을 직접 실행하므로 러너로 수집된다(정상 프로젝트에선 그게 맞는 판단이다).
11858
+ // 그래서 기준값은 **우리 CLI 소스를 입력**으로 잰다 탐지기가 망가지면 여기서 잡힌다.
11859
+ // ⚠ `test:core`·`test:smoke` 실제로 어떤 러너도 부르지 않는다(ci.yml `test:fast` 돌린다).
11860
+ // 나중에 CI 에 배선하면 이 단언을 **의도적으로** 갱신하라.
11861
+ const selfNoBin = Object.assign({}, selfInp, { runners: selfInp.runners.filter(r => !/bin[\\/]leerness\.js$/.test(r.file)) });
11862
+ const selfBase = PU._detectOrphanGuards(selfNoBin);
11671
11863
  for (const expect of ['test:smoke', 'test:core']) {
11672
- if (!selfRes.orphanScripts.includes(expect)) bad.push(`자기기준:${expect}가고아목록에없음(${selfRes.orphanScripts.join(',') || '없음'})`);
11864
+ if (!selfBase.orphanScripts.includes(expect)) bad.push(`자기기준:${expect}가고아목록에없음(${selfBase.orphanScripts.join(',') || '없음'})`);
11673
11865
  }
11674
- if (selfRes.orphanScripts.includes('test:fast')) bad.push('자기기준:CI가도는test:fast를오탐');
11866
+ if (selfBase.orphanScripts.includes('test:fast')) bad.push('자기기준:CI가도는test:fast를오탐');
11675
11867
  // 그리고 **불변식**으로 고정한다: 이 테스트 파일 자체가 판정을 바꾸면 안 된다.
11676
11868
  // 리터럴 쪼개기로 막으면 다음에 누가 이름을 적는 순간 또 뚫린다 — 값이 아니라 성질을 단언한다.
11677
- const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfInp,
11678
- { runners: selfInp.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
11679
- if (JSON.stringify(selfRes.orphanScripts.slice().sort()) !== JSON.stringify(selfNoE2e.orphanScripts.slice().sort())) {
11680
- bad.push(`자기참조:테스트파일이_판정을_바꿈(${selfRes.orphanScripts.join(',')} vs ${selfNoE2e.orphanScripts.join(',')})`);
11869
+ const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfNoBin,
11870
+ { runners: selfNoBin.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
11871
+ // ⚠ 비교 대상을 `orphanScripts` 하나로 두면 약한 불변식이다(검수 P3) — 판정 전체를 비교한다.
11872
+ // `orphanFiles` 를 통째로 빼면 자기참조 회귀를 못 잡는다(검수 P3) **측정된 차이만 허용**한다.
11873
+ // 허용치: `scripts/e2e-core.js`. 근거는 실측이다 — e2e.js 가 그 경로를 언급하고, 그 파일은
11874
+ // 고아인 `test:core` 만 실행하므로 e2e.js 를 빼면 고아로 뜬다. 그 밖의 차이는 자기참조 회귀다.
11875
+ const _FILE_ALLOW = new Set(['scripts/e2e-core.js']);
11876
+ const _shape = (x) => JSON.stringify({
11877
+ s: x.orphanScripts.slice().sort(),
11878
+ d: x.dynamicRunners.slice().sort(), p: x.enumPrefixes.slice().sort(),
11879
+ i: x.scanIncomplete, o: x.deferred, m: x.monorepoOutOfScope,
11880
+ });
11881
+ if (_shape(selfBase) !== _shape(selfNoE2e)) {
11882
+ bad.push(`자기참조:테스트파일이_판정을_바꿈(${selfBase.orphanScripts.join(',')} vs ${selfNoE2e.orphanScripts.join(',')})`);
11681
11883
  }
11884
+ const _fdiff = [...new Set([...selfBase.orphanFiles, ...selfNoE2e.orphanFiles])]
11885
+ .filter(f => selfBase.orphanFiles.includes(f) !== selfNoE2e.orphanFiles.includes(f))
11886
+ .filter(f => !_FILE_ALLOW.has(String(f).replace(/\\/g, '/')));
11887
+ if (_fdiff.length) bad.push(`자기참조:테스트파일이_파일판정을_바꿈(${_fdiff.join(',')})`);
11888
+ // 셋업 유효성 — e2e.js 가 실제로 live 러너여야 이 불변식이 의미가 있다(비어 있으면 공허하게 통과한다)
11889
+ if (!selfNoBin.runners.some(r => /e2e\.js$/.test(r.file))) bad.push('자기참조:불변식이_공허(e2e.js가입력에없음)');
11682
11890
  }
11683
11891
  // ⑭ (검수 #14) Linux 는 XDG_CONFIG_HOME 이 있으면 그쪽이 정답이다 — ~/.config 는 기본값일 뿐이다.
11684
11892
  {