leerness 1.36.83 → 1.36.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.84 — 2026-07-29 — 탐지 대신 **동결** · 검수 25회전 커버리지 부족 5건 상환
4
+
5
+ 1.36.82~83 에서 자기참조 공허 가드를 **탐지**하려 세 라운드 연속 탐지기를 고쳤고, 매번 외부 검수가 새 우회를 찾아냈다(직접 호출 · `fs.readFileSync` · `indexOf` · 정규식 `.test` · alias/재할당/구조분해 · 자기제외 표식 복사 · UI 문자열 · **읽는 파일의 무관한 위치에서 만족되는 리터럴**). 마지막 형태는 존재 검사로 **원리적으로 판별 불가**다.
6
+
7
+ **그래서 탐지를 포기하고 형태를 동결했다.** "selftest 가 자기 소스를 읽어 검증하는 출현 횟수는 늘지 않는다"(현재값 고정, 줄이는 방향으로만). 우회를 쫓는 대신 부패할 수 있는 형태가 늘지 못하게 막는 쪽이 이긴다 — 탐지는 heuristic 이라 지고 계수는 syntactic 이라 진다.
8
+
9
+ **이 가드가 보장하는 것과 보장하지 않는 것** (검수가 반례로 좁혀 준 대로, 과장하지 않는다):
10
+ - **보장**: `_selfTestCases` 영역에서 **지원 철자 2종**의 lexical 출현 횟수 상한. 들여쓰기 변경·기존 케이스 내부 추가도 여기 걸린다(둘 다 1차 구현에서는 뚫렸고 출현-횟수 방식으로 바꿔 닫았다). 영역 파싱이 깨지면 **fail-closed**(검수 H2: 영역 안 주석에 줄머리 `function` 한 줄만 넣으면 스캔 범위가 306KB→108B 로 줄고 카운트 0 이 되어 통과했다 — 파싱된 케이스 수와 실제 수가 다르면 실패로 전환).
11
+ - **미보장**: 별칭 · 동적 프로퍼티 접근 · `require` 체인 · 구조분해 · 배열 순회를 통한 간접 호출. 주석/문자열 안의 같은 철자도 계수된다(오탐 방향 — 실제로 이 보장 범위를 적은 주석 자신이 계수돼 가드가 자기를 막았고, split-literal 로 회피했다).
12
+ - "어떤 형태든 막는다"는 lexical 방식으로 달성 불가다. 그렇게 적지 않는다.
13
+
14
+ **검수 25회전이 남긴 커버리지 부족 5건 상환** — 각각 변이로 증명하고 독립 재현했다:
15
+ - env-family 를 3종 테이블로 확장했더니 검수가 **여전히 과적합**임을 보였다(그 셋만 허용하도록 좁혀도 통과). bare `.env` + **열거되지 않은 임의 suffix**(`.env.qa7`)로 접두 규칙 자체를 검사하고, 음성 대조(`.environment`/`.envx`)를 추가.
16
+ - **`[].every` 빈 배열 공허참이 새 수리안에 그대로 들어와 있었다** — 세 라운드 내내 고쳐온 클래스가 방금 만든 코드에 재발했다. `length > 0` 명시로 차단.
17
+ - `fs.readFileSync` spy 로 eager read 탐지(1MB 초과 파일을 읽고 버리는 회귀) · lesson 파서 행위 검증(동치 정규식 리팩터에는 false-BLOCK 하지 않음) · `--done-when` ko/en **정확값** + child exit 검사(합집합만 보면 언어 매핑이 뒤바뀌어도 통과했다) · e2e constraints 를 `--json` + exit 검사로 교체.
18
+
19
+ **고치지 않은 것** (검수 지적 중 남긴 것 — 다음 라운드 과제):
20
+ - `readFileSync` spy 가 fd 기반 읽기(`openSync`+`readSync`, 모듈 로드 시 `bind` 캡처)를 놓치고, `read()` 를 fd 로 바꾸는 동치 리팩터에는 false-BLOCK 한다.
21
+ - lesson 정규식 파서가 bin 에 3곳 중복 — 정규 파서만 고쳐도 `brainstorm` 같은 소비 경로는 무방비.
22
+ - 동결 래칫의 미보장 형태(위 목록).
23
+
24
+ - 검증: selftest 338 · e2e 406/406 · 동결 6방향(우회 4형태 차단 · 행위검사 통과 · fail-closed) · M4 4방향 변이 · 임계값 경계 시험.
25
+
3
26
  ## 1.36.83 — 2026-07-28 — 자기참조 가드 부채 상환 (탐지 가능 범위 12 → 0) · 소스 문자열 검사를 행위 검사로
4
27
 
