leerness 1.36.118 → 1.36.120

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.118 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.120 하네스를 사용합니다. 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.118는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.120는 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.118는 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.118 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.120 릴리스 태그 이력** (수백 라운드) · _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.118: 2026-08-14
238
+ Last synced by Leerness v1.36.120: 2026-08-15
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.118';
37
+ const VERSION = '1.36.120';
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') 시 호스트 프로세스 오염.
@@ -4781,7 +4781,9 @@ function _selfTestCases() {
4781
4781
  const wired = src.includes("if (cmd === 'export' || cmd === 'prompt') {")
4782
4782
  && src.includes("arg('--target', null)")
4783
4783
  && src.includes("'agents-md': 'codex'")
4784
- && src.includes('return adapterCmd(arg(\'--path\', process.cwd()), _xTool);');
4784
+ // 1.36.119: 별칭도 `adapter` 와 **같은 손상 가드**를 타야 한다 — 전엔 일반 error 로 떨어져
4785
+ // 자동화가 '손상' 과 '그 밖의 실패' 를 구분할 수 없었다(검수 P2).
4786
+ && src.includes('_guardStore(has(\'--json\'), () => adapterCmd(arg(\'--path\', process.cwd()), _xTool))');
4785
4787
  return wired;
4786
4788
  } },
4787
4789
  { name: '12th 외부평가 Sonnet P3 (UR-0143): plan drop 은 plan.md(D-)만 기록, progress-tracker T- 행 미생성 (1.9.449)', run: () => {
@@ -23913,23 +23915,30 @@ function adapterCmd(root, tool, opts = {}) {
23913
23915
  if (files[k] == null) continue;
23914
23916
  results.push(writeIfSafe(root, k, files[k], { mergeManaged: managedOverwrite.has(k), lang: lang === 'en' ? 'en' : 'ko' })); // 1.36.60 (검수 2차 #2): 래퍼 언어 전달 (altTemplate 은 전환-감지 없는 이 경로에선 미적용 — High 회귀 방지)
23915
23917
  }
23916
- if (a.mcp) results.push(_mergeMcpJson(root));
23918
+ // 1.36.119 (검수): 아래 안내 문구의 판정 대상을 **MCP 병합 결과로 한정**한다. 전에는 `results` 전체를 훑었고,
23919
+ // 지금 맞는 이유는 `writeIfSafe` 가 'merged'/'preserved' 만 돌려주기 때문이다 — 그 이름이 바뀌면
23920
+ // 지침 파일을 썼다는 이유로 "MCP 호출 가능" 이라고 말하게 된다(우연한 정합은 계약이 아니다).
23921
+ const mcpResults = [];
23922
+ if (a.mcp) { const _m = _mergeMcpJson(root); results.push(_m); mcpResults.push(_m); }
23917
23923
  // 1.36.116 (검수 P1-D): Cursor 의 프로젝트별 MCP 설정은 `.cursor/mcp.json` 이다. 루트 `.mcp.json` 만 만들어 놓고
23918
23924
  // "Cursor 도 인식한다" 고 말하던 것은 거짓 안내였다 — 이 어댑터의 목적 자체가 그 도구를 배선하는 것이다.
23919
23925
  // 루트 파일도 그대로 둔다(다른 도구가 쓴다). 같은 병합기라 손상 파일은 여전히 보존/중단된다.
23920
- if (tool === 'cursor') { _assertStoreParsable(path.join(root, '.cursor', 'mcp.json'), '.cursor/mcp.json'); results.push(_mergeMcpJson(root, '.cursor/mcp.json')); }
23926
+ if (tool === 'cursor') { _assertStoreParsable(path.join(root, '.cursor', 'mcp.json'), '.cursor/mcp.json'); const _m2 = _mergeMcpJson(root, '.cursor/mcp.json'); results.push(_m2); mcpResults.push(_m2); }
23921
23927
  if (json) { log(JSON.stringify({ adapter: tool, files: results }, null, 2)); return; }
23922
23928
  ok(`adapter ${tool} (${a.label}) 적용 — ${results.length}개 파일`);
23923
23929
  for (const r of results) log(` ${r.action}: ${r.file}`);
23924
23930
  // 1.36.117 (검수 P1): 깨진 항목을 보존했을 때도 "직접 호출 가능" 이라고 말하고 있었다 — 그건 거짓 성공이다.
23925
- const _brk = results.filter(r => r.broken);
23931
+ const _brk = mcpResults.filter(r => r.broken);
23926
23932
  if (_brk.length) _brk.forEach(r => warn(`${r.file} 의 leerness 항목에 command 가 없어 서버가 뜨지 않습니다 — 사용자 설정이라 보존했습니다`));
23927
23933
  // 1.36.118 (자체 헌트): **보존**했을 때도 "leerness MCP verb 를 직접 호출 가능" 이라고 말하고 있었다.
23928
23934
  // 보존한 항목이 leerness 를 띄우는지는 우리가 확인하지 않았다(사용자가 다른 서버를 적어 뒀을 수도 있다) —
23929
23935
  // 우리가 쓴 항목이 하나라도 있을 때만 그렇게 말한다. 그 밖에는 무엇을 보존했는지만 알린다.
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)를 직접 호출 가능`);
23936
+ const _wrote = mcpResults.filter(r => r.action === 'created' || r.action === 'updated');
23937
+ const _kept = mcpResults.filter(r => r.preserved && !r.broken);
23938
+ // 1.36.120 (검수·자체 재현): cursor MCP 설정이 **둘**(루트 `.mcp.json` + `.cursor/mcp.json`)이다.
23939
+ // 한쪽만 우리가 썼는데 "이 도구가 직접 호출 가능" 이라고 하면, 정작 그 도구가 읽는 파일은 남의 설정일 수 있다.
23940
+ // 주장은 **파일 단위**로 한다 — 우리가 쓴 파일만 이름을 대고 말한다.
23941
+ _wrote.forEach(r => log(` ℹ ${r.file} 에 MCP 등록 — 이 파일을 읽는 도구는 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`));
23933
23942
  _kept.forEach(r => log(` ℹ ${r.file} 의 기존 leerness 항목을 그대로 두었습니다 — 그 항목이 leerness MCP 를 띄우는지는 확인하지 않았습니다`));
23934
23943
  // 보존은 안전하지만 **막다른 길이면 안 된다** — 우리 것이 망가졌을 때 되돌릴 방법을 알려 준다(자체 헌트).
23935
23944
  if (_kept.length || _brk.length) log(` → 표준 설정으로 되돌리려면 그 파일의 "leerness" 항목을 지우고 이 명령을 다시 실행하세요`);
@@ -27022,7 +27031,9 @@ async function main() {
27022
27031
  const _ADAPTER_ALIAS = { 'agents-md': 'codex', 'agents': 'codex', 'agent': 'codex', 'agents.md': 'codex' };
27023
27032
  let _xTool = arg('--target', null) || (args[1] && !args[1].startsWith('-') ? args[1] : 'list');
27024
27033
  _xTool = _ADAPTER_ALIAS[String(_xTool).toLowerCase()] || _xTool;
27025
- return adapterCmd(arg('--path', process.cwd()), _xTool);
27034
+ // 1.36.119 (검수 P2): 같은 공개 명령의 **별칭**인데 `adapter` 만 손상을 구조화해 내고 여기는 일반 error 였다 —
27035
+ // 자동화가 '손상' 과 '그 밖의 실패' 를 구분할 수 없다. 같은 가드를 태운다.
27036
+ return _guardStore(has('--json'), () => adapterCmd(arg('--path', process.cwd()), _xTool));
27026
27037
  }
27027
27038
  // 1.9.245: API skill cache — 공식 문서·관련링크 자동 정리 (사용자 명시 UR-0015)
27028
27039
  if (cmd === 'api-skill') return apiSkillCmd(arg('--path', process.cwd()), args[1] || 'help');
package/lib/audit.js CHANGED
@@ -36,7 +36,10 @@ function _collectGuardInputs(root) {
36
36
  let ownEntries = new Set();
37
37
  let workspaces = false;
38
38
  try {
39
- const pkg = JSON.parse(rd(path.join(root, 'package.json')) || '{}');
39
+ // 0바이트 `package.json` 유효 JSON 아닌데 `|| '{}'` 가 그것을 '스크립트 없음' 으로 삼켰다(검수 P2).
40
+ const _pkgRaw = rd(path.join(root, 'package.json'));
41
+ if (_pkgRaw != null && !String(_pkgRaw).trim()) throw new Error('empty package.json');
42
+ const pkg = JSON.parse(_pkgRaw || '{}');
40
43
  packageScripts = pkg.scripts || {};
41
44
  workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces.length > 0 : !!(pkg.workspaces && pkg.workspaces.packages);
42
45
  // 1.36.116 (실측): 패키지 **자신의 진입점**(bin/main)은 러너가 아니라 제품이다. leerness 에서 실제로 물렸다 —
@@ -87,15 +90,27 @@ function _collectGuardInputs(root) {
87
90
  // 가드를 배선하고 있으면 그걸 못 봐서 정상 가드가 고아로 뒤집힌다. CI 가 부르면 그건 러너가 맞다.
88
91
  // ⚠ 단 **주석 줄에서 수집하면 안 된다**: `# debug: node bin/cli.js` 한 줄로 제품 진입점이 러너가 되고
89
92
  // 그 안의 문자열이 진짜 고아를 덮는다(검수 P2 — 자기참조가 주석 경로로 되살아난다).
90
- const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|--|;)/.test(l)).join('\n');
93
+ // CMD 스텝의 `REM`·`::` 도 주석이다(검수 재현: `REM debug: node .\bin\phantom.js` 줄로 제품 진입점이 러너가 됐다).
94
+ // ⚠ `--`·`;` 는 여기서 다루는 파일 종류(YAML·Makefile·셸·JS)에서 주석이 아니다 — 오히려 여러 줄 명령의
95
+ // 연속 인자(`--file=scripts/x.js`)를 지워 파일 참조를 잃는다. 주석 표기는 실제로 쓰이는 것만 둔다.
96
+ const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|::|@?REM\s)/i.test(l)).join('\n');
97
+ // 설명 필드(`- name: archive index.js`)는 실행 증거가 아니다 — bare 진입점 매처에도 같은 규칙을 건다.
98
+ // 단 값이 **산문일 때만** 설명으로 본다(matrix 변수명이 `name` 일 수 있다 — 판정기와 같은 규칙).
99
+ const _noDescriptive = (s) => String(s).split('\n').filter(l => {
100
+ const m = /^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:\s*(.*)$/i.exec(l);
101
+ if (!m) return true;
102
+ const v = String(m[1]).trim();
103
+ return !v || v.startsWith('[') || !/\s/.test(v);
104
+ }).join('\n');
91
105
  for (const r of runners.slice()) {
92
106
  const txt = _noComment(r.text);
93
107
  for (const m of txt.matchAll(_FILE_RE)) push(_norm(m[0]), null);
94
108
  // 디렉터리 접두가 없는 진입점(`"main": "index.js"`)은 위 정규식이 못 잡는다 — 선언된 bin/main 만 이름으로 확인한다.
109
+ const bareTxt = _noDescriptive(txt);
95
110
  for (const own of ownEntries) {
96
111
  if (/[/\\]/.test(own)) continue;
97
112
  const esc = own.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
- if (new RegExp(`(^|[^\\w.\\-/\\\\])\\.?[/\\\\]?${esc}([^\\w.-]|$)`, 'm').test(txt)) push(own, null);
113
+ if (new RegExp(`(^|[^\\w.\\-/\\\\])\\.?[/\\\\]?${esc}([^\\w.-]|$)`, 'm').test(bareTxt)) push(own, null);
99
114
  }
100
115
  }
101
116
  for (const [name, body] of Object.entries(packageScripts)) for (const m of String(body || '').matchAll(_FILE_RE)) {
@@ -135,7 +150,13 @@ function audit(root, opts = {}, deps = {}) {
135
150
  const findings = [];
136
151
  const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
137
152
  const _origWrite = process.stdout.write.bind(process.stdout);
138
- if (jsonMode) process.stdout.write = () => true;
153
+ // 1.36.120: `--json` 은 **stdout 만** 막고 있었다 — `warn()` 쓰는 stderr 로 사람용 문구가 그대로 샜다.
154
+ // 그 결과 JSON 계약 표면에 잡음이 남고, 그 잡음의 내용이 **프로젝트 상태에 따라** 달라져
155
+ // 게이트 판정이 저장소 상태에 종속됐다(같은 코드로 통과/실패가 갈렸다). json 모드에서는 양쪽을 다 막는다.
156
+ // (findings 배열이 그 정보를 이미 싣고 있으므로 사람용 문구는 중복이다.)
157
+ const _origErrWrite = process.stderr.write.bind(process.stderr);
158
+ const _restoreStreams = () => { process.stdout.write = _origWrite; process.stderr.write = _origErrWrite; };
159
+ if (jsonMode) { process.stdout.write = () => true; process.stderr.write = () => true; }
139
160
  try {
140
161
  // 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
141
162
  if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
@@ -144,7 +165,7 @@ function audit(root, opts = {}, deps = {}) {
144
165
  _finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
145
166
  // 1.27.1 (13번째 외부리뷰 #2): 미초기화 시 후속 체크(design/reuse 등)를 없는 하네스에 대해 보고하던 모순 출력 차단 — 요약/JSON 으로 직행 후 종료(exit code/JSON 페이로드는 종전과 동일).
146
167
  log(`Audit summary: warnings=${warnings} failures=${failures}`);
147
- if (jsonMode) { process.stdout.write = _origWrite; process.stdout.write(JSON.stringify({ version: VERSION, root, warnings, failures, fixed, healthy: false, fixApplied: fix, strict: has('--strict'), strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null, summary: `warnings=${warnings} failures=${failures}`, findings }, null, 2) + '\n'); }
168
+ if (jsonMode) { _restoreStreams(); process.stdout.write(JSON.stringify({ version: VERSION, root, warnings, failures, fixed, healthy: false, fixApplied: fix, strict: has('--strict'), strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null, summary: `warnings=${warnings} failures=${failures}`, findings }, null, 2) + '\n'); }
148
169
  process.exitCode = 1;
149
170
  return;
150
171
  }
@@ -542,7 +563,7 @@ function audit(root, opts = {}, deps = {}) {
542
563
  log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
543
564
  } finally {
544
565
  // 1.9.102: stdout 복원
545
- if (jsonMode) process.stdout.write = _origWrite;
566
+ if (jsonMode) _restoreStreams();
546
567
  }
547
568
  // 1.9.102: JSON 모드 — 구조화 출력
548
569
  if (jsonMode) {
package/lib/pure-utils.js CHANGED
@@ -1209,16 +1209,22 @@ function _detectOrphanGuards(input) {
1209
1209
  // 줄 필터는 **코드 파일**에만 건다 — 거기서만 이름이 데이터로 등장한다(우리 e2e.js 가 그랬다).
1210
1210
  // ⚠ CI 설정을 전문으로 보되 **설명용 필드는 뺀다**: `- name: TODO check:leaf 를 붙이자`,
1211
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/;
1212
+ // 한때 **허용 목록**(실행 키만 본다)으로 뒤집었다가 되돌렸다. 그건 정상 배선을 버렸다(검수 재현):
1213
+ // `matrix: { target: [check:leaf] }` + `run: npm run ${{ matrix.target }}` 에서 `target` 실행 키가
1214
+ // 아니므로 잘려 나가고, 실행 줄에는 이름이 없어 **정상 가드가 고아로 보고**된다. Azure 의 `customCommand:` 같다.
1215
+ // 이 저장소의 편향은 명시적이다 — **오탐이 이 경고를 죽인다**. 그래서 방향은 과소 보고다:
1216
+ // 사람이 읽는 설명 필드만 빼고 나머지는 그대로 본다.
1217
+ // 그 대가(측정됨): `env: { TARGET: check:x }`·`key: check:x-hash` 처럼 **데이터 값**에 이름이 있으면
1218
+ // 그것을 호출로 보고 진짜 고아를 가린다. 그건 미탐이라 감수한다.
1219
+ // ⚠ `name:` 을 **무조건** 설명으로 보면 matrix 변수명이 `name` 인 정상 배선이 잘려 나간다(검수 재현):
1220
+ // `matrix: { name: [check:leaf] }` + `run: npm run ${{ matrix.name }}`. 값이 **산문일 때만** 설명으로 본다 —
1221
+ // 목록(`[...]`)이나 공백 없는 단일 토큰은 데이터일 수 있으므로 남긴다(편향은 과소 보고).
1222
+ const _DESCRIPTIVE = /^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:\s*(.*)$/i;
1218
1223
  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);
1224
+ const m = _DESCRIPTIVE.exec(l);
1225
+ if (!m) return true;
1226
+ const v = String(m[1]).trim();
1227
+ return !v || v.startsWith('[') || !/\s/.test(v);
1222
1228
  };
1223
1229
  const _raw = _dropCommentLines(String(r.text || ''));
1224
1230
  const t = _isConfigRunner(r.file)
@@ -1244,8 +1250,13 @@ function _detectOrphanGuards(input) {
1244
1250
  // 1.36.116: 참조하는 쪽이 **도달 가능해야** 덮은 것으로 본다 — 아무도 안 부르는 스크립트가 파일을
1245
1251
  // 언급해 봐야 그 파일도 안 도는 건 마찬가지다(스크립트 쪽 #7 과 같은 결함이 파일 쪽에도 있었다).
1246
1252
  const liveScripts = Object.entries(scripts).filter(([n]) => isLive(n));
1247
- const allText = runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1248
- + liveScripts.map(([, b]) => String(b || '')).join('\n');
1253
+ // ⚠ 매칭 전에 경로 구분자를 정규화한다. Windows CI 는 `node scripts\x\check.js` 적는데
1254
+ // 후보 경로는 `/` 표기라 전체 경로가 안 맞고, basename 경계 판정은 앞의 `\` 때문에 걸러져
1255
+ // **실제로 실행되는 가드가 고아로 보고됐다**(자체 헌트 실측: POSIX 표기만 통과).
1256
+ // `scripts/./check.js` 같은 dot-segment 도 편다 — 전체 경로 비교가 정규화를 안 해서 실제 호출이
1257
+ // 고아로 보고됐다(검수 재현). basename 경계는 앞의 `/` 를 거부하므로 폴백으로도 안 잡혔다.
1258
+ const allText = (runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1259
+ + liveScripts.map(([, b]) => String(b || '')).join('\n')).replace(/\\/g, '/').split('/./').join('/');
1249
1260
  // ⚠ basename 만으로 덮였다고 보면 **동명 파일이 서로를 살린다**(검수 P2): `a/check.js` 를 부르는 배선이
1250
1261
  // `b/check.js` 까지 덮어 버린다. basename 은 그 이름이 유일할 때만 근거로 쓴다.
1251
1262
  const _baseCount = new Map();
@@ -1253,23 +1264,35 @@ function _detectOrphanGuards(input) {
1253
1264
  // ⚠ 그런데 유일성만으로 끄면 **해석 가능한 상대 호출**까지 잃는다(검수 P2): `scripts/a/run.js` 안의
1254
1265
  // `require('./check.js')` 는 `scripts/a/check.js` 를 가리키는데, 동명 파일이 있다는 이유로 그것까지 고아가 됐다.
1255
1266
  // 참조한 러너의 디렉터리를 기준으로 상대 경로를 **해석해서** 덮인 집합에 넣는다.
1267
+ // ⚠ 원문을 그대로 훑으면 **주석 속 참조**도 실행으로 계상되고(검수 재현: `// require("./a/check.js")`),
1268
+ // `..` 가 루트 밖으로 나가면 남은 조각이 엉뚱한 루트 내부 파일로 해석된다. 둘 다 막는다.
1269
+ // 구분자는 `\` 도 받는다(`require(".\\a\\check.js")`).
1256
1270
  const _resolved = new Set();
1257
1271
  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)) {
1272
+ // ⚠ 기준 디렉터리가 다르다: **CI 설정의 명령은 프로젝트 루트에서 실행**된다(`run: node ./scripts/x.js`).
1273
+ // 설정 파일 위치(`.github/workflows`) 기준으로 풀면 `.github/workflows/scripts/x.js` 돼 실제 호출을 놓친다(검수 재현).
1274
+ // 코드 파일 안의 `require('./x')` 는 그 파일 기준이 맞다.
1275
+ const dir = _isConfigRunner(r.file) ? [] : String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
1276
+ const body = _dropCommentLines(String(r.text || ''));
1277
+ for (const m of body.matchAll(/(\.{1,2}(?:[/\\][\w.-]+)+\.(?:m?js|cjs|ts|sh))/g)) {
1260
1278
  const segs = dir.slice();
1261
- for (const s of m[1].split('/')) {
1279
+ let escaped = false;
1280
+ for (const s of m[1].replace(/\\/g, '/').split('/')) {
1262
1281
  if (s === '.' || s === '') continue;
1263
- if (s === '..') segs.pop(); else segs.push(s);
1282
+ if (s === '..') { if (!segs.length) { escaped = true; break; } segs.pop(); } else segs.push(s);
1264
1283
  }
1265
- _resolved.add(segs.join('/'));
1284
+ // `scripts/./check.js` 처럼 중간 dot-segment 가 남아 있으면 여기서 이미 걸러진다(위 루프가 `.` 를 건너뛴다).
1285
+ if (!escaped) _resolved.add(segs.join('/'));
1266
1286
  }
1267
1287
  }
1268
1288
  const orphanFiles = unknownOrchestrator ? [] : scriptFiles.filter(f => {
1269
1289
  const full = String(f).replace(/\\/g, '/');
1270
1290
  if (allText.includes(full) || _resolved.has(full)) return false;
1271
1291
  const base = full.split('/').pop();
1272
- if (_baseCount.get(base) === 1 && allText.includes(base)) return false;
1292
+ // basename 폴백은 **경로의 일부**를 잡으면 안 된다: `../../outside/check.js` 우리 `scripts/check.js` 를
1293
+ // 덮어 버렸다(자체 검증에서 실측). 앞에 경로 구분자가 없는 **단독 언급**일 때만 근거로 쓴다.
1294
+ const _bnRe = new RegExp(`(^|[^\\w./\\\\-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w.-]|$)`);
1295
+ if (_baseCount.get(base) === 1 && _bnRe.test(allText)) return false;
1273
1296
  return true;
1274
1297
  });
1275
1298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.118",
3
+ "version": "1.36.120",
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
@@ -5088,8 +5088,10 @@ total++;
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;
5090
5090
  const se = r.stderr || '';
5091
- // 알려진 유출원 3종: fail() 프로브의 '✗ ' · graph 사람용 3줄(파일경로/노드수/안내)
5092
- const noiseFree = !se.includes('✗ ') && !se.includes('leerness.html →') && !/nodes · /.test(se);
5091
+ // 1.36.120: 잡음을 **열거**하는 대신 stderr 통째로 0 으로 고정한다. audit 이 `--json` 에서 stdout 만 막고
5092
+ // stderr 열어 둬서 사람용 경고가 샜고(309B), 문구가 **프로젝트 상태에 따라** 달라져
5093
+ // 같은 코드로 게이트가 통과/실패로 갈렸다(실측). 열거는 유계가 아니다 — 원인 단계에서 막았으니 여기선 0을 요구한다.
5094
+ const noiseFree = se.trim() === '';
5093
5095
  ok = pureStdout && payloadOk && noiseFree && r.status === 0;
5094
5096
  } catch {}
5095
5097
  console.log(ok ? '✓ B(1.36.88) selftest --json 계약: 리포 루트 cwd stdout 순수 JSON + stderr 잡음 0' : '✗ selftest --json 계약 실패 (stdout 오염 또는 stderr 잡음)');
@@ -11222,6 +11224,41 @@ total++;
11222
11224
  cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dm2], { cwd: dm2, encoding: 'utf8', timeout: 300000, env: ENV });
11223
11225
  const afterLen = fs.readFileSync(path.join(dm2, 'CLAUDE.md'), 'utf8').length;
11224
11226
  if (afterLen > beforeLen * 1.5) bad.push(`adapter:등급무시(${beforeLen}→${afterLen})`);
11227
+ // (검수 P3) manifest **형상** 5종을 고정한다 — `_assertStoreParsable` 은 문법만 보므로
11228
+ // `null`·`[]`·`false`·`0`·문자열이 통과한 뒤 조용히 standard 로 되돌아갔다.
11229
+ for (const shape of ['null', '[]', 'false', '0', '"minimal"']) {
11230
+ const dsh = mk(['--mode', 'minimal']);
11231
+ const shLen = fs.readFileSync(path.join(dsh, 'CLAUDE.md'), 'utf8').length;
11232
+ fs.writeFileSync(path.join(dsh, '.harness', 'manifest.json'), shape);
11233
+ const rsh = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dsh], { cwd: dsh, encoding: 'utf8', timeout: 300000, env: ENV });
11234
+ if (rsh.status === 0) bad.push(`manifest형상:${shape}에서_중단안함`);
11235
+ if (fs.readFileSync(path.join(dsh, 'CLAUDE.md'), 'utf8').length !== shLen) bad.push(`manifest형상:${shape}에서_등급뒤집힘`);
11236
+ }
11237
+ // 판별력 — 정상 객체(구형: mode 없음)는 통과해야 한다(형상 검사가 과하면 기존 설치가 깨진다)
11238
+ const dok = mk(['--mode', 'minimal']);
11239
+ fs.writeFileSync(path.join(dok, '.harness', 'manifest.json'), JSON.stringify({ installedAt: '2026-01-01' }, null, 2));
11240
+ if (cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dok], { cwd: dok, encoding: 'utf8', timeout: 300000, env: ENV }).status !== 0) bad.push('manifest형상:정상객체를_거부');
11241
+ // (검수 P2) `export`/`prompt` 는 같은 명령의 별칭이다 — 손상을 구조화해 내야 자동화가 구분한다
11242
+ const dex = mk(['--mode', 'minimal']);
11243
+ fs.writeFileSync(path.join(dex, '.harness', 'manifest.json'), 'false');
11244
+ const rex = cp.spawnSync(process.execPath, [CLI, 'export', 'claude', '--path', dex, '--json'], { cwd: dex, encoding: 'utf8', timeout: 300000, env: ENV });
11245
+ let jex = null; try { jex = JSON.parse(String(rex.stdout || '').trim()); } catch {}
11246
+ if (!jex || jex.code !== 'store_corrupt' || rex.status === 0) bad.push(`export별칭:손상코드아님(${jex && jex.code}/${rex.status})`);
11247
+ // (검수 P3) `prompt` 도 같은 별칭이다 — `export` 만 검사하면 `prompt` 를 가드 밖으로 빼는 변이가 산다.
11248
+ const dpr = mk(['--mode', 'minimal']);
11249
+ fs.writeFileSync(path.join(dpr, '.harness', 'manifest.json'), 'false');
11250
+ const rpr = cp.spawnSync(process.execPath, [CLI, 'prompt', 'claude', '--path', dpr, '--json'], { cwd: dpr, encoding: 'utf8', timeout: 300000, env: ENV });
11251
+ let jpr = null; try { jpr = JSON.parse(String(rpr.stdout || '').trim()); } catch {}
11252
+ if (!jpr || jpr.code !== 'store_corrupt' || rpr.status === 0) bad.push(`prompt별칭:손상코드아님(${jpr && jpr.code}/${rpr.status})`);
11253
+ // (검수 P3) MCP 안내가 **MCP 결과에만** 결부되는지 — 지침 파일이 새로 생기는 경로에서 검사해야
11254
+ // `results` 로 되돌리는 변이가 죽는다(기존 픽스처는 모두 init 이 끝난 뒤라 우연히 같았다).
11255
+ const dmr = path.join(sb, 'mcponly'); fs.mkdirSync(dmr, { recursive: true });
11256
+ fs.writeFileSync(path.join(dmr, 'package.json'), '{"name":"m","version":"0.1.0"}');
11257
+ fs.writeFileSync(path.join(dmr, '.mcp.json'), JSON.stringify({ mcpServers: { leerness: { command: 'node', args: ['./mine.js'] } } }, null, 2));
11258
+ const rmr = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dmr], { cwd: dmr, encoding: 'utf8', timeout: 300000, env: ENV });
11259
+ const omr = String(rmr.stdout || '') + String(rmr.stderr || '');
11260
+ if (/MCP 등록 —/.test(omr)) bad.push('MCP안내가_지침파일생성으로_발화');
11261
+ if (!/확인하지 않았습니다/.test(omr)) bad.push('MCP보존_중립문구없음');
11225
11262
  // 판별력 — standard 프로젝트에서는 adapter 가 여전히 standard 를 유지해야 한다(등급 반영이 전부를 줄이면 안 된다)
11226
11263
  const ds2 = mk();
11227
11264
  const sBefore = fs.readFileSync(path.join(ds2, 'CLAUDE.md'), 'utf8').length;
@@ -11312,6 +11349,19 @@ total++;
11312
11349
  cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc3], { cwd: dc3, encoding: 'utf8', timeout: 300000, env: ENV });
11313
11350
  let jc3 = null; try { jc3 = JSON.parse(fs.readFileSync(path.join(dc3, '.cursor', 'mcp.json'), 'utf8')); } catch {}
11314
11351
  if (!jc3 || jc3.mcpServers.leerness.command !== 'node' || !jc3.mcpServers.other) bad.push('cursor:사용자설정미보존');
11352
+ // (검수·자체 재현) cursor 는 MCP 설정이 **둘**이다. 한쪽만 우리가 썼는데 "이 도구가 직접 호출 가능" 이라고 하면
11353
+ // 정작 그 도구가 읽는 파일은 남의 설정일 수 있다 — 주장은 우리가 쓴 **파일 이름과 함께** 해야 한다.
11354
+ const dc4 = mk();
11355
+ fs.rmSync(path.join(dc4, '.mcp.json'), { force: true });
11356
+ fs.mkdirSync(path.join(dc4, '.cursor'), { recursive: true });
11357
+ fs.writeFileSync(path.join(dc4, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { leerness: { command: 'node', args: ['./mine.js'] } } }, null, 2));
11358
+ const rc4 = cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc4], { cwd: dc4, encoding: 'utf8', timeout: 300000, env: ENV });
11359
+ const oc4 = String(rc4.stdout || '') + String(rc4.stderr || '');
11360
+ if (!/\.mcp\.json 에 MCP 등록/.test(oc4)) bad.push('cursor:쓴파일_범위표기없음');
11361
+ if (/\.cursor[\\/]mcp\.json 에 MCP 등록/.test(oc4)) bad.push('cursor:보존파일까지_호출가능주장');
11362
+ if (!/확인하지 않았습니다/.test(oc4)) bad.push('cursor:보존_중립문구없음');
11363
+ let jc4 = null; try { jc4 = JSON.parse(fs.readFileSync(path.join(dc4, '.cursor', 'mcp.json'), 'utf8')); } catch {}
11364
+ if (!jc4 || jc4.mcpServers.leerness.command !== 'node') bad.push('cursor:보존실패');
11315
11365
  }
11316
11366
  // ⑦ (검수 P1) `_ours` 경계 — 우리가 만든 **정확한 형태**만 갱신하고, 모르는 키가 하나라도 붙으면 보존한다.
11317
11367
  // 그리고 명시적으로 적힌 falsy 값을 '없음' 으로 오인해 덮어쓰면 안 된다.
@@ -11556,6 +11606,8 @@ total++;
11556
11606
  if (f.severity !== 'warn') bad.push(`권고아님(${f.severity})`); // 차단으로 승격되면 안 된다
11557
11607
  if (!(f.orphanScripts || []).includes('test:orphan-guard')) bad.push('audit_대상누락');
11558
11608
  if (!f.reason) bad.push('audit_근거없음');
11609
+ // (검수 P3) 범위 메타데이터는 고아가 **있을 때도** 실려야 한다 — 0건 분기에만 넣으면 계약이 갈린다.
11610
+ if (!('monorepoOutOfScope' in f) || !('deferred' in f)) bad.push('audit_범위메타누락');
11559
11611
  }
11560
11612
  }
11561
11613
  // 대조군 — 고아가 없으면 이 발견이 나오면 안 된다(오탐 0)
@@ -11765,6 +11817,112 @@ total++;
11765
11817
  const cr = ci.runners.find(r => /tools[\\/]ci\.js$/.test(r.file)) || {};
11766
11818
  if (!cr.unconditional) bad.push('CI직접호출_출처_유실');
11767
11819
  if (PU._detectOrphanGuards(ci).orphanScripts.length) bad.push('CI직접호출인데_고아로_오탐');
11820
+ // (검수 P2) CMD 스텝의 `REM` 주석에서 제품 진입점을 러너로 승격시키면 자기참조가 되살아난다
11821
+ const d6c = path.join(sb4, 'rem');
11822
+ fs.mkdirSync(path.join(d6c, 'bin'), { recursive: true });
11823
+ fs.mkdirSync(path.join(d6c, '.github', 'workflows'), { recursive: true });
11824
+ fs.writeFileSync(path.join(d6c, 'package.json'), JSON.stringify({ name: 'r', version: '0.1.0', bin: { r: 'bin/phantom.js' },
11825
+ scripts: { test: 'node t.js', 'check:leaf': 'node l.js' } }));
11826
+ fs.writeFileSync(path.join(d6c, '.github', 'workflows', 'ci.yml'),
11827
+ 'jobs:\n a:\n steps:\n - shell: cmd\n run: |\n REM debug only: node .\\bin\\phantom.js\n echo harmless\n - run: npm test\n');
11828
+ fs.writeFileSync(path.join(d6c, 'bin', 'phantom.js'), 'const x = "npm run check:leaf";\n');
11829
+ const remInp = AUD._collectGuardInputs(d6c);
11830
+ if (remInp.runners.some(r => /phantom\.js$/.test(r.file))) bad.push('CMD주석에서_진입점_수집');
11831
+ if (!PU._detectOrphanGuards(remInp).orphanScripts.includes('check:leaf')) bad.push('CMD주석이_고아를_덮음');
11832
+ // (검수 P3) `REM` 만 검사하면 `::`·`@REM` 변이가 산다 — 주석 표기별로 잰다.
11833
+ for (const [tag, mark] of [['::', '::'], ['@REM', '@REM']]) {
11834
+ fs.writeFileSync(path.join(d6c, '.github', 'workflows', 'ci.yml'),
11835
+ `jobs:\n a:\n steps:\n - shell: cmd\n run: |\n ${mark} debug: node .\\bin\\phantom.js\n echo harmless\n - run: npm test\n`);
11836
+ const ri = AUD._collectGuardInputs(d6c);
11837
+ if (ri.runners.some(r => /phantom\.js$/.test(r.file))) bad.push(`CMD주석(${tag})에서_진입점_수집`);
11838
+ }
11839
+ // (검수 P3) bare 진입점(`main: index.js`)의 설명 필터 — 픽스처가 없어 되돌리는 변이가 살았다.
11840
+ const dbe = path.join(sb4, 'bare');
11841
+ fs.mkdirSync(path.join(dbe, '.github', 'workflows'), { recursive: true });
11842
+ fs.writeFileSync(path.join(dbe, 'package.json'), JSON.stringify({ name: 'b', version: '0.1.0', main: 'index.js',
11843
+ scripts: { test: 'node t.js', 'check:leaf': 'node l.js' } }));
11844
+ fs.writeFileSync(path.join(dbe, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - name: archive index.js\n run: npm test\n');
11845
+ fs.writeFileSync(path.join(dbe, 'index.js'), 'sh("npm run check:leaf")\n');
11846
+ const beInp = AUD._collectGuardInputs(dbe);
11847
+ if (beInp.runners.some(r => /index\.js$/.test(r.file))) bad.push('설명필드가_bare진입점을_승격');
11848
+ if (!PU._detectOrphanGuards(beInp).orphanScripts.includes('check:leaf')) bad.push('설명필드_승격이_고아를_덮음');
11849
+ // 판별력 — 실제 실행이면 수집돼야 한다
11850
+ fs.writeFileSync(path.join(dbe, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: node index.js --guard\n');
11851
+ const beInp2 = AUD._collectGuardInputs(dbe);
11852
+ if (!beInp2.runners.some(r => /index\.js$/.test(r.file))) bad.push('실제실행인데_bare진입점_미수집');
11853
+ // (검수 P3) 0바이트 package.json 을 **실제로 수집**해서 계상되는지 — 지금까지는 readErrors 를 주입만 했다.
11854
+ const dz = path.join(sb4, 'zero'); fs.mkdirSync(dz, { recursive: true });
11855
+ fs.writeFileSync(path.join(dz, 'package.json'), '');
11856
+ const zInp = AUD._collectGuardInputs(dz);
11857
+ if (!zInp.readErrors) bad.push('0바이트_package.json_미계상');
11858
+ if (!/수집 불완전/.test(PU._detectOrphanGuards(zInp).reason || '')) bad.push('0바이트_근거미표기');
11859
+ // (검수 P2) 상대 경로 해석: 주석 속 참조는 무시, 루트 밖은 내부 파일로 오인 금지, 역슬래시도 해석
11860
+ const rel1 = PU._detectOrphanGuards({
11861
+ packageScripts: { test: 'node scripts/run-a.js && node scripts/run-b.js' },
11862
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11863
+ { file: 'scripts/run-a.js', text: '// require("./a/check.js")\n', viaScript: 'test', viaScripts: ['test'] },
11864
+ { file: 'scripts/run-b.js', text: 'require("./b/check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
11865
+ scriptFiles: ['scripts/a/check.js', 'scripts/b/check.js'],
11866
+ });
11867
+ if (!rel1.orphanFiles.includes('scripts/a/check.js')) bad.push('주석속_상대참조를_실행으로');
11868
+ if (rel1.orphanFiles.includes('scripts/b/check.js')) bad.push('실제_상대참조를_고아로');
11869
+ const rel2 = PU._detectOrphanGuards({
11870
+ packageScripts: { test: 'node scripts/run.js' },
11871
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11872
+ { file: 'scripts/run.js', text: 'require("../../outside/check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
11873
+ scriptFiles: ['scripts/check.js'],
11874
+ });
11875
+ if (!rel2.orphanFiles.includes('scripts/check.js')) bad.push('루트밖_참조가_내부파일을_덮음');
11876
+ // ⚠ 루트 이탈(`../../../…`) 가드는 **여기서 관측할 수 없다** — 측정해 보고 단언을 뺐다:
11877
+ // `allText` 의 전체 경로 비교가 부분 문자열이라, 이탈 경로도 우리 후보를 접미로 포함하면 어차피 덮인다.
11878
+ // 경계 판정을 전체 경로에까지 걸면 흔한 `./scripts/x.js` 표기가 깨지므로 그렇게 하지 않는다.
11879
+ // 가드 자체는 남긴다(해석 결과가 접미가 아닌 경우를 위한 방어) — 다만 공허한 단언을 두지는 않는다.
11880
+ const relIn = PU._detectOrphanGuards({
11881
+ packageScripts: { test: 'node scripts/a/run.js' },
11882
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11883
+ { file: 'scripts/a/run.js', text: 'require("../../scripts/./check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
11884
+ scriptFiles: ['scripts/check.js'],
11885
+ });
11886
+ if (relIn.orphanFiles.length) bad.push(`루트내_dot세그먼트_경로_오탐(${relIn.orphanFiles.join(',')})`);
11887
+ // (검수 P3) 역슬래시 상대경로 — 픽스처가 없어 `[/\\]` 를 `/` 로 줄이는 변이가 살았다.
11888
+ const relBs = PU._detectOrphanGuards({
11889
+ packageScripts: { test: 'node scripts/a/run.js' },
11890
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11891
+ { file: 'scripts/a/run.js', text: 'require(".\\check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
11892
+ scriptFiles: ['scripts/a/check.js', 'scripts/b/check.js'],
11893
+ });
11894
+ if (relBs.orphanFiles.includes('scripts/a/check.js')) bad.push('역슬래시_상대경로_미해석');
11895
+ // (검수) CI 설정의 명령은 **프로젝트 루트**에서 돈다 — 설정 파일 위치 기준으로 풀면 실제 호출을 놓친다.
11896
+ const relRoot = PU._detectOrphanGuards({
11897
+ packageScripts: { test: 'node t.js' },
11898
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11899
+ text: 'jobs:\n a:\n steps:\n - run: node ./scripts/deep/check.js\n - run: npm test\n' }],
11900
+ scriptFiles: ['scripts/deep/check.js'],
11901
+ });
11902
+ if (relRoot.orphanFiles.length) bad.push(`CI상대경로_루트기준아님(${relRoot.orphanFiles.join(',')})`);
11903
+ // dot-segment 가 낀 전체 경로도 정규화돼야 한다
11904
+ const relDot = PU._detectOrphanGuards({
11905
+ packageScripts: { test: 'node run.sh' },
11906
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11907
+ { file: 'run.sh', text: 'node scripts/./check.js\n', viaScript: 'test', viaScripts: ['test'] }],
11908
+ scriptFiles: ['scripts/check.js'],
11909
+ });
11910
+ if (relDot.orphanFiles.length) bad.push(`dot세그먼트_경로_오탐(${relDot.orphanFiles.join(',')})`);
11911
+ // (자체 헌트) Windows CI 는 `node scripts\x\check.js` 로 적는다. 후보 경로는 `/` 표기라
11912
+ // 전체 경로가 안 맞고 basename 경계 판정도 앞의 `\` 때문에 걸러져 **정상 배선이 고아로 보고**됐다.
11913
+ for (const [label, ref] of [['posix', 'scripts/x/check.js'], ['win', 'scripts\\x\\check.js'], ['win-dot', '.\\scripts\\x\\check.js']]) {
11914
+ const rw = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js' },
11915
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11916
+ text: `jobs:\n a:\n steps:\n - run: node ${ref}\n - run: npm test\n` }],
11917
+ scriptFiles: ['scripts/x/check.js'] });
11918
+ if (rw.orphanFiles.includes('scripts/x/check.js')) bad.push(`경로표기_오탐:${label}`);
11919
+ }
11920
+ // 판별력 — 루트의 동명 파일을 부르는 것은 하위 디렉터리 후보를 덮지 않는다(경계 판정의 목적)
11921
+ const rw2 = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js' },
11922
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11923
+ text: 'jobs:\n a:\n steps:\n - run: node .\\check.js\n - run: npm test\n' }],
11924
+ scriptFiles: ['scripts/x/check.js', 'scripts/y/check.js'] });
11925
+ if (rw2.orphanFiles.length !== 2) bad.push(`경로경계_과다커버(${rw2.orphanFiles.join(',')})`);
11768
11926
  } finally { try { fs.rmSync(sb4, { recursive: true, force: true }); } catch {} }
11769
11927
  }
11770
11928
  // ⑰ (검수 P2) 와일드카드가 있다고 실행은 아니다 — `echo "test:*"` 는 아무것도 안 돌린다.
@@ -11838,6 +11996,13 @@ total++;
11838
11996
  ['산문만', 'jobs:\n a:\n steps:\n - name: TODO check:leaf 붙이기\n run: echo skip\n', true],
11839
11997
  ['name 뒤 실제 run', 'jobs:\n a:\n steps:\n - name: Run check:leaf\n run: npm run check:leaf\n', false],
11840
11998
  ['matrix', 'jobs:\n a:\n strategy:\n matrix:\n script: [check:leaf]\n steps:\n - run: npm run ${{ matrix.script }}\n', false],
11999
+ // ⚠ 한때 **허용 목록**(실행 키만 본다)으로 뒤집었다가 되돌렸다 — 아래 두 케이스가 그때 고아로 뒤집혔다(오탐).
12000
+ // 이 저장소의 편향은 "오탐이 경고를 죽인다" 이므로 방향은 과소 보고다. 이 두 줄이 그 결정을 고정한다.
12001
+ ['matrix 변수명(실행키 아님)', 'jobs:\n a:\n strategy:\n matrix:\n target: [check:leaf]\n steps:\n - run: npm run ${{ matrix.target }}\n', false],
12002
+ ['Azure customCommand', 'steps:\n- task: Npm@1\n inputs:\n command: custom\n customCommand: run check:leaf\n', false],
12003
+ // (검수) `name:` 을 **무조건** 설명으로 보면 matrix 변수명이 `name` 인 정상 배선이 잘린다.
12004
+ // 값이 산문일 때만 설명이다 — 목록·단일 토큰은 데이터일 수 있으므로 남긴다.
12005
+ ['matrix 변수명이 name', 'jobs:\n a:\n strategy:\n matrix:\n name: [check:leaf]\n steps:\n - run: npm run ${{ matrix.name }}\n', false],
11841
12006
  ]) {
11842
12007
  const rr = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
11843
12008
  runners: [{ file: '.github/workflows/ci.yml', text, viaScript: null, viaScripts: [] }], scriptFiles: [] });