leerness 1.36.125 → 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.125 하네스를 사용합니다. 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.125는 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.125는 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.125 릴리스 태그 이력** (수백 라운드) · _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.125: 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.125';
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') 시 호스트 프로세스 오염.
@@ -3748,7 +3748,8 @@ function _selfTestCases() {
3748
3748
  } catch {} // 심링크 생성 권한 없는 환경 → 소스 불변식만으로 판정(false-BLOCK 회피)
3749
3749
  if (_linked) {
3750
3750
  try {
3751
- const _rep = _migrateWorkspaceDir(_st, { dryRun: true });
3751
+ // 1.36.126: CLI fail-closed 지만 복사 구현의 안전 속성은 계속 시험한다(내부 통로).
3752
+ const _rep = _migrateWorkspaceDir(_st, { dryRun: true, allowUnsupported: true });
3752
3753
  const _skipped = _rep.skippedFiles.some(f => /^loop\b/.test(f) && f.includes('symlink'));
3753
3754
  const _notFollowed = !_rep.copiedFiles.some(f => /^loop[\\/]/.test(f));
3754
3755
  symlinkGuard = symlinkGuard && _skipped && _notFollowed;
@@ -4261,7 +4262,7 @@ function _selfTestCases() {
4261
4262
  { name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 1, 1).level === 'very-high' && _cadenceAssessment(3, 1, 1).level === 'high' && _cadenceAssessment(1, 1, 1).level === 'moderate' && _cadenceAssessment(0.2, 1, 1).level === 'healthy' && _cadenceAssessment(7, 1, 1).recommendation.length > 0; } },
4262
4263
  { name: 'UR-0084: _withLock 획득/재진입/해제 + maxWaitMs 하드닝(10s) 행위 (1.9.375)', run: () => { if (typeof _withLock !== 'function') return false; const src = read(__filename); const hardened = /maxWaitMs = opts\.maxWaitMs \|\| 10000/.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lock_')); try { const target = path.join(tmp, 'f.md'); let reentrant = false; const lockSeen = _withLock(target, () => { const exists = fs.existsSync(target + '.lock'); reentrant = (_withLock(target, () => 42) === 42); return exists; }); const cleaned = !fs.existsSync(target + '.lock'); return hardened && lockSeen === true && reentrant === true && cleaned; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
4263
4264
  { name: 'UR-0073 Phase D: _teamDeployGate 이중 게이트 (dry-run 기본/env 게이트/실행) 행위 (1.9.376)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamDeployGate !== 'function' || m._teamDeployGate !== _teamDeployGate) return false; const team = { id: 'd', deployCommand: 'echo hi' }; const noCmd = _teamDeployGate({ id: 'x' }, { yes: true, envOn: true }).mode === 'no-command'; const dry = _teamDeployGate(team, { yes: false, envOn: true }).mode === 'dry-run'; const gated = _teamDeployGate(team, { yes: true, envOn: false }).mode === 'gated'; const exec = _teamDeployGate(team, { yes: true, envOn: true }).mode === 'execute'; return noCmd && dry && gated && exec; } },
4264
- { name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && g.includes('마이그레이션 안내') && wrapperThin; } },
4265
+ { name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); /* 1.36.126 (T-0107): 제목 리터럴('마이그레이션 안내')을 붙잡던 자리 — 그 절이 바로 **AI 를 죽은 사본으로 안내**하고 있었다. 제목이 아니라 계약을 건다: ① `.leerness` 상황을 다루는 절이 여전히 있고 ② 그 절이 "우선 사용/가야 함" 같은 **지시**를 하지 않는다(생성 결과 문자열에 대한 행위 단언 — 소스 자기참조가 아니다). */ const guideSafe = /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && guideSafe && wrapperThin; } },
4265
4266
  { name: 'UR-0073: team MCP 도구 2종(read-only) 정의 + dispatch 와이어 (1.9.378)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const tl = tools.find(t => t.name === 'leerness_team_list'); const tp = tools.find(t => t.name === 'leerness_team_preview'); const defsOk = tl && tl.requiredTier === 'read-only' && tp && tp.requiredTier === 'read-only' && tp.inputSchema.required && tp.inputSchema.required.includes('id'); const wired = src.includes("case " + "'leerness_team_list':") && src.includes("case " + "'leerness_team_preview':") && /cliArgs = \['team', 'list'/.test(src) && /cliArgs = \['team', 'preview'/.test(src); return !!defsOk && wired; } },
4266
4267
  { name: 'UR-0025 심화: pulse 렌더 코어 분리 — _memorySurface + _renderPulseLine 행위 (1.9.379)', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && wired; } },
4267
4268
  { name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.harness/plan.md'); const harnessUses = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 1; const migUses = (read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 2; return listOk && harnessUses && migUses; } },
@@ -6692,6 +6693,101 @@ function _selfTestCases() {
6692
6693
  } finally { if (saved !== undefined) process.env.LEERNESS_MCP_TIMEOUT_MS = saved; else delete process.env.LEERNESS_MCP_TIMEOUT_MS; }
6693
6694
  } },
6694
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
+ } },
6752
+ // 1.36.126 (T-0107): `.leerness` 를 살아 있는 저장소로 **주장하지 않는다**.
6753
+ // ⚠ 이 케이스는 **행위검사만** 한다(자기 소스 읽기 금지 — 1.36.84 메타가드). 상수와 하드코딩 수의
6754
+ // 모순 금지 불변식은 e2e V 블록이 소스를 읽어 건다. 여기서는 실제 함수를 불러 결과를 본다.
6755
+ { name: '1.36.126 (T-0107): 워크스페이스 대체 디렉터리 — 해석기가 살아 있는 저장소를 말함 + 무시 고지 + 대조군 침묵 (행위검사)', run: () => {
6756
+ // 행위 ① 해석기는 env·마커가 있어도 살아 있는 저장소를 말한다(예전엔 `.leerness` 를 말해 미초기화 오판을 냈다)
6757
+ const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws126-'));
6758
+ const savedEnv = process.env.LEERNESS_WORKSPACE_DIR;
6759
+ try {
6760
+ fs.mkdirSync(path.join(arena, '.leerness'), { recursive: true });
6761
+ fs.writeFileSync(path.join(arena, '.leerness', 'MIGRATED_FROM_HARNESS'), 'x');
6762
+ fs.mkdirSync(path.join(arena, '.harness'), { recursive: true });
6763
+ fs.writeFileSync(path.join(arena, '.harness', 'HARNESS_VERSION'), VERSION);
6764
+ process.env.LEERNESS_WORKSPACE_DIR = '.leerness';
6765
+ if (_workspaceDirName(arena) !== '.harness') return false;
6766
+ if (!_workspaceDirAbs(arena).endsWith('.harness')) return false;
6767
+ // 마커가 있어도 초기화된 프로젝트로 인식해야 한다(예전엔 여기서 "미초기화" 오판이 났다)
6768
+ if (_isInitialized(arena) !== true) return false;
6769
+ // 행위 ② 무시한 설정은 **말한다** — env·마커 각각, 그리고 아무것도 없으면 침묵(오탐 0)
6770
+ const both = _workspaceDirAltIgnored(arena);
6771
+ if (!both || both.env !== '.leerness' || both.marker !== true || both.effective !== '.harness') return false;
6772
+ delete process.env.LEERNESS_WORKSPACE_DIR;
6773
+ const markerOnly = _workspaceDirAltIgnored(arena);
6774
+ if (!markerOnly || markerOnly.env !== null || markerOnly.marker !== true) return false;
6775
+ const clean = path.join(arena, 'clean'); fs.mkdirSync(clean, { recursive: true });
6776
+ if (_workspaceDirAltIgnored(clean) !== null) return false; // 대조군 — 평범한 프로젝트는 조용하다
6777
+ // 행위 ③ 코어가 실제로 거부하는가 — CLI 인자 없이 함수를 직접 불러 판정(문자열이 아니라 결과로)
6778
+ const blocked = _migrateWorkspaceDir(arena, { dryRun: true });
6779
+ if (blocked.blocked !== true || blocked.blockedReason !== 'workspace-dir-alt-unsupported') return false;
6780
+ if ((blocked.copiedFiles || []).length !== 0) return false;
6781
+ // 그리고 내부 시험 통로는 여전히 복사 구현을 태운다(안전 속성이 도달 불가로 썩지 않게)
6782
+ const seam = _migrateWorkspaceDir(arena, { dryRun: true, allowUnsupported: true });
6783
+ if (seam.blocked === true) return false;
6784
+ return true;
6785
+ } catch { return false; }
6786
+ finally {
6787
+ if (savedEnv !== undefined) process.env.LEERNESS_WORKSPACE_DIR = savedEnv; else delete process.env.LEERNESS_WORKSPACE_DIR;
6788
+ try { fs.rmSync(arena, { recursive: true, force: true }); } catch {}
6789
+ }
6790
+ } },
6695
6791
  { name: '1.36.125 (검수 P2): selftest 측정 통제 — 통제 목록 실재 + 실제 삭제 + 무시 사실 보고(조용한 무시 금지)', run: () => {
6696
6792
  const must = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_TIMEOUT_MS'];
6697
6793
  if (!must.every(k => _SELFTEST_CONTROLLED_ENV.includes(k))) return false;
@@ -9201,7 +9297,7 @@ function commandsCmd(root) {
9201
9297
  { cmd: 'migrate [path] [--dry-run]', desc: '업데이트 마이그레이션', descEn: 'update migration' },
9202
9298
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트', descEn: 'self-update' },
9203
9299
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)', descEn: 'adaptive wakeup interval (1.9.210)' },
9204
- { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 (1.9.211)', descEn: 'workspace directory (1.9.211)' },
9300
+ { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 확인 — 이 빌드는 .harness 만 사용 (migrate-workspace-dir 는 1.36.126 부터 거부, T-0107)', descEn: 'show the workspace directory — this build uses .harness only (migrate-workspace-dir refuses since 1.36.126, T-0107)' },
9205
9301
  { cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)', descEn: 'detect a parent leerness above + gated asset adoption (1.30.2~3)' },
9206
9302
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)', descEn: 'infer intent + scope (1.9.213)' },
9207
9303
  { cmd: 'constraints list|check|add', desc: '플랫폼/API 제약 (1.9.208)', descEn: 'platform/API constraints (1.9.208)' },
@@ -9756,20 +9852,49 @@ function _computeAdaptiveInterval(root) {
9756
9852
  // - LEERNESS_WORKSPACE_DIR env 또는 .leerness/MARKER 존재 시 .leerness 사용
9757
9853
  // - leerness migrate-workspace-dir CLI: .harness → .leerness copy + AI reference guide 생성
9758
9854
  // - AI 참조 가이드: WHERE_TO_FIND.md (디렉토리 구조 + 파일별 역할 + 마이그레이션 history)
9759
- function _workspaceDirName(root) {
9760
- // 1) env override
9761
- if (process.env.LEERNESS_WORKSPACE_DIR) {
9762
- const v = process.env.LEERNESS_WORKSPACE_DIR.trim();
9763
- if (v) return v;
9764
- }
9765
- // 2) .leerness 마이그레이션 마커 존재 .leerness 우선
9855
+ // 1.36.126 (T-0107 실측): 이 해석기는 **소수파**였다. 실측 —
9856
+ // 해석기 소비처 9곳 vs `.harness` 경로 구성 252곳(bin 215 · lib 37) + 안내/템플릿 문자열 634곳.
9857
+ // 그래서 env 나 마커로 `.leerness` 를 고르게 해도 **쓰기는 전부 `.harness` 로 갔다**(실측:
9858
+ // migrate `task add` 가 `.leerness` 에 안 남음. env 를 켜도 동일). 결과는 두 가지 해악이었다:
9859
+ // ① `_isInitialized` 만 `.leerness` 를 봐서 정상 프로젝트를 "미초기화" 로 오판(실측 selftest 4건 실패)
9860
+ // ② `.leerness` 는 마이그레이션 시점에 얼어붙은 **죽은 사본**이 되고 WHERE_TO_FIND.md 가 AI 를 그리로 안내
9861
+ // 소수파를 따르는 것은 "다르게 동작" 아니라 **틀리게 동작**이었다. 살아 있는 저장소를 그대로 말한다.
9862
+ // ⚠ 이 상수를 true 로 바꾸려면 하드코딩 252곳을 먼저 없애야 한다 — selftest 가 그 순서를 강제한다.
9863
+ const WORKSPACE_DIR_ALT_SUPPORTED = false;
9864
+ // 거부 사유로 대는 숫자는 **실행 시** 센다 — 손으로 적으면 다음 편집에서 곧바로 낡는다(검수 P2 실측: 252→255).
9865
+ // 거부 경로에서만 불리므로 비용은 문제되지 않는다. 세다가 실패하면 숫자를 지어내지 말고 null 로 둔다.
9866
+ function _measureWorkspaceHardcoding() {
9867
+ const out = { resolverConsumers: null, hardcodedPathSites: null, hardcodedTextMentions: null };
9766
9868
  try {
9767
- if (root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))) {
9768
- return '.leerness';
9869
+ let src = read(__filename);
9870
+ const libDir = path.join(__dirname, '..', 'lib');
9871
+ for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) src += '\n' + read(path.join(libDir, f));
9872
+ out.hardcodedPathSites = (src.match(/path\.join\([^)]*'\.harness'/g) || []).length;
9873
+ out.hardcodedTextMentions = (src.match(/\.harness\/[A-Za-z0-9_.-]+/g) || []).length;
9874
+ out.resolverConsumers = (src.match(/_workspaceDir(?:Name|Abs)\(/g) || []).length;
9875
+ } catch { /* 셀 수 없으면 null — 지어내지 않는다 */ }
9876
+ return out;
9877
+ }
9878
+ // 사용자가 켠 설정이 **안 먹는다는 사실**은 반드시 말해야 한다(조용한 무시 금지 — 1.36.125 와 같은 원칙).
9879
+ function _workspaceDirAltIgnored(root) {
9880
+ if (WORKSPACE_DIR_ALT_SUPPORTED) return null;
9881
+ const env = (process.env.LEERNESS_WORKSPACE_DIR || '').trim();
9882
+ let marker = false;
9883
+ try { marker = !!(root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))); } catch {}
9884
+ if (!env && !marker) return null;
9885
+ return { env: env || null, marker, effective: '.harness' };
9886
+ }
9887
+ function _workspaceDirName(root) {
9888
+ if (WORKSPACE_DIR_ALT_SUPPORTED) {
9889
+ if (process.env.LEERNESS_WORKSPACE_DIR) {
9890
+ const v = process.env.LEERNESS_WORKSPACE_DIR.trim();
9891
+ if (v) return v;
9769
9892
  }
9770
- } catch {}
9771
- // 3) default
9772
- return '.harness';
9893
+ try {
9894
+ if (root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))) return '.leerness';
9895
+ } catch {}
9896
+ }
9897
+ return '.harness'; // 살아 있는 저장소 — 위 주석의 실측대로 이 빌드에서는 항상 여기다
9773
9898
  }
9774
9899
  function _workspaceDirAbs(root) {
9775
9900
  return path.join(root, _workspaceDirName(root));
@@ -9788,7 +9913,12 @@ function _findParentWorkspace(root, opts = {}) {
9788
9913
  const hasHarness = exists(path.join(cur, '.harness'));
9789
9914
  const hasLeerness = exists(path.join(cur, '.leerness'));
9790
9915
  if (hasHarness || hasLeerness) {
9791
- const wd = (hasLeerness && exists(path.join(cur, '.leerness', 'MIGRATED_FROM_HARNESS'))) ? '.leerness' : (hasHarness ? '.harness' : '.leerness');
9916
+ // 1.36.126 (T-0107): 여기는 해석기를 쓰고 **자체 로직으로 `.leerness` 우선**하고 있었다
9917
+ // 부모가 옛 migrate 를 돌렸다면 그 `.leerness` 는 죽은 사본이므로, 자식이 **낡은 자산**
9918
+ // (design-system·reuse-map·skills)을 물려받는다. 살아 있는 저장소가 있으면 그쪽이 먼저다.
9919
+ // `.leerness` 만 있는 경우(사용자가 믿고 `.harness` 를 지운 상태)는 부모 탐지 자체를 잃는 것보다
9920
+ // 읽어 주는 편이 낫다 — 읽기 전용 자산 탐지이고, 그 프로젝트는 audit 이 미초기화로 따로 알린다.
9921
+ const wd = hasHarness ? '.harness' : '.leerness';
9792
9922
  const wsAbs = path.join(cur, wd);
9793
9923
  const assets = {
9794
9924
  designSystem: exists(path.join(wsAbs, 'design-system.md')),
@@ -9821,6 +9951,24 @@ function _migrateWorkspaceDir(root, opts = {}) {
9821
9951
  errors: [],
9822
9952
  dryRun
9823
9953
  };
9954
+ // 1.36.126 (T-0107): 이 명령은 **배달할 수 없는 것을 배달했다고 말하고 있었다**. 실측 —
9955
+ // 복사(58파일)는 성공하고 "✓ 마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 찍는데,
9956
+ // 그 직후 `task add` 는 여전히 `.harness` 에 쓴다. 결과물은 마이그레이션이 아니라 **얼어붙은 사본**이고,
9957
+ // 같이 만들어지는 WHERE_TO_FIND.md 는 AI 를 그 낡은 상태로 안내한다 — 우리가 막으려는 바로 그 실패다.
9958
+ // 그래서 복사를 하지 않는다. 반쯤 된 상태를 만들 바에는 **아무것도 만들지 않는 편이 낫다**(fail-closed).
9959
+ // 되살리려면 하드코딩 252곳을 해석기로 바꾸는 것이 선행 조건이다(T-0107).
9960
+ // ⚠ `allowUnsupported` 는 **내부 시험 전용 통로**다. 복사 구현(심링크 미추종 등 1.36.33 안전 속성)을
9961
+ // 도달 불가로 만들면 그 보호가 조용히 썩는다 — T-0107 이 풀리면 다시 살아날 코드이므로 계속 시험한다.
9962
+ // CLI 경로는 이 옵션을 절대 넘기지 않는다(e2e 가 호출부를 열거해 단언한다).
9963
+ if (!WORKSPACE_DIR_ALT_SUPPORTED && opts.allowUnsupported !== true) {
9964
+ report.blocked = true;
9965
+ report.blockedReason = 'workspace-dir-alt-unsupported';
9966
+ // ⚠ 이 숫자를 손으로 적어 뒀더니 곧바로 낡았다(적을 땐 252, 이 라운드 편집 후 실제 255 — 검수 P2).
9967
+ // "실측" 이라 말하면서 낡은 수를 보여 주는 것은 이 라운드가 고치는 결함과 같은 클래스다. 실행 시 센다.
9968
+ report.measured = _measureWorkspaceHardcoding();
9969
+ report.errors.push('이 빌드는 .leerness 를 살아 있는 저장소로 쓰지 못합니다 — 복사하면 죽은 사본이 됩니다');
9970
+ return report;
9971
+ }
9824
9972
  if (!report.srcExists) {
9825
9973
  report.errors.push('source .harness not found');
9826
9974
  return report;
@@ -10638,6 +10786,7 @@ function workspaceDirCmd(root, sub) {
10638
10786
  const isTty = process.stdout && process.stdout.isTTY;
10639
10787
  const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
10640
10788
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
10789
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
10641
10790
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
10642
10791
 
10643
10792
  if (!sub || sub === 'get') {
@@ -10646,19 +10795,37 @@ function workspaceDirCmd(root, sub) {
10646
10795
  const hasHarness = exists(path.join(root, '.harness'));
10647
10796
  const hasLeerness = exists(path.join(root, '.leerness'));
10648
10797
  const migrated = exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'));
10649
- const result = { current: dirName, abs: dirAbs, hasHarness, hasLeerness, migrated };
10798
+ // 1.36.126 (T-0107): `migrated: true` 그대로 보여 주면 "옮겨졌다" 는 **거짓 인상**을 준다 —
10799
+ // 실제로는 그 뒤 모든 쓰기가 .harness 로 간다. 마커의 의미를 사실대로 옮겨 적는다.
10800
+ const ignored = _workspaceDirAltIgnored(root);
10801
+ // 1.36.126 (검수 P2): 마커가 지워진 사본도 잡되, 남의 `.leerness` 는 건드리지 않는다 —
10802
+ // 두 번째 신호는 **우리 생성물의 서명**(가이드 헤더). audit 과 같은 술어를 쓴다.
10803
+ let ourGuide = false;
10804
+ try { ourGuide = exists(path.join(root, '.leerness', 'WHERE_TO_FIND.md'))
10805
+ && /by leerness \d+\.\d+\.\d+/.test(read(path.join(root, '.leerness', 'WHERE_TO_FIND.md'))); } catch {}
10806
+ const staleCopy = (migrated || ourGuide) && dirName === '.harness';
10807
+ const result = { current: dirName, abs: dirAbs, hasHarness, hasLeerness, migrated,
10808
+ altSupported: WORKSPACE_DIR_ALT_SUPPORTED, ignoredSetting: ignored, staleCopy };
10650
10809
  if (has('--json')) { log(JSON.stringify(result, null, 2)); return; }
10651
- log(cyan(`# leerness workspace-dir (1.9.211)`));
10652
- log(` 현재 디렉토리: ${grn(dirName + '/')}`);
10810
+ log(cyan(`# leerness workspace-dir (1.36.126)`));
10811
+ log(` 현재 디렉토리: ${grn(dirName + '/')} ${dim('(읽기·쓰기가 실제로 일어나는 곳)')}`);
10653
10812
  log(` abs: ${dirAbs}`);
10654
10813
  log(` .harness 존재: ${hasHarness ? '✓' : '✗'}`);
10655
10814
  log(` .leerness 존재: ${hasLeerness ? '✓' : '✗'}`);
10656
- log(` 마이그레이션 완료: ${migrated ? '✓' : '✗'}`);
10815
+ log(` .leerness 마커: ${migrated ? '✓' : '✗'}`);
10657
10816
  log('');
10658
- if (hasHarness && !hasLeerness) {
10659
- log(dim(` 마이그레이션: leerness migrate-workspace-dir`));
10660
- } else if (migrated) {
10661
- log(dim(` → AI 참조 가이드: leerness workspace-dir guide`));
10817
+ if (ignored) {
10818
+ log(yel(` ${ignored.env ? `LEERNESS_WORKSPACE_DIR=${ignored.env}` : '.leerness/MIGRATED_FROM_HARNESS 마커'} 는 이 빌드에서 적용되지 않습니다.`));
10819
+ log(` 실제 저장소는 ${grn(ignored.effective + '/')} 입니다 설정을 켜 두어도 동작은 바뀌지 않습니다(T-0107).`);
10820
+ }
10821
+ if (staleCopy) {
10822
+ log('');
10823
+ log(yel(` ⚠ .leerness/ 는 **죽은 사본**입니다 — 마이그레이션 시점에 멈춰 있고 이후 변경이 반영되지 않습니다.`));
10824
+ log(` .leerness/WHERE_TO_FIND.md 를 AI 에게 주지 마세요(낡은 상태로 안내합니다).`);
10825
+ log(dim(` 안전하게 지우려면: 내용을 .harness 와 비교해 필요한 것이 없는지 확인한 뒤 직접 삭제하세요.`));
10826
+ log(dim(` (leerness 는 사용자 데이터를 대신 지우지 않습니다)`));
10827
+ } else if (hasHarness && !hasLeerness) {
10828
+ log(dim(` → 이 빌드는 .harness 만 사용합니다(디렉터리 이름 변경은 T-0107 이후).`));
10662
10829
  }
10663
10830
  return;
10664
10831
  }
@@ -10801,7 +10968,29 @@ function migrateWorkspaceDirCmd(root) {
10801
10968
  const force = has('--force');
10802
10969
  const report = _migrateWorkspaceDir(root, { dryRun, force });
10803
10970
 
10804
- if (has('--json')) { log(JSON.stringify(report, null, 2)); return; }
10971
+ if (has('--json')) { log(JSON.stringify(report, null, 2)); if (report.blocked) process.exitCode = 1; return; }
10972
+ // 1.36.126: 못 하는 일을 했다고 말하지 않는다 — 사유와 **실측 근거**를 함께 준다.
10973
+ if (report.blocked) {
10974
+ const m = report.measured || {};
10975
+ log(cyan(`# leerness migrate-workspace-dir (1.36.126)`));
10976
+ log('');
10977
+ log(red(` ✗ 이 빌드는 .leerness 로 옮길 수 없습니다 — 복사만 하면 죽은 사본이 됩니다.`));
10978
+ log('');
10979
+ if (m.hardcodedPathSites != null) {
10980
+ log(` 왜: leerness 내부에서 워크스페이스 경로를 만드는 곳 ${m.hardcodedPathSites}곳이 '.harness' 를 그대로 쓰고,`);
10981
+ log(` 경로 해석기를 거치는 곳은 ${m.resolverConsumers}곳뿐입니다(안내 문구 ${m.hardcodedTextMentions}곳은 별도).`);
10982
+ } else {
10983
+ log(` 왜: leerness 내부 대부분이 '.harness' 경로를 그대로 쓰고 있어, 복사해도 그쪽으로는 아무것도 쓰이지 않습니다.`);
10984
+ log(` (이 실행에서는 소스를 세지 못해 정확한 수를 대지 못합니다 — 판정 자체는 바뀌지 않습니다.)`);
10985
+ }
10986
+ log(` 그래서 복사한 뒤에도 모든 쓰기는 .harness 로 갑니다 — .leerness 는 복사 시점에 멈춘 사본이 되고,`);
10987
+ log(` 함께 만들어지던 WHERE_TO_FIND.md 가 AI 를 그 낡은 상태로 안내합니다.`);
10988
+ log('');
10989
+ log(dim(` 지금 할 수 있는 것: 그대로 .harness 를 쓰시면 됩니다(기능 차이 없음 — 디렉터리 이름만 다릅니다).`));
10990
+ log(dim(` 진행 상황: 이 제약은 T-0107 로 등록돼 있습니다 — 해결되면 이 명령이 다시 동작합니다.`));
10991
+ process.exitCode = 1;
10992
+ return;
10993
+ }
10805
10994
  log(cyan(`# leerness migrate-workspace-dir (1.9.211)${dryRun ? ' [DRY-RUN]' : ''}`));
10806
10995
  log(` src: ${report.src} ${report.srcExists ? '✓' : red('✗ 없음')}`);
10807
10996
  log(` dst: ${report.dst} ${report.dstExists ? yel('⚠ 이미 존재') : '(생성)'}`);
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,58 @@ 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
+ }
293
+ } catch {}
294
+ // 1.36.126 (T-0107): 예전 버전의 `migrate-workspace-dir` 는 `.harness` 를 `.leerness` 로 **복사만** 하고
295
+ // "마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 말했다. 실제로는 그 뒤 모든 쓰기가
296
+ // `.harness` 로 가므로 `.leerness` 는 복사 시점에 얼어붙는다. 문제는 디스크 낭비가 아니라
297
+ // 같이 만들어진 `WHERE_TO_FIND.md` 가 **AI 를 그 낡은 상태로 안내**한다는 것이다 — 우리가 막으려는 실패 그 자체.
298
+ // 1.36.126 부터 그 명령은 거부하지만, **이미 당한 프로젝트**는 스스로 알 수 없다. 여기서 알린다.
299
+ // ⚠ 지우지는 않는다 — 사용자 데이터를 대신 삭제하지 않는다는 원칙(편의 명령이 P1 5건을 냈던 전례).
300
+ try {
301
+ const staleMarker = path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS');
302
+ const guide = path.join(root, '.leerness', 'WHERE_TO_FIND.md');
303
+ // 1.36.126 (검수 P2): 마커 하나만 믿으면 **마커가 지워진 사본**을 놓친다(수동 복구·복사 실패 등).
304
+ // 반대로 남의 도구가 만든 `.leerness` 를 "죽은 사본" 이라 부르면 오탐이 이 경고를 죽인다.
305
+ // 그래서 두 번째 신호는 **우리 생성물의 서명**으로 좁힌다 — 가이드 파일의 우리 헤더 문구.
306
+ // (파일명만 보면 남의 것과 구별할 수 없다. 내용 서명이 있어야 우리 것이라고 말할 수 있다.)
307
+ let ourGuide = false;
308
+ try { ourGuide = exists(guide) && /by leerness \d+\.\d+\.\d+/.test(read(guide)); } catch {}
309
+ if (exists(staleMarker) || ourGuide) {
310
+ let ageDays = null;
311
+ try {
312
+ const mt = exists(staleMarker) ? fs.statSync(staleMarker).mtimeMs : fs.statSync(guide).mtimeMs;
313
+ const live = fs.statSync(path.join(root, '.harness', 'progress-tracker.md')).mtimeMs;
314
+ ageDays = Math.max(0, Math.round((live - mt) / 86400000));
315
+ } catch {}
316
+ warnings++;
317
+ warn(`.leerness/ 는 죽은 사본입니다 — 실제 저장소는 .harness 이고, 이 사본은 복사 시점에 멈춰 있습니다`);
318
+ log(` 근거: ${exists(staleMarker) ? '.leerness/MIGRATED_FROM_HARNESS 마커' : '.leerness/WHERE_TO_FIND.md 가 leerness 생성물'} + 이 빌드는 .leerness 를 읽지 않음 (leerness workspace-dir)`);
319
+ if (ageDays != null) log(` 낡음 정도: 실제 저장소가 사본보다 약 ${ageDays}일 앞서 있습니다`);
320
+ if (exists(guide)) log(` ⚠ .leerness/WHERE_TO_FIND.md 를 AI 에게 주지 마세요 — 낡은 상태로 안내합니다`);
321
+ log(` → 내용을 .harness 와 비교해 필요한 것이 없는지 확인한 뒤 직접 삭제하세요 (leerness 는 대신 지우지 않습니다)`);
322
+ _finding('stale_workspace_copy', 'warn', '.leerness is a frozen copy; the live store is .harness', {
323
+ markerPresent: exists(staleMarker), guidePresent: exists(guide), guideIsOurs: ourGuide, liveAheadDays: ageDays,
324
+ });
325
+ }
266
326
  } catch {}
267
327
  const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4,})\./gm)).map(m => m[1]);
268
328
  const rows = readProgressRows(root);
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
@@ -1579,14 +1620,18 @@ function _renderWorkspaceReferenceGuide(dirName, version, generatedAt) {
1579
1620
  lines.push(`| API 제약 catalog | \`${dirName}/platform-constraints.json\` (1.9.208) |`);
1580
1621
  lines.push(`| 자동 wakeup 권장 간격 | \`${dirName}/wakeup-history.json\` (1.9.210) |`);
1581
1622
  lines.push('');
1582
- lines.push(`## 🔄 마이그레이션 안내`);
1623
+ // 1.36.126 (T-0107, 검수 P1): 이 절은 AI 에게 **"마커가 있으면 `.leerness` 를 쓰라"** 고 지시하고 있었다.
1624
+ // 메커니즘(해석기)은 고쳐 놓고 지시문을 남기면, 그 지시문이 같은 버그를 계속 재생산한다.
1625
+ // 실측: `.leerness` 는 옛 migrate 가 만든 **얼어붙은 사본**이고 이후 모든 쓰기는 `.harness` 로 간다.
1626
+ lines.push(`## 🔄 \`.leerness\` 디렉터리를 보셨다면`);
1583
1627
  lines.push('');
1584
- lines.push(`이 워크스페이스는 \`.harness\` \`.leerness\` 마이그레이션되었을 수 있습니다.`);
1585
- lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 존재 마이그레이션 완료, \`.leerness\` 우선 사용`);
1586
- lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 존재 \`.leerness/\` 가야 함`);
1587
- lines.push(`- 양쪽 모두 없음 → 기본 \`.harness\` 사용 중`);
1628
+ lines.push(`이 빌드가 읽고 쓰는 워크스페이스는 **\`${dirName}/\` 하나뿐**입니다.`);
1629
+ lines.push(`- \`.leerness/\` 있어도 **읽지 마십시오** — 옛 \`migrate-workspace-dir\` 만든 사본이며 그 시점에 멈춰 있습니다.`);
1630
+ lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 마커도 마찬가지입니다 빌드에서는 아무 효력이 없습니다.`);
1631
+ lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 안내문 역시 낡았습니다.`);
1632
+ lines.push(`- \`migrate-workspace-dir\` 는 1.36.126 부터 사유를 설명하고 거부합니다 (T-0107).`);
1588
1633
  lines.push('');
1589
- lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 자동으로 올바른 디렉토리를 사용합니다.`);
1634
+ lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 항상 살아 있는 워크스페이스를 씁니다.`);
1590
1635
  lines.push('');
1591
1636
  return lines.join('\n');
1592
1637
  }
@@ -1809,6 +1854,9 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1809
1854
  }
1810
1855
 
1811
1856
  module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
1857
+ // 1.36.127 (검수 P1): 경로 접기 규칙은 **한 곳**이어야 한다 — 수집기가 따로 구현했다가 중간 dot-segment 에서
1858
+ // 갈라져 `ownEntry` 판정이 어긋났다(같은 파일을 다른 이름으로 봄).
1859
+ _normPath,
1812
1860
  _parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
1813
1861
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
1814
1862
  _isSecretKey, redactSecrets, hasCredentialMarker, compareVer, parseHarnessVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.125",
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;
@@ -5482,7 +5482,11 @@ total++;
5482
5482
  const pu = require(path.resolve(__dirname, '..', 'lib', 'pure-utils'));
5483
5483
  const g = pu._renderWorkspaceReferenceGuide('.harness', '1.2.3', '2026-01-01T00:00:00.000Z');
5484
5484
  ok = typeof g === 'string' && g.includes('.harness/progress-tracker.md') && g.includes('by leerness 1.2.3')
5485
- && g.includes('## 📁 디렉토리 구조 (핵심)') && g.includes('## 🧭 자주 묻는 위치') && g.includes('## 🔄 마이그레이션 안내') && g.includes('plan.md');
5485
+ && g.includes('## 📁 디렉토리 구조 (핵심)') && g.includes('## 🧭 자주 묻는 위치') && g.includes('plan.md')
5486
+ // 1.36.126 (T-0107): 여기도 제목 리터럴('## 🔄 마이그레이션 안내')을 붙잡고 있었다 — selftest 쪽만 고치고
5487
+ // 이 **복제본**을 놓쳐 게이트가 깨졌다(같은 단언이 두 표면에 있으면 둘 다 고쳐야 한다).
5488
+ // 제목이 아니라 계약을 건다: 가이드는 `.leerness` 를 다루되 그리로 **가라고 지시하지 않는다**.
5489
+ && /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g);
5486
5490
  } catch {}
5487
5491
  console.log(ok ? '✓ B(1.9.377) UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 (빌더 동작 + 핵심 섹션)' : '✗ workspace guide 빌더 실패');
5488
5492
  if (!ok) failed++;
@@ -7160,8 +7164,12 @@ total++;
7160
7164
  // ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
7161
7165
  // 1.36.87: doctor 는 내부에서 selftest 를 돌린다 — 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
7162
7166
  // 제한을 올린 대신 종료코드도 함께 단언한다 — 타임아웃(status=null)이 출력 매칭만으로 통과하면 안 된다(codex 26차 #11).
7163
- const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 120000, cwd: d });
7164
- 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 });
7165
7173
  const docEn = out(docEnR), docKo = out(docKoR);
7166
7174
  const doctorOk = docEnR.status === 0 && docKoR.status === 0
7167
7175
  && /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
@@ -12311,14 +12319,29 @@ total++;
12311
12319
  // *다른 프로젝트용* CI 를 생성하는 코드라 `npm run test:smoke` 같은 문자열을 담고 있고,
12312
12320
  // CI 가 그 파일을 직접 실행하므로 러너로 수집된다(정상 프로젝트에선 그게 맞는 판단이다).
12313
12321
  // 그래서 기준값은 **우리 CLI 소스를 뺀 입력**으로 잰다 — 탐지기가 망가지면 여기서 잡힌다.
12314
- // ⚠ `test:core`·`test:smoke` 실제로 어떤 러너도 부르지 않는다(ci.yml 은 `test:fast` 만 돌린다).
12315
- // 나중에 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
+ // 실제 수집 입력에 **가짜 잠든 가드를 심어** 잡히는지 함께 본다(계측이 살아 있다는 증거).
12316
12327
  const selfNoBin = Object.assign({}, selfInp, { runners: selfInp.runners.filter(r => !/bin[\\/]leerness\.js$/.test(r.file)) });
12317
12328
  const selfBase = PU._detectOrphanGuards(selfNoBin);
12318
- for (const expect of ['test:smoke', 'test:core']) {
12319
- if (!selfBase.orphanScripts.includes(expect)) bad.push(`자기기준:${expect}가고아목록에없음(${selfBase.orphanScripts.join(',') || '없음'})`);
12320
- }
12329
+ if (selfBase.orphanScripts.length) bad.push(`자기기준:이제0이어야(${selfBase.orphanScripts.join(',')})`);
12321
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
+ }
12322
12345
  // 그리고 **불변식**으로 고정한다: 이 테스트 파일 자체가 판정을 바꾸면 안 된다.
12323
12346
  // 리터럴 쪼개기로 막으면 다음에 누가 이름을 적는 순간 또 뚫린다 — 값이 아니라 성질을 단언한다.
12324
12347
  const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfNoBin,
@@ -12411,6 +12434,280 @@ total++;
12411
12434
  if (!ok) failed++;
12412
12435
  }
12413
12436
 
12437
+ // 1.36.126 (T-0107): `migrate-workspace-dir` 는 **배달할 수 없는 것을 배달했다고** 말하고 있었다.
12438
+ // 실측: 58파일을 복사하고 "✓ 마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라 찍은 뒤,
12439
+ // 바로 이어지는 `task add` 는 여전히 `.harness` 에 썼다(env 를 켜도 동일). 즉 `.leerness` 는 복사 시점에
12440
+ // 얼어붙은 사본이 되고, 같이 생성되는 WHERE_TO_FIND.md 가 **AI 를 그 낡은 상태로 안내**한다.
12441
+ // 배선 비용 실측: 경로 구성 252곳 + 안내 문자열 634곳 vs 해석기 소비처 9곳 → 이번 라운드에 배선 불가.
12442
+ // 그래서 ① 함정을 만들지 않고(fail-closed) ② 이미 당한 프로젝트를 알리고 ③ 평범한 프로젝트는 조용하다.
12443
+ total++;
12444
+ {
12445
+ let ok = false; const bad = []; const dbg = {};
12446
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws126-'));
12447
+ const envW = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
12448
+ for (const k of ['LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envW[k];
12449
+ const mkP = (name) => {
12450
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
12451
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"w","version":"0.1.0"}');
12452
+ const r = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 600000, env: envW });
12453
+ if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
12454
+ return d;
12455
+ };
12456
+ const RW = (d, args, extra) => cp.spawnSync(process.execPath, [CLI, ...args],
12457
+ { cwd: d, encoding: 'utf8', timeout: 600000, env: Object.assign({}, envW, extra || {}), maxBuffer: 32 * 1024 * 1024 });
12458
+ try {
12459
+ // ① 함정을 만들지 않는다 — 복사 0 · 명시적 실패 · 사유와 실측 근거를 함께 말한다
12460
+ const A = mkP('A');
12461
+ const m = RW(A, ['migrate-workspace-dir', '--apply', '--path', A]);
12462
+ if (fs.existsSync(path.join(A, '.leerness'))) bad.push('①.leerness가생성됨');
12463
+ if (m.status === 0) bad.push('①조용한성공(exit 0)');
12464
+ if (!/죽은 사본|옮길 수 없습니다/.test(m.stdout || '')) bad.push('①사유없음');
12465
+ // ⚠ 여기에 리터럴(252)을 박아 뒀더니 다음 편집에서 곧바로 틀렸다 — 사유의 숫자는 **실측과 같은지**로 본다.
12466
+ // 실제 대조는 아래 ⑥(제품 보고 == e2e 실측). 여기서는 "숫자를 아예 안 대는" 경우만 잡는다.
12467
+ if (!/\d{2,}곳이 '\.harness'/.test(m.stdout || '')) bad.push('①실측근거없음');
12468
+ const mj = RW(A, ['migrate-workspace-dir', '--apply', '--path', A, '--json']);
12469
+ let mjj = null; try { mjj = JSON.parse(mj.stdout); } catch { bad.push('①JSON아님'); }
12470
+ if (mjj && mjj.blocked !== true) bad.push('①json:blocked아님');
12471
+ if (mjj && mjj.blockedReason !== 'workspace-dir-alt-unsupported') bad.push(`①json:reason=${mjj && mjj.blockedReason}`);
12472
+ if (mj.status === 0) bad.push('①json:exit 0');
12473
+ if (mjj && Array.isArray(mjj.copiedFiles) && mjj.copiedFiles.length) bad.push('①json:복사보고');
12474
+ dbg.A = { exit: m.status, created: fs.existsSync(path.join(A, '.leerness')) };
12475
+
12476
+ // ② 이미 당한 프로젝트 — 옛 버전이 남긴 상태를 재현(옛 바이너리가 없으므로 손으로 만든다)
12477
+ const B = mkP('B');
12478
+ fs.mkdirSync(path.join(B, '.leerness'), { recursive: true });
12479
+ fs.copyFileSync(path.join(B, '.harness', 'progress-tracker.md'), path.join(B, '.leerness', 'progress-tracker.md'));
12480
+ fs.writeFileSync(path.join(B, '.leerness', 'MIGRATED_FROM_HARNESS'), 'migrated');
12481
+ fs.writeFileSync(path.join(B, '.leerness', 'WHERE_TO_FIND.md'), '# 어디서 찾나\n');
12482
+ RW(B, ['task', 'add', '사본 이후 작업', '--path', B, '--json']);
12483
+ const au = RW(B, ['audit', '--path', B, '--json']);
12484
+ let aj = null; try { aj = JSON.parse(au.stdout); } catch { bad.push('②auditJSON아님'); }
12485
+ const fnd = aj && Array.isArray(aj.findings) ? aj.findings.find(x => x.kind === 'stale_workspace_copy') : null;
12486
+ if (!fnd) bad.push('②죽은사본_미탐지');
12487
+ else if (fnd.guidePresent !== true) bad.push('②가이드파일_미보고');
12488
+ const wdj = RW(B, ['workspace-dir', 'get', '--path', B, '--json']);
12489
+ let wj = null; try { wj = JSON.parse(wdj.stdout); } catch { bad.push('②wdJSON아님'); }
12490
+ if (wj && wj.current !== '.harness') bad.push(`②현재=${wj.current}`);
12491
+ if (wj && wj.staleCopy !== true) bad.push('②staleCopy아님');
12492
+ if (wj && wj.altSupported !== false) bad.push('②altSupported참');
12493
+ if (!/죽은 사본/.test(RW(B, ['workspace-dir', 'get', '--path', B]).stdout || '')) bad.push('②사람용경고없음');
12494
+ // 마커·env 가 있어도 **쓰기는 정상**이어야 한다(예전엔 _isInitialized 가 .leerness 를 봐서 "미초기화" 거부했다)
12495
+ const w = RW(B, ['task', 'add', '마커 있어도 쓰기', '--path', B, '--json'], { LEERNESS_WORKSPACE_DIR: '.leerness' });
12496
+ if (w.status !== 0) bad.push(`②마커상태쓰기실패(${w.status})`);
12497
+ if (!fs.readFileSync(path.join(B, '.harness', 'progress-tracker.md'), 'utf8').includes('마커 있어도 쓰기')) bad.push('②쓰기가.harness에없음');
12498
+ dbg.B = { finding: !!fnd, staleCopy: wj && wj.staleCopy, writeExit: w.status };
12499
+
12500
+ // ③ 대조군 — 평범한 프로젝트는 조용하다(오탐이 이 경고를 죽인다)
12501
+ const C = mkP('C');
12502
+ const au2 = RW(C, ['audit', '--path', C, '--json']);
12503
+ let aj2 = null; try { aj2 = JSON.parse(au2.stdout); } catch { bad.push('③auditJSON아님'); }
12504
+ const n2 = aj2 && Array.isArray(aj2.findings) ? aj2.findings.filter(x => x.kind === 'stale_workspace_copy').length : -1;
12505
+ if (n2 !== 0) bad.push(`③대조군오탐 ${n2}건`);
12506
+ const wd2 = RW(C, ['workspace-dir', 'get', '--path', C]).stdout || '';
12507
+ if (/죽은 사본|적용되지 않습니다/.test(wd2)) bad.push('③대조군에경고문구');
12508
+ dbg.C = { falsePositives: n2 };
12509
+
12510
+ // ③-c 마커가 지워진 사본도 잡는다(검수 P2) + **남의 `.leerness` 는 건드리지 않는다**(오탐이 경고를 죽인다)
12511
+ {
12512
+ const D = mkP('D'); // 마커 없음 · 우리 가이드 파일만 남음
12513
+ fs.mkdirSync(path.join(D, '.leerness'), { recursive: true });
12514
+ fs.writeFileSync(path.join(D, '.leerness', 'WHERE_TO_FIND.md'), 'Generated: 2026-01-01T00:00:00.000Z by leerness 1.36.100\n');
12515
+ const ad = RW(D, ['audit', '--path', D, '--json']);
12516
+ let adj = null; try { adj = JSON.parse(ad.stdout); } catch { bad.push('③c auditJSON아님'); }
12517
+ const fd = adj && Array.isArray(adj.findings) ? adj.findings.find(x => x.kind === 'stale_workspace_copy') : null;
12518
+ if (!fd) bad.push('③c마커없는사본_미탐지');
12519
+ else if (fd.markerPresent !== false || fd.guideIsOurs !== true) bad.push(`③c근거오류(${fd.markerPresent}/${fd.guideIsOurs})`);
12520
+
12521
+ const E = mkP('E'); // 남의 도구가 만든 .leerness — 우리 서명 없음
12522
+ fs.mkdirSync(path.join(E, '.leerness'), { recursive: true });
12523
+ fs.writeFileSync(path.join(E, '.leerness', 'WHERE_TO_FIND.md'), '# someone else\n');
12524
+ fs.writeFileSync(path.join(E, '.leerness', 'config.yml'), 'a: 1\n');
12525
+ const ae = RW(E, ['audit', '--path', E, '--json']);
12526
+ let aej = null; try { aej = JSON.parse(ae.stdout); } catch { bad.push('③c(대조)auditJSON아님'); }
12527
+ const fe = aej && Array.isArray(aej.findings) ? aej.findings.filter(x => x.kind === 'stale_workspace_copy').length : -1;
12528
+ if (fe !== 0) bad.push(`③c남의.leerness오탐 ${fe}건`);
12529
+ dbg.D = { detected: !!fd, foreignFalsePositives: fe };
12530
+ }
12531
+
12532
+ // ③-b 부모 자산 — 부모에 죽은 사본이 있어도 **살아 있는 저장소**의 자산을 물려받아야 한다.
12533
+ // (`_findParentWorkspace` 는 해석기를 안 쓰고 자체 로직으로 `.leerness` 를 우선하고 있었다 —
12534
+ // 같은 클래스가 다른 자리에 남아 있던 경우. 자식이 낡은 design-system 을 물려받는다.)
12535
+ {
12536
+ const P = mkP('P'); // 부모
12537
+ fs.writeFileSync(path.join(P, '.harness', 'design-system.md'), '# LIVE\n');
12538
+ fs.mkdirSync(path.join(P, '.leerness'), { recursive: true });
12539
+ fs.writeFileSync(path.join(P, '.leerness', 'MIGRATED_FROM_HARNESS'), 'x');
12540
+ fs.writeFileSync(path.join(P, '.leerness', 'design-system.md'), '# STALE\n');
12541
+ const kid = path.join(P, 'kid'); fs.mkdirSync(kid, { recursive: true });
12542
+ fs.writeFileSync(path.join(kid, 'package.json'), '{"name":"kid","version":"0.1.0"}');
12543
+ const pw = RW(kid, ['parent', 'detect', '--path', kid, '--json']);
12544
+ let pj = null; try { pj = JSON.parse(pw.stdout); } catch { bad.push('③b parentJSON아님'); }
12545
+ const par = pj && pj.parent;
12546
+ if (!par) bad.push('③b부모미탐지(셋업실패면 아래 단언이 공허해진다)');
12547
+ else {
12548
+ if (par.workspaceDir !== '.harness') bad.push(`③b부모워크스페이스=${par.workspaceDir}`);
12549
+ if (!String(par.workspaceAbs || '').endsWith('.harness')) bad.push('③b부모abs가.harness아님');
12550
+ if (par.assets && par.assets.designSystem !== true) bad.push('③b살아있는자산미탐지(계측붕괴)');
12551
+ // 판별: 실제로 읽히는 파일이 LIVE 인가(경로만 맞고 내용이 STALE 이면 의미 없다)
12552
+ const body = fs.readFileSync(path.join(par.workspaceAbs, 'design-system.md'), 'utf8');
12553
+ if (!/LIVE/.test(body)) bad.push('③b읽히는자산이 STALE');
12554
+ }
12555
+ dbg.P = { workspaceDir: par && par.workspaceDir, depth: par && par.depth };
12556
+ }
12557
+
12558
+ // ④ 순서 강제 — 하드코딩이 남아 있는 한 "지원함" 이라 말할 수 없다.
12559
+ // (selftest 는 자기 소스를 못 읽는다 — 1.36.84 메타가드. 그래서 이 불변식은 여기서 건다.)
12560
+ const binSrc = fs.readFileSync(CLI, 'utf8');
12561
+ const libDir = path.resolve(path.dirname(CLI), '..', 'lib'); // CLI 에서 유도 — 실행 위치에 의존하지 않는다
12562
+ let libSrc = '';
12563
+ for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) libSrc += '\n' + fs.readFileSync(path.join(libDir, f), 'utf8');
12564
+ const hard = ((binSrc + libSrc).match(/path\.join\([^)]*'\.harness'/g) || []).length;
12565
+ if (hard < 50) bad.push(`④하드코딩계측붕괴(${hard})`); // 0건이면 측정이 깨진 것 — 공허통과 금지
12566
+ const claims = /const WORKSPACE_DIR_ALT_SUPPORTED = (true|false);/.exec(binSrc);
12567
+ if (!claims) bad.push('④지원상수없음');
12568
+ else if (claims[1] === 'true' && hard > 0) bad.push(`④하드코딩 ${hard}곳인데 지원함이라 주장`);
12569
+ // ⑤ 내부 시험 통로가 **제품 경로**로 새지 않는다.
12570
+ // (검수 P3) 정확 카운트는 안전한 리팩터만으로 깨진다 — 개수가 아니라 **어디에 있는가**로 본다:
12571
+ // selftest 케이스 영역(_selfTestCases) 밖에 이 옵션을 넘기는 곳이 있으면 제품 경로 누출이다.
12572
+ const selfStart = binSrc.indexOf('function _selfTestCases(');
12573
+ const selfEnd = selfStart < 0 ? -1 : binSrc.indexOf('\nfunction ', selfStart + 10);
12574
+ if (selfStart < 0 || selfEnd < 0) bad.push('⑤selftest영역경계못찾음');
12575
+ else {
12576
+ const outside = binSrc.slice(0, selfStart) + binSrc.slice(selfEnd);
12577
+ const inside = binSrc.slice(selfStart, selfEnd);
12578
+ // "언급" 이 아니라 **넘기는 것**만 센다 — 주석·선언(`opts.allowUnsupported !== true`)은 호출이 아니다.
12579
+ const passes = (s) => (s.match(/allowUnsupported\s*:/g) || []).length;
12580
+ const leaked = passes(outside);
12581
+ if (leaked > 0) bad.push(`⑤시험통로가 제품 경로에 ${leaked}곳`);
12582
+ if (passes(inside) < 1) bad.push('⑤시험통로를 아무도 안 태움(보호가 썩는다)');
12583
+ dbg.seam = { leaked, insideUses: passes(inside) };
12584
+ }
12585
+ dbg.src = { hard, claims: claims && claims[1] };
12586
+ // ⑥ 거부 사유의 숫자는 **실행 시 측정**이어야 한다(검수 P2: 손으로 적은 252가 곧 255로 낡았다).
12587
+ // 제품이 보고한 수와 여기서 센 수가 같아야 한다 — 다르면 둘 중 하나가 거짓말이다.
12588
+ if (mjj && mjj.measured) {
12589
+ if (mjj.measured.hardcodedPathSites !== hard) bad.push(`⑥보고=${mjj.measured.hardcodedPathSites} vs 실측=${hard}`);
12590
+ if (!(mjj.measured.resolverConsumers > 0)) bad.push('⑥해석기 소비처 0(계측붕괴)');
12591
+ } else bad.push('⑥measured 없음');
12592
+ ok = bad.length === 0;
12593
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
12594
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
12595
+ console.log(ok ? `✓ V(1.36.126/T-0107) 워크스페이스 대체 디렉터리: migrate 가 죽은 사본을 만들지 않고 명시적으로 실패(사유+실측) · 이미 당한 프로젝트를 audit/workspace-dir 가 알림 · 마커·env 가 있어도 쓰기 정상(.harness) · 대조군 오탐 0 ${JSON.stringify(dbg)}`
12596
+ : '✗ 1.36.126 워크스페이스 대체 디렉터리 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
12597
+ if (!ok) failed++;
12598
+ }
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
+
12414
12711
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
12415
12712
  if (failed > 0) process.exit(1);
12416
12713