5
28
  1.36.82 가 `baseline 12` 로 **유예**했던 자기참조 소스가드를 갚고 baseline 을 **0 으로 조였다**(유예 수치를 남기면 그 안에서 조용히 썩는다).
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.83 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.84 하네스를 사용합니다. 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.83는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.84는 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.83는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.83 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.84 릴리스 태그 이력** (수백 라운드) · _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.83: 2026-07-28
238
+ Last synced by Leerness v1.36.84: 2026-07-29
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.83';
37
+ const VERSION = '1.36.84';
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') 시 호스트 프로세스 오염.
@@ -3907,11 +3907,15 @@ function _selfTestCases() {
3907
3907
  { name: 'count drift 수정: _countDatedBlocks 코드펜스(템플릿) 제외 (UR-0053 1.9.320; memory count canonical 전환 후 legacy parser 보존)', run: () => { const f = _countDatedBlocks; const withTpl = '# D\n\n```md\n### 2026-01-01 — Decision 제목\n- Decision:\n```\n\n### 2026-06-04 — 실제\n- Decision: 실제\n'; const c1 = f(withTpl) === 1; const c0 = f('```md\n### 2026-01-01 — x\n```\n') === 0; const c2 = f('### 2026-01-01 — A\n### 2026-02-02 — B\n') === 2; return typeof f === 'function' && c1 && c0 && c2; } },
3908
3908
  { name: 'decision/lesson 필드 파싱: 빈 필드가 다음 줄로 안 샘 ([ \\t]* 사용) (UR-0053 1.9.321)', run: () => { const block = '### 2026-06-05 — X\n- Decision: X\n- Reason: r\n- Alternatives: \n- Impact: 보안\n'; const alt = block.match(/- Alternatives:[ \t]*(.+)/); const imp = block.match(/- Impact:[ \t]*(.+)/); const altNoBleed = !alt || !/Impact/.test(alt[1]); const impOk = !!imp && imp[1].trim() === '보안'; /* 1.36.82: 파서가 lib/pure-utils.js 로 이동했는데 가드는 bin 을 검사해 자기 줄만 매칭했다 —
3909
3909
  구현 파일을 읽고, 소스 문자열만이 아니라 **실제 파서 동작**으로도 확인한다(빈 필드가 다음 줄을 먹지 않는가). */
3910
- const puSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'pure-utils.js'));
3911
- const fixedOk = puSrc.includes('- Alternatives:[ \\t]*(.+)') && puSrc.includes('- Lesson:[ \\t]*(.+)') && puSrc.includes('- Impact:[ \\t]*(.+)');
3910
+ /* 1.36.84 (검수 Medium#6): decision 행위검사였고 lesson 은 puSrc.includes 3개(소스 문자열)로만 봤다.
3911
+ 형태는 양방향으로 틀린다 (1) 패턴이 파일의 다른 위치(주석 등) 남아 있으면 실제 정규식을
3912
+ `- Lesson:\s*(.+)` 로 바꿔 빈 Lesson 이 다음 Tag 줄을 통째로 먹게 만들어도 통과했고(실측: 스위트 전체 초록),
3913
+ (2) 반대로 `[^\S\n]*` 같은 **동치 리팩터**는 정확 리터럴이 사라져 false-BLOCK 했다.
3914
+ → 소스 문자열 검사를 걷어내고 빈 Lesson + 정상 Tag 입력의 파싱 결과(text 는 빈 문자열, tag 는 보존)로 직접 검증한다. */
3912
3915
  const parsed = require('../lib/pure-utils')._extractDecisionBlocks ? true : false;
3913
3916
  const behav = (() => { const objs = require('../lib/pure-utils')._decisionsFromMd('### 2026-06-05 — X\n- Decision: X\n- Reason: r\n- Alternatives: \n- Impact: 보안\n'); return objs.length === 1 && String(objs[0].alternatives || '').trim() === '' && String(objs[0].impact || '').trim() === '보안'; })();
3914
- return altNoBleed && impOk && fixedOk && parsed && behav; } },
3917
+ const lessonBehav = (() => { const ls = require('../lib/pure-utils')._parseLessonEntries('### 2026-06-05\n- Lesson: \n- Tag: t\n'); return ls.length === 1 && ls[0].text === '' && ls[0].tag === 't'; })();
3918
+ return altNoBleed && impOk && parsed && behav && lessonBehav; } },
3915
3919
  { name: 'MCP handler 통합: _mcpToCliArgs 단일 함수 + mcpServeCmd 호출 + 인라인 switch 단일화 (UR-0044 1.9.322)', run: () => { const src = read(__filename); const fnDef = /function _mcpToCliArgs\(name, args, targetPath\) \{/.test(src); const called = src.includes('cliArgs = _mcpToCliArgs(name, args, targetPath)'); const switchCount = (src.match(/switch \(name\) \{/g) || []).length; const nullPath = src.includes('if (cliArgs === null) return send('); return fnDef && called && switchCount === 1 && nullPath; } },
3916
3920
  { name: 'fresh-init gate 통과: lazy detect 부재신호(handoff/test/progress) done-work 없으면 비차단 (UR-0054 ⑥ 1.9.323)', run: () => { const src = read(__filename); const doneWork = src.includes("const _hasDoneWork = rows.some(r => /^(done|completed|verified)$/i.test(r.status))"); const advisory = src.includes('_ADVISORY_KINDS') && src.includes("'handoff_never_generated'") && src.includes("'no_test_run'"); const blocking = src.includes('const blockingIssues = Math.max(0, issues - advisoryCount)') && src.includes('if (blockingIssues > 0) process.exitCode = 1'); return doneWork && advisory && blocking; } },
3917
3921
  { name: 'lib/pure-utils: 메모리 MD 파서 분리(_countDatedBlocks/_extractDecisionBlocks) + _compareSemver 중복제거 (UR-0025 1.9.324)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._countDatedBlocks === 'function' && typeof m._extractDecisionBlocks === 'function'; const work = m._countDatedBlocks('```md\n### 2026-01-01 — T\n```\n### 2026-06-05 — R\n') === 1 && m._extractDecisionBlocks('### 2026-06-05 — A\n- Decision: x\n').length === 1; const src = read(__filename); const moved = m._countDatedBlocks === _countDatedBlocks && m._extractDecisionBlocks === _extractDecisionBlocks && !/^function _countDatedBlocks\(/m.test(src) && !/^function _compareSemver\(/m.test(src); return fnOk && work && moved; } },
@@ -3974,7 +3978,16 @@ function _selfTestCases() {
3974
3978
  { name: 'UR-0025 큰핸들러토대: lib/io.js fs 프리미티브(read/writeUtf8/exists/mkdirp/append/rel/absRoot) 분리 + round-trip (1.9.383)', run: () => { const io = require('../lib/io'); const exp = ['absRoot', 'exists', 'read', 'readBuf', 'mkdirp', 'writeUtf8', 'append', 'rel'].every(k => typeof io[k] === 'function') && io.read === read && io.writeUtf8 === writeUtf8 && io.exists === exists; const src = read(__filename); const moved = !/^function writeUtf8\(p, s\) \{/m.test(src) && !/^function read\(p\) \{/m.test(src) && !/^function exists\(p\) \{/m.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_io_')); let rt = false; try { const f = path.join(tmp, 'a', 'b.txt'); io.writeUtf8(f, '한글RT'); rt = io.exists(f) && io.read(f) === '한글RT' && io.rel(tmp, f) === 'a/b.txt'; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return exp && moved && rt; } },
3975
3979
  { name: '5th외부평가/UR-0085: status --json 구조화 출력 + verify --json 와이어 (1.9.384)', run: () => { if (typeof status !== 'function' || typeof verify !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sj_')); const save = process.argv; const _w = process.stdout.write; let so = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); process.argv = ['node', 'h', 'status', tmp, '--json']; process.stdout.write = s => { so += s; return true; }; status(tmp); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } let sj; try { sj = JSON.parse(so); } catch {} const statusOk = !!sj && typeof sj.total === 'number' && typeof sj.present === 'number' && 'healthy' in sj && Array.isArray(sj.missing); const src = read(__filename); const verifyWired = /function verify\(root\) \{[\s\S]*?has\('--json'\)[\s\S]*?JSON\.stringify\(\{ ok:/.test(src); return statusOk && verifyWired; } },
3976
3980
  { name: '5th외부평가/UR-0086: _parseContractSpec markdown bullet 함수 감지 + 순수 추출 (1.9.385)', run: () => { const m = require('../lib/pure-utils'); if (m._parseContractSpec !== _parseContractSpec) return false; const p = _parseContractSpec('# Spec\n- add(a,b)\n* subtract(a,b)\n1. multiply(a,b)\nfunction legacy(x)\n`mentioned(`\ntick.amount\n'); const declOk = ['add', 'subtract', 'multiply', 'legacy'].every(n => p.declared.includes(n)) && p.declared.length === 4; const menOk = p.mentioned.includes('mentioned') && !p.declared.includes('mentioned'); const fieldOk = p.fields.includes('amount'); const fpOk = _parseContractSpec('- 합계 (a+b)\n- result (total)\n- foo: bar(x)\n**bold**').declared.length === 0; const src = read(__filename); const moved = src.includes('_parseContractSpec(specText)') && !/specText\.matchAll\(\/function/.test(src); return declOk && menOk && fieldOk && fpOk && moved; } },
3977
- { name: '5th외부평가/UR-0087: _gitignoreMatch git 일치(.env↛.env.bad) + env-family 스캔 (1.9.386)', run: () => { const m = require('../lib/pure-utils'); if (m._gitignoreMatch !== _gitignoreMatch) return false; const gm = _gitignoreMatch; const semOk = gm('.env', '.env') === true && gm('.env', '.env.bad') === false && gm('.env', '.env.local') === false && gm('.env.*', '.env.bad') === true && gm('.env*', '.env') === true && gm('*.pem', 'k.pem') === true && gm('src/', 'src/a.txt') === true; const src = read(__filename); let envFamilyScan = false; { /* 1.36.83 (공허가드 스윕): 종전 리터럴 '!SCAN_TEXT_EXT.has(ext) && !isEnv' + 'Family' 은 1.36.56 리팩터(_known)로 제품에서 사라져 이 줄 자신만 매칭하던 공허 가드였다. env-family 강제포함을 행위로 검사한다: .env.production 은 확장자('.production')가 allow-list 밖 + 64KB 초과라, isEnvFamily 가 _known 에서 빠지면 '작은 텍스트' 폴백 경로에서 크기로 걸러져 미탐된다(실측 1→0). */ const _t = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_envfam_')); try { fs.writeFileSync(path.join(_t, '.env.production'), 'x'.repeat(70 * 1024) + '\nAWS_ACCESS_KEY_ID=' + 'AKIAJQXMP7RZ2KL9WXYZ' + '\n'); const _r = _collectSecretFindings(_t); envFamilyScan = _r.findings.some(f => f.file === '.env.production' && f.name === 'AWS Access Key'); } catch { envFamilyScan = false; } finally { try { fs.rmSync(_t, { recursive: true, force: true }); } catch {} } } const delegated = src.includes('return _gitignoreMatch(gi, fileRel)'); return semOk && envFamilyScan && delegated; } },
3981
+ { name: '5th외부평가/UR-0087: _gitignoreMatch git 일치(.env↛.env.bad) + env-family 스캔 (1.9.386)', run: () => { const m = require('../lib/pure-utils'); if (m._gitignoreMatch !== _gitignoreMatch) return false; const gm = _gitignoreMatch; const semOk = gm('.env', '.env') === true && gm('.env', '.env.bad') === false && gm('.env', '.env.local') === false && gm('.env.*', '.env.bad') === true && gm('.env*', '.env') === true && gm('*.pem', 'k.pem') === true && gm('src/', 'src/a.txt') === true; const src = read(__filename); let envFamilyScan = false; { /* 1.36.83 (공허가드 스윕): 종전 리터럴 '!SCAN_TEXT_EXT.has(ext) && !isEnv' + 'Family' 은 1.36.56 리팩터(_known)로 제품에서 사라져 이 줄 자신만 매칭하던 공허 가드였다. env-family 강제포함을 행위로 검사한다: .env.production 은 확장자('.production')가 allow-list 밖 + 64KB 초과라, isEnvFamily 가 _known 에서 빠지면 '작은 텍스트' 폴백 경로에서 크기로 걸러져 미탐된다(실측 1→0). 1.36.84 (검수 Medium#4 — 과적합): .env.production 단일 픽스처는 제품 정규식을 `.env` 와 `.env.production` 만 허용하도록 좁혀도 통과했다(70KB `.env.local` 미탐). → env-family 3종을 테이블 픽스처로 전수 검사하고, 빈 배열 공허참(`[].every`)을 막으려 length 일치를 함께 요구한다. */ const _envFamNames = ['.env', '.env.production', '.env.local', '.env.development', '.env.qa7']; const _envFamHits = []; for (const _n of _envFamNames) { const _t = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_envfam_')); try { fs.writeFileSync(path.join(_t, _n), 'x'.repeat(70 * 1024) + '\nAWS_ACCESS_KEY_ID=' + 'AKIAJQXMP7RZ2KL9WXYZ' + '\n'); const _r = _collectSecretFindings(_t); _envFamHits.push(_r.findings.some(f => f.file === _n && f.name === 'AWS Access Key')); } catch { _envFamHits.push(false); } finally { try { fs.rmSync(_t, { recursive: true, force: true }); } catch {} } } /* 1.36.84 (검수 M2): ① 세 이름 열거는 여전히 과적합 — 제품 판정을 그 셋만 허용하도록 좁혀도 통과했다. bare `.env` 와 **열거되지 않은 임의 suffix**(.env.qa7)를 넣어 "접두 규칙" 자체를 검사한다. ② `_envFamNames.length > 0` 을 명시 — 배열이 비면 `[].every` 가 공허참이라 통과한다(내가 반복해 고쳐온 클래스가 이 수리안에 그대로 들어와 있었다). 음성 대조: `.environment`/`.envx` env-family 아니므로 강제포함되면 안 된다. */
3982
+ envFamilyScan = _envFamNames.length > 0 && _envFamHits.length === _envFamNames.length && _envFamHits.every(Boolean);
3983
+ { const _neg = ['.environment', '.envx'];
3984
+ for (const _n of _neg) {
3985
+ const _t = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_envneg_'));
3986
+ try {
3987
+ fs.writeFileSync(path.join(_t, _n), 'x'.repeat(70 * 1024) + '\nAWS_ACCESS_KEY_ID=' + 'AKIAJQXMP7RZ2KL9WXYZ' + '\n');
3988
+ if ((_collectSecretFindings(_t).findings || []).some(f => f.file === _n)) envFamilyScan = false; // env-family 로 오인하면 실패
3989
+ } catch { envFamilyScan = false; } finally { try { fs.rmSync(_t, { recursive: true, force: true }); } catch {} }
3990
+ } } } const delegated = src.includes('return _gitignoreMatch(gi, fileRel)'); return semOk && envFamilyScan && delegated; } },
3978
3991
  { name: 'UR-0088 5th외부평가 일관성: incident/runs list 빈 케이스 --json 구조화 (1.9.387)', run: () => { if (typeof incidentListCmd !== 'function' || typeof runsListCmd !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lj_')); const save = process.argv; const _w = process.stdout.write; let io = '', ro = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); process.argv = ['node', 'h', 'incident', 'list', '--json']; process.stdout.write = s => { io += s; return true; }; incidentListCmd(tmp); process.stdout.write = _w; process.argv = ['node', 'h', 'runs', 'list', '--json']; process.stdout.write = s => { ro += s; return true; }; runsListCmd(tmp); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } let ij, rj; try { ij = JSON.parse(io); rj = JSON.parse(ro); } catch {} return !!ij && ij.total === 0 && Array.isArray(ij.items) && !!rj && rj.total === 0 && Array.isArray(rj.items); } },
3979
3992
  { name: 'UR-0025 큰핸들러 모듈화: migrate audit/apply/plan → lib/migrate.js + DI 위임 + 동작 (1.9.388)', run: () => { const m = require('../lib/migrate'); const expOk = typeof m.migrateAuditCmd === 'function' && typeof m.migrateApplyCmd === 'function' && typeof m.migratePlanCmd === 'function'; const src = read(__filename); const delegated = src.includes("require('../lib/migrate')") && src.includes('_migrate.migrateAuditCmd(root, opts, _migrateDeps())') && src.includes('_migrate.migratePlanCmd(root, opts, _migrateDeps())'); const movedToLib = read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).includes('leerness-plan-'); let behavOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_mig_')); const save = process.argv; const _w = process.stdout.write; let out = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.harness', 'HARNESS_VERSION'), VERSION); process.argv = ['node', 'h', 'migrate', 'audit', tmp, '--json']; process.stdout.write = s => { out += s; return true; }; migrateAuditCmd(tmp, { json: true }); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } try { const j = JSON.parse(out); behavOk = j.version === VERSION && typeof j.willChange === 'number' && Array.isArray(j.findings); } catch {} return expOk && delegated && movedToLib && behavOk; } },
3980
3993
  { name: 'UR-0025 큰핸들러 모듈화: teamCmd → lib/team.js + DI 위임 + 동작 (1.9.389)', run: () => { const m = require('../lib/team'); const expOk = typeof m.teamCmd === 'function'; const src = read(__filename); const delegated = src.includes("require('../lib/team')") && src.includes('_team.teamCmd(root, sub, id, opts,'); const teamSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'team.js')); const movedToLib = teamSrc.includes("require('./pure-utils')") && teamSrc.includes('_teamDeployGate') && teamSrc.includes('알 수 없는 team 하위명령'); let behavOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_tm_')); const save = process.argv; const _w = process.stdout.write; let out = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); process.argv = ['node', 'h', 'team', 'list', '--json']; process.stdout.write = s => { out += s; return true; }; teamCmd(tmp, 'list', undefined, { json: true }); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } try { const j = JSON.parse(out); behavOk = j.version === VERSION && j.count === 0 && Array.isArray(j.teams); } catch {} return expOk && delegated && movedToLib && behavOk; } },
@@ -4697,8 +4710,21 @@ function _selfTestCases() {
4697
4710
  const _sec = 'module.exports={apiKey:"sk-test-1234567890abcdefghijklmnopqrstuvwxyz"};';
4698
4711
  fs.writeFileSync(path.join(_t15, 'small.js'), _sec);
4699
4712
  fs.writeFileSync(path.join(_t15, 'big.js'), '// ' + 'x'.repeat(1024 * 1024) + '\n' + _sec);
4700
- const _hits = _collectSecretFindings(_t15).findings || [];
4701
- const _capOk = _hits.some(f => /small\.js/.test(f.file)) && !_hits.some(f => /big\.js/.test(f.file)); // 1MB 초과는 스캔 제외
4713
+ // 1.36.84 (검수 Medium#5): "findings big.js 가 없음"은 "읽지 않았음"의 증거가 아니다 —
4714
+ // size 검사 **앞에** eager read 줄을 넣어 1MB 초과 파일을 통째로 읽게 만들어도 findings 는 그대로라 가드가 통과했다.
4715
+ // → fs.readFileSync 를 spy 로 감싸 big.js 가 한 번도 읽히지 않았음을 직접 확인한다(small.js 는 양성 대조).
4716
+ // spy 는 프로세스 전역이므로 반드시 finally 에서 복원한다(복원 실패 시 이후 전 케이스가 오염된다).
4717
+ const _reads = [];
4718
+ const _origReadFileSync = fs.readFileSync;
4719
+ let _hits;
4720
+ try {
4721
+ fs.readFileSync = function (p, ...rest) { try { _reads.push(String(p)); } catch {} return _origReadFileSync.call(fs, p, ...rest); };
4722
+ _hits = _collectSecretFindings(_t15).findings || [];
4723
+ } finally { fs.readFileSync = _origReadFileSync; }
4724
+ const _bigRead = _reads.some(p => /big\.js$/.test(p)); // 선행 eager read 도 위반
4725
+ const _smallRead = _reads.some(p => /small\.js$/.test(p)); // spy 가 실제로 걸렸다는 양성 대조
4726
+ const _capOk = _hits.some(f => /small\.js/.test(f.file)) && !_hits.some(f => /big\.js/.test(f.file)) // 1MB 초과는 스캔 제외
4727
+ && _smallRead && !_bigRead; // + 애초에 읽지도 않음
4702
4728
  const _order = /statSync\(file\)[\s\S]{0,240}?size > 1024 \* 1024\)\s*continue;[\s\S]{0,600}?read(?:FileSync)?\(file\)/.test(src)
4703
4729
  && /statSync\(file\)\.size > 5 \* 1024 \* 1024\)\s*continue;[\s\S]{0,240}?readBuf\(file\)/.test(src)
4704
4730
  && /statSync\(fp2\)\.size > budget\)\s*continue;[\s\S]{0,160}?read\(fp2\)/.test(src);
@@ -4724,11 +4750,13 @@ function _selfTestCases() {
4724
4750
  return typeof m.reviewRequestCmd === 'function' && wired;
4725
4751
  } },
4726
4752
  { name: 'Karpathy 가이드라인4 (UR-0032): plan --done-when 검증가능 완료조건 저장/파싱/표시 (1.14.2)', run: () => {
4727
- const src = read(__filename);
4728
4753
  // 1.36.80 (가드 부패 수리): planAdd 의 doneWhen 기본값 한 줄을 통째로 박아둔 낡은 exact-literal 은 1.36.63 다국어화로
4729
4754
  // 제품 코드에서 사라졌고 이 가드 줄 자신에만 남아 includes 가 영원히 참이었다(자기참조 false-pass — --done-when 을 통째로 무시해도 초록).
4730
4755
  // 아래 dw 계산도 제품 파서가 아니라 이 케이스가 새로 쓴 정규식이라 아무것도 지키지 못했다.
4731
- // → plan add 를 실제 실행해 저장(plan.md)과 파싱(planListCmd)을 행위 검증. 표시/기본값은 언어별이라 불변식 정규식으로.
4756
+ // → plan add 를 실제 실행해 저장(plan.md)과 파싱(planListCmd)을 행위 검증.
4757
+ // 1.36.84 (검수 Medium#7): 표시/기본값이 아직 소스 정규식이라 기본값을 통째로 없애도(`|| ''`) 초록이었다 —
4758
+ // 이 케이스가 --done-when **있는** 경로만 실행했기 때문. → 옵션 없는 plan add 도 실제로 돌려 저장/파싱/표시
4759
+ // 세 지점에서 언어별 기본값((미정)/(unset))을 행위 검증한다. 자기 소스 읽기는 제거(더는 소스 존재로 판정하지 않음).
4732
4760
  let wired = false;
4733
4761
  const _tp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_dw_'));
4734
4762
  try {
@@ -4736,20 +4764,43 @@ function _selfTestCases() {
4736
4764
  fs.writeFileSync(path.join(_tp, '.harness', 'HARNESS_VERSION'), VERSION);
4737
4765
  const _cond = '로그인 e2e 테스트 통과';
4738
4766
  const _mtitle = 'Done-When 회귀가드';
4739
- cp.spawnSync(process.execPath, [__filename, 'plan', 'add', _mtitle, '--path', _tp, '--done-when', _cond, '--json'],
4767
+ const _mdefault = 'Done-When 기본값 회귀가드';
4768
+ const _spawn = a => cp.spawnSync(process.execPath, [__filename, ...a, '--path', _tp],
4740
4769
  { cwd: _tp, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
4770
+ // 1.36.84 (검수 M4): child 의 exit 를 버리면 "JSON 은 정상인데 exit 1" 을 못 잡는다 — status 를 단언한다.
4771
+ const _r1 = _spawn(['plan', 'add', _mtitle, '--done-when', _cond, '--json']);
4772
+ const _r2 = _spawn(['plan', 'add', _mdefault, '--json']); // 옵션 **없는** 기본값 경로도 실제 실행
4773
+ if (_r1.status !== 0 || _r2.status !== 0) throw new Error('plan add exit != 0');
4741
4774
  // 저장: milestone 블록에 Done-When 줄 + 제목이 --done-when 값을 흡수하지 않음(nonFlagArgs withValue 회귀가드)
4742
- const _pm = read(planPath(_tp));
4743
- const _stored = new RegExp('^### M-\\d{4,}\\. ' + _mtitle + '$[\\s\\S]*?^Done-When: ' + _cond + '$', 'm').test(_pm);
4744
- // 파싱: 제품 파서(planListCmd) doneWhen 실제로 되돌려줌
4775
+ const _blocks = read(planPath(_tp)).replace(/\r\n/g, '\n').split(/\n(?=### M-\d{4,}\.)/);
4776
+ const _blockOf = t => _blocks.find(b => new RegExp('^### M-\\d{4,}\\. ' + t + '$', 'm').test(b)) || '';
4777
+ // 1.36.84 (검수 M4): `(미정|unset)` 합집합만 보면 **언어 매핑이 뒤바뀌어도** 통과한다(한국어에서 (unset) 저장 등).
4778
+ // 이 워크스페이스는 ko 이므로 ko 기본값을 정확히 요구한다(en 경로는 아래 _en 블록에서 따로 확인).
4779
+ const _DEFAULT_LINE = /^Done-When: \(미정\)[ \t]*$/m;
4780
+ const _stored = new RegExp('^Done-When: ' + _cond + '$', 'm').test(_blockOf(_mtitle))
4781
+ && _DEFAULT_LINE.test(_blockOf(_mdefault));
4782
+ // 파싱: 제품 파서(planListCmd)가 doneWhen 을 실제로 되돌려줌 — 기본값도 빈 값/인접줄 흡수가 아니어야
4745
4783
  let _out = ''; const _wr = process.stdout.write;
4746
4784
  try { process.stdout.write = x => { _out += x; return true; }; planListCmd(_tp, { json: true }); } finally { process.stdout.write = _wr; }
4747
4785
  let _ms = []; try { _ms = JSON.parse(_out).milestones || []; } catch {}
4748
- const _parsed = _ms.length === 1 && _ms[0].doneWhen === _cond && _ms[0].title === _mtitle;
4749
- // 표시 + 미지정 기본값: 1.36.63 다국어화로 값이 언어별 exact 리터럴 대신 불변식
4750
- const _shown = /완료기준\(Done-When\): \$\{m\.doneWhen/.test(src);
4751
- const _hasDefault = /const doneWhen = _lineSafe\(arg\('--done-when', ''\)\s*\|\|/.test(src);
4752
- wired = _stored && _parsed && _shown && _hasDefault;
4786
+ const _dw = t => (_ms.find(m => m.title === t) || {}).doneWhen;
4787
+ const _parsed = _ms.length === 2 && _dw(_mtitle) === _cond && _dw(_mdefault) === '(미정)'; // ko 정확값(검수 M4)
4788
+ // 표시: 사람용 plan list 를 자식 프로세스로 실행(셀프테스트 argv 에 --json 이 있어 in-process 로는 사람용 경로가 안 나옴)
4789
+ const _human = _spawn(['plan', 'list']).stdout || '';
4790
+ const _shown = _human.includes('완료기준(Done-When): ' + _cond)
4791
+ && _human.includes('완료기준(Done-When): (미정)');
4792
+ // 1.36.84 (검수 M4): **en 분기 기본값**도 따로 검사 — ko 만 보면 en 기본값을 ''로 없애도 통과했다(실측).
4793
+ let _enOk = false;
4794
+ // 언어는 수동 파일이 아니라 실제 init(--language en)으로만 확정된다(수동 .harness/LANGUAGE·manifest 로는 미적용 — 실측).
4795
+ const _te = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_dwen_'));
4796
+ try {
4797
+ cp.spawnSync(process.execPath, [__filename, 'init', _te, '--yes', '--no-env', '--no-stale-check', '--language', 'en'],
4798
+ { encoding: 'utf8', timeout: 90000, maxBuffer: 8 * 1024 * 1024 });
4799
+ const _re = cp.spawnSync(process.execPath, [__filename, 'plan', 'add', 'EN default guard', '--path', _te, '--json'],
4800
+ { cwd: _te, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
4801
+ _enOk = _re.status === 0 && /^Done-When: \(unset\)[ \t]*$/m.test(read(planPath(_te)).replace(/\r\n/g, '\n'));
4802
+ } catch { _enOk = false; } finally { try { fs.rmSync(_te, { recursive: true, force: true }); } catch {} }
4803
+ wired = _stored && _parsed && _shown && _enOk;
4753
4804
  } catch {} finally { try { fs.rmSync(_tp, { recursive: true, force: true }); } catch {} }
4754
4805
  return wired;
4755
4806
  } },
@@ -5003,6 +5054,44 @@ function _selfTestCases() {
5003
5054
  && src.includes("failJson(has('--json'), 'rule_not_found'");
5004
5055
  return restoreOk && planOk && fileReOk && analyzersOk && archiveOk && jsonOk;
5005
5056
  } },
5057
+ { name: '자기소스 검사 동결 (1.36.84): selftest 가 자기 소스를 읽어 검증하는 케이스는 더 늘지 않는다 — 신규는 행위검사로 (메타가드)', run: () => {
5058
+ // 왜 "탐지"가 아니라 "동결"인가:
5059
+ // 1.36.82~83 에서 자기참조 공허 가드를 탐지하려 세 라운드 연속 탐지기를 고쳤는데,
5060
+ // 그때마다 외부 검수가 새 우회를 찾아냈다(직접 호출 · fs.readFileSync · indexOf · 정규식 .test ·
5061
+ // alias/재할당/구조분해 · 표식 복사 · UI 문자열 · **읽는 파일의 무관한 위치에서 만족되는 리터럴**).
5062
+ // 마지막 형태는 존재 검사로는 원리적으로 판별 불가다(health 가드가 실제로 그랬다 — 커밋된 시크릿이
5063
+ // 있는데도 healthy:true 가 되게 깨뜨려도 337/337 초록이었다).
5064
+ // → 우회를 쫓는 대신 **부패할 수 있는 형태가 늘지 못하게** 막는다.
5065
+ //
5066
+ // **이 가드가 보장하는 것과 보장하지 않는 것** (검수 H1/H2 실측 — 과장하지 않는다):
5067
+ // 보장: `_selfTestCases` 영역에서 **지원 철자 2종**(io 의 read 헬퍼 / fs 의 readFileSync 를 __filename 에 직접
5068
+ // 적용한 형태)의 lexical 출현 횟수가 기준치를 넘지 않음. 들여쓰기 변경·기존 케이스 내부 추가도 걸린다.
5069
+ // 미보장: 별칭(변수에 __filename 을 담아 넘기기) · 동적 접근(대괄호 프로퍼티) · require 체인 · 구조분해 ·
5070
+ // 간접 호출(배열 순회로 넘기기). 주석/문자열 안의 같은 철자도 계수된다(오탐 방향 —
5071
+ // 그래서 이 주석은 지원 철자를 그대로 적지 않는다. 적었더니 실제로 카운트가 올라 자기 자신을 막았다).
5072
+ // "어떤 형태든 막는다"는 lexical 방식으로 달성 불가다 — 그렇게 적지 않는다.
5073
+ // 기존 206건은 유예(grandfathered)되며 **줄이는 방향으로만** 갱신한다. 신규 가드는 행위검사로 써야 한다.
5074
+ // 자기 제외 표식은 두지 않는다 — 표식 복사가 곧 우회가 된다(검수가 실증). 이 가드 자신도 셈에 포함한다.
5075
+ const src = read(__filename);
5076
+ const s = src.indexOf('function _selfTestCases(');
5077
+ if (s < 0) return false; // 영역을 못 찾으면 fail-closed
5078
+ const nextFn = src.indexOf('\nfunction ', s + 10);
5079
+ const region = src.slice(s, nextFn < 0 ? src.length : nextFn);
5080
+ // (검수 H2, 실측) 영역 경계가 **fail-open** 이었다: 영역 안 주석에 줄머리 `function` 한 줄만 넣으면
5081
+ // nextFn 이 거기서 끊겨 영역이 306KB→108B 로 줄고 카운트 0 이 되어 통과했다.
5082
+ // → 파싱된 케이스 수가 실제 케이스 수와 다르면 무조건 실패(fail-closed).
5083
+ const parsed = region.split(/\n(?=\s*\{ name: )/).slice(1).length;
5084
+ if (parsed !== _selfTestCases().length) return false;
5085
+ // 케이스 단위가 아니라 **출현 횟수**로 센다. 청크 분할 기준으로 세면
5086
+ // (a) 이미 자기소스를 쓰는 케이스에 한 줄 더 넣기 (b) 들여쓰기를 바꿔 분할을 빠져나가는 새 케이스
5087
+ // 두 우회가 뚫린다(둘 다 실측 확인). 출현 횟수는 분할에 의존하지 않아 둘 다 막는다.
5088
+ const SELF_READ = /(?:read\(__filename\)|fs\.readFileSync\(\s*__filename)/g;
5089
+ const n = (region.match(SELF_READ) || []).length;
5090
+ // 1.36.84 실측 **215 회 출현**(이 가드 자신 포함). 늘어나면 실패한다 — 줄였으면 반드시 이 수치를 내려 적을 것.
5091
+ // 여유를 남기면 그만큼 새 자기소스 검사가 조용히 들어온다(같은 라운드에 커버리지 수리로 한 건이 줄어
5092
+ // 임계값에 여유가 생긴 것을 실측으로 발견해 딱 맞췄다).
5093
+ return n <= 215;
5094
+ } },
5006
5095
  { name: 'DB 렌즈 recall (dogfood FN, 1.36.4): 내용기반 감지 — 평범한 이름의 DB 모듈 잡고 산문 FP 0 (행위)', run: () => {
5007
5096
  const T = _isDbContentText, W = _withDbDomain;
5008
5097
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.83",
3
+ "version": "1.36.84",
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
@@ -4153,8 +4153,13 @@ total++;
4153
4153
  // 소비 명령 회귀: constraints check (review-request 도 _checkRequestConstraints 사용)
4154
4154
  const cd = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-con-'));
4155
4155
  cp.spawnSync(process.execPath, [CLI, 'init', cd, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
4156
- const cr = cp.spawnSync(process.execPath, [CLI, 'constraints', 'check', 'stripe 결제 구현', '--path', cd], { encoding: 'utf8', timeout: 20000 });
4157
- const cmdOk = /stripe|플랫폼 매칭/.test(cr.stdout || '');
4156
+ // 1.36.84 (검수 Medium#8): 종전 `/stripe|플랫폼 매칭/.test(cr.stdout)` **exit 보지 않아**
4157
+ // 올바른 텍스트를 찍고 exit 1 로 죽어도 통과했다(부분-stdout 정규식 = 실패 무감지).
4158
+ // → --json 으로 실행해 exit 0 + 에러계약 부재 + 전체 stdout 파싱 + 매칭 결과까지 단언한다.
4159
+ const cr = cp.spawnSync(process.execPath, [CLI, 'constraints', 'check', 'stripe 결제 구현', '--path', cd, '--json'], { encoding: 'utf8', timeout: 20000 });
4160
+ let _cj = null; try { _cj = JSON.parse(cr.stdout || ''); } catch {}
4161
+ const cmdOk = cr.status === 0 && !!_cj && !_cj.error && Array.isArray(_cj.matched)
4162
+ && _cj.matched.length > 0 && _cj.matched[0].platform === 'stripe';
4158
4163
  ok = work && movedOut && cmdOk;
4159
4164
  fs.rmSync(cd, { recursive: true, force: true });
4160
4165
  } catch {}