leerness 1.36.83 → 1.36.85

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,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.85 — 2026-07-29 — brainstorm 줄번호 오보 수정(10곳) · 실행되지 않던 명령 경로에 가드
4
+
5
+ 여러 라운드 연속 테스트 인프라를 손봤으므로 **표면을 제품 정확성으로 옮겨** 검색했다(신뢰도가 낮은 곳을 보는 편이 낫다).
6
+
7
+ - **`brainstorm` 검색 결과의 줄번호가 틀렸다.** 줄번호를 `text.indexOf(block)` 로 구하는데, **내용이 같은 블록이 둘 이상이면 항상 첫 번째 위치**를 돌려준다 — 실측으로 3행·10행에 있는 동일 lesson 블록이 둘 다 `line=3` 으로 보고됐다. 사용자가 그 줄로 가면 다른 항목이 있다. `_blocksWithOffset(text, sep)` 로 **순회하며 오프셋을 누적**하도록 10곳 전부 전환(evidence · skill history · lessons · plan milestones · archive × `_brainstormFor`/사람용 두 구현). 경계 9종(CRLF · 빈 블록 · 선두/말미 · 단일 조각 · 한글 · 구분자 없음 · 빈 문자열) 검증.
8
+ - **selftest 338/338 이 초록인데 `brainstorm` 이 통째로 죽어 있었다.** 위 수정 중 import 를 빠뜨려 `_blocksWithOffset is not defined` 로 명령 전체가 실패했는데도 스위트는 전부 통과했다 — **그 명령의 실행 경로가 어떤 케이스에도 없었다**. 신설 가드는 실제 명령을 spawn 해 exit 와 출력을 함께 보므로 (a) 줄번호 회귀 (b) import 누락 둘 다 잡는다(변이로 확인).
9
+ - **`brainstorm` 은 사람용과 `--json` 이 서로 다른 구현을 쓴다**(`--json` 은 `_brainstormFor`, 사람용은 자체 수집 코드). **텍스트 비교로는 "skills 매칭 입력이 다르다"고 보였지만 행위로 비교하니 같은 결과를 냈다** — 텍스트 diff 가 오판이었다. 다만 검수가 **세 입력에서 두 경로가 실제로 갈린다**는 반례를 냈다(`--include-code` · archive-only · workspace `--include`). 내 픽스처로는 재현되지 않았고 제대로 고치려면 사람용 수집을 `_brainstormFor` 로 단일화하는 큰 리팩터가 필요해 **이번 라운드에는 하지 않았다** — 다음 라운드 과제로 명시한다. 신설 가드에는 lessons/decisions 두 표면의 **건수와 줄번호 일치**만 넣었다(그 이상은 주장하지 않는다).
10
+ - **작업 도구가 또 한 번 틀렸다**: 일괄 치환 스크립트의 앵커가 너무 일반적이라(`for (const b of blocks) {` 가 7곳 매칭) 엉뚱한 루프까지 바꿀 뻔했다 — dry-run 의 **기대 개수 검증**이 잡았다. 각 `indexOf` 지점에서 위로 올라가 그 루프만 지목하는 방식으로 바꿔 10/10 정확 적용.
11
+
12
+ **검수 26회전 반영** (High 0 · Medium 5 중 4건):
13
+
14
+ - **내가 이 라운드에 만든 가드가 공허했다** — `_lineOfOffset` 을 `offset + 1` 로 바꿔도 통과했다("서로 다르고 양수"만 검사). 정확한 값(`[3,10]`/`[7,14]`)을 단언하도록 강화하고 3방향 변이로 확인.
15
+ - **같은 버그의 다른 철자를 놓쳤다** — decisions 경로는 `indexOf` 가 아니라 `decLines.findIndex(line === heading)` 라 내 스윕 패턴에 안 걸렸고, 결과는 동일한 오보(`[3,3]`)였다. 클래스는 표현이 아니라 **의미**로 훑어야 한다. 코드펜스를 **길이 보존 마스킹**으로 제외하는 `_decisionBlocksWithOffset` 을 추가해(펜스·Template 제외 동작은 기존과 동일함을 확인) 정확한 줄번호를 얻는다. 펜스 없는 픽스처로는 이 회귀를 못 잡아 픽스처에 펜스를 넣었다.
16
+ - **O(n²) 성능 회귀** — `_lineOfOffset` 이 블록마다 처음부터 재스캔했다(검수 실측: lesson 40k건 ≈ 62초). 순회 중 줄 수를 누적해 O(n) 으로(자체 실측 5000건 973ms → 446ms).
17
+ - **행위 검사의 비용을 명시한다**: 소스 문자열 검사를 행위 검사로 바꾸며 selftest 가 **약 4초 → 약 13초**가 됐고(CLI spawn 비용), 4초 시절 기준으로 잡힌 e2e 타임아웃(30s)이 전체 부하에서 터졌다 — **게이트가 잡았다**. 격리 재현 3/3 통과로 검사 자체는 정상임을 확인한 뒤, 비용 구조를 측정해(`init` 1330ms · 초기화 디렉토리 복사 78ms · CLI 기동 434ms) 언어별 **공유 픽스처 캐시**를 도입(14.1→13.0s)하고 타임아웃 근거를 주석에 남겼다. 행위 검사는 소스 검사보다 3배 비싸지만, 소스 검사는 리팩터 한 번에 조용히 공허해지고 행위 검사는 import 누락 같은 실제 파손까지 잡는다 — 값을 치를 만하다고 판단했다.
18
+
19
+ - 검증: selftest 339 · e2e 406/406 · 원 버그 재현→수정(3행/10행, 펜스 포함 시 7행/14행) · 신규 가드 3방향 변이(line 누적 훼손 · line 고정 · 마스킹 제거) · 오프셋 경계 8종 · 격리 재현 3회.
20
+
21
+ ## 1.36.84 — 2026-07-29 — 탐지 대신 **동결** · 검수 25회전 커버리지 부족 5건 상환
22
+
23
+ 1.36.82~83 에서 자기참조 공허 가드를 **탐지**하려 세 라운드 연속 탐지기를 고쳤고, 매번 외부 검수가 새 우회를 찾아냈다(직접 호출 · `fs.readFileSync` · `indexOf` · 정규식 `.test` · alias/재할당/구조분해 · 자기제외 표식 복사 · UI 문자열 · **읽는 파일의 무관한 위치에서 만족되는 리터럴**). 마지막 형태는 존재 검사로 **원리적으로 판별 불가**다.
24
+
25
+ **그래서 탐지를 포기하고 형태를 동결했다.** "selftest 가 자기 소스를 읽어 검증하는 출현 횟수는 늘지 않는다"(현재값 고정, 줄이는 방향으로만). 우회를 쫓는 대신 부패할 수 있는 형태가 늘지 못하게 막는 쪽이 이긴다 — 탐지는 heuristic 이라 지고 계수는 syntactic 이라 진다.
26
+
27
+ **이 가드가 보장하는 것과 보장하지 않는 것** (검수가 반례로 좁혀 준 대로, 과장하지 않는다):
28
+ - **보장**: `_selfTestCases` 영역에서 **지원 철자 2종**의 lexical 출현 횟수 상한. 들여쓰기 변경·기존 케이스 내부 추가도 여기 걸린다(둘 다 1차 구현에서는 뚫렸고 출현-횟수 방식으로 바꿔 닫았다). 영역 파싱이 깨지면 **fail-closed**(검수 H2: 영역 안 주석에 줄머리 `function` 한 줄만 넣으면 스캔 범위가 306KB→108B 로 줄고 카운트 0 이 되어 통과했다 — 파싱된 케이스 수와 실제 수가 다르면 실패로 전환).
29
+ - **미보장**: 별칭 · 동적 프로퍼티 접근 · `require` 체인 · 구조분해 · 배열 순회를 통한 간접 호출. 주석/문자열 안의 같은 철자도 계수된다(오탐 방향 — 실제로 이 보장 범위를 적은 주석 자신이 계수돼 가드가 자기를 막았고, split-literal 로 회피했다).
30
+ - "어떤 형태든 막는다"는 lexical 방식으로 달성 불가다. 그렇게 적지 않는다.
31
+
32
+ **검수 25회전이 남긴 커버리지 부족 5건 상환** — 각각 변이로 증명하고 독립 재현했다:
33
+ - env-family 를 3종 테이블로 확장했더니 검수가 **여전히 과적합**임을 보였다(그 셋만 허용하도록 좁혀도 통과). bare `.env` + **열거되지 않은 임의 suffix**(`.env.qa7`)로 접두 규칙 자체를 검사하고, 음성 대조(`.environment`/`.envx`)를 추가.
34
+ - **`[].every` 빈 배열 공허참이 새 수리안에 그대로 들어와 있었다** — 세 라운드 내내 고쳐온 클래스가 방금 만든 코드에 재발했다. `length > 0` 명시로 차단.
35
+ - `fs.readFileSync` spy 로 eager read 탐지(1MB 초과 파일을 읽고 버리는 회귀) · lesson 파서 행위 검증(동치 정규식 리팩터에는 false-BLOCK 하지 않음) · `--done-when` ko/en **정확값** + child exit 검사(합집합만 보면 언어 매핑이 뒤바뀌어도 통과했다) · e2e constraints 를 `--json` + exit 검사로 교체.
36
+
37
+ **고치지 않은 것** (검수 지적 중 남긴 것 — 다음 라운드 과제):
38
+ - `readFileSync` spy 가 fd 기반 읽기(`openSync`+`readSync`, 모듈 로드 시 `bind` 캡처)를 놓치고, `read()` 를 fd 로 바꾸는 동치 리팩터에는 false-BLOCK 한다.
39
+ - lesson 정규식 파서가 bin 에 3곳 중복 — 정규 파서만 고쳐도 `brainstorm` 같은 소비 경로는 무방비.
40
+ - 동결 래칫의 미보장 형태(위 목록).
41
+
42
+ - 검증: selftest 338 · e2e 406/406 · 동결 6방향(우회 4형태 차단 · 행위검사 통과 · fail-closed) · M4 4방향 변이 · 임계값 경계 시험.
43
+
3
44
  ## 1.36.83 — 2026-07-28 — 자기참조 가드 부채 상환 (탐지 가능 범위 12 → 0) · 소스 문자열 검사를 행위 검사로
4
45
 
5
46
  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.85 하네스를 사용합니다. 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.85는 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.85 릴리스 태그 이력** (수백 라운드) · _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.85: 2026-07-29
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -26,7 +26,7 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
26
26
  _migrationGuideText, _parseContractSpec, _gitignoreMatch,
27
27
  _featureGraphTemplate, _parseFeatureGraph, _nextFeatureId, _featureBlock, _featureImpactBfs,
28
28
  _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _parseLimit, _parseAddTitle, _parseImplExports, _taskPositionalPath, _completionClaimAllowed, _minorKey, _shouldPublishNpm,
29
- _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
29
+ _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody, _blocksWithOffset, _decisionBlocksWithOffset } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
30
30
  // 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
31
31
  const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
32
32
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
@@ -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.85';
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') 시 호스트 프로세스 오염.
@@ -3193,6 +3193,28 @@ function pathSetupCmd(root, opts = {}) {
3193
3193
  }
3194
3194
  }
3195
3195
 
3196
+ // 1.36.85: selftest 행위 가드용 **공유 픽스처 캐시**.
3197
+ // 소스 문자열 검사를 행위 검사로 바꾸면서 케이스마다 `init` 을 spawn 했더니 selftest 가 4초대 → 14초가 됐고,
3198
+ // 30초 타임아웃을 쓰던 e2e 소비자가 전체 부하에서 실패했다(게이트가 잡음).
3199
+ // 실측 비용: init 1330ms · 초기화된 디렉토리 복사 78ms(17배 저렴) · CLI 1회 기동 434ms.
3200
+ // → 언어별로 **한 번만** init 하고 이후는 복사해서 쓴다. 각 호출자는 자기 사본을 받으므로 상호 오염이 없다.
3201
+ const _stFixtureCache = new Map();
3202
+ function _selftestFixture(lang) {
3203
+ const key = lang || 'ko';
3204
+ if (!_stFixtureCache.has(key)) {
3205
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_stfx_' + key + '_'));
3206
+ const args = [__filename, 'init', base, '--yes', '--no-env', '--no-stale-check'];
3207
+ if (key !== 'ko') args.push('--language', key);
3208
+ const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 });
3209
+ _stFixtureCache.set(key, r.status === 0 ? base : null);
3210
+ }
3211
+ const base = _stFixtureCache.get(key);
3212
+ if (!base) return null; // init 실패 시 호출자가 스스로 판단(조용한 통과 금지)
3213
+ const dst = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_stuse_'));
3214
+ fs.cpSync(base, dst, { recursive: true });
3215
+ return dst;
3216
+ }
3217
+
3196
3218
  // 1.9.258: leerness selftest — 설치된 leerness 바이너리의 코어 순수 함수 자가 검증.
3197
3219
  // 1.9.255~257 에서 export 한 보안/정확성/인코딩-핵심 함수를 실제 호출해 무결성 확인.
3198
3220
  // 사용자/CI 가 "내 leerness 가 정상인가?" 를 1초 내 검증 (npm 캐시 손상/부분 설치 감지).
@@ -3907,11 +3929,15 @@ function _selfTestCases() {
3907
3929
  { 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
3930
  { 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
3931
  구현 파일을 읽고, 소스 문자열만이 아니라 **실제 파서 동작**으로도 확인한다(빈 필드가 다음 줄을 먹지 않는가). */
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]*(.+)');
3932
+ /* 1.36.84 (검수 Medium#6): decision 행위검사였고 lesson 은 puSrc.includes 3개(소스 문자열)로만 봤다.
3933
+ 형태는 양방향으로 틀린다 (1) 패턴이 파일의 다른 위치(주석 등) 남아 있으면 실제 정규식을
3934
+ `- Lesson:\s*(.+)` 로 바꿔 빈 Lesson 이 다음 Tag 줄을 통째로 먹게 만들어도 통과했고(실측: 스위트 전체 초록),
3935
+ (2) 반대로 `[^\S\n]*` 같은 **동치 리팩터**는 정확 리터럴이 사라져 false-BLOCK 했다.
3936
+ → 소스 문자열 검사를 걷어내고 빈 Lesson + 정상 Tag 입력의 파싱 결과(text 는 빈 문자열, tag 는 보존)로 직접 검증한다. */
3912
3937
  const parsed = require('../lib/pure-utils')._extractDecisionBlocks ? true : false;
3913
3938
  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; } },
3939
+ 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'; })();
3940
+ return altNoBleed && impOk && parsed && behav && lessonBehav; } },
3915
3941
  { 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
3942
  { 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
3943
  { 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 +4000,16 @@ function _selfTestCases() {
3974
4000
  { 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
4001
  { 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
4002
  { 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; } },
4003
+ { 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 아니므로 강제포함되면 안 된다. */
4004
+ envFamilyScan = _envFamNames.length > 0 && _envFamHits.length === _envFamNames.length && _envFamHits.every(Boolean);
4005
+ { const _neg = ['.environment', '.envx'];
4006
+ for (const _n of _neg) {
4007
+ const _t = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_envneg_'));
4008
+ try {
4009
+ fs.writeFileSync(path.join(_t, _n), 'x'.repeat(70 * 1024) + '\nAWS_ACCESS_KEY_ID=' + 'AKIAJQXMP7RZ2KL9WXYZ' + '\n');
4010
+ if ((_collectSecretFindings(_t).findings || []).some(f => f.file === _n)) envFamilyScan = false; // env-family 로 오인하면 실패
4011
+ } catch { envFamilyScan = false; } finally { try { fs.rmSync(_t, { recursive: true, force: true }); } catch {} }
4012
+ } } } const delegated = src.includes('return _gitignoreMatch(gi, fileRel)'); return semOk && envFamilyScan && delegated; } },
3978
4013
  { 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
4014
  { 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
4015
  { 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 +4732,21 @@ function _selfTestCases() {
4697
4732
  const _sec = 'module.exports={apiKey:"sk-test-1234567890abcdefghijklmnopqrstuvwxyz"};';
4698
4733
  fs.writeFileSync(path.join(_t15, 'small.js'), _sec);
4699
4734
  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 초과는 스캔 제외
4735
+ // 1.36.84 (검수 Medium#5): "findings big.js 가 없음"은 "읽지 않았음"의 증거가 아니다 —
4736
+ // size 검사 **앞에** eager read 줄을 넣어 1MB 초과 파일을 통째로 읽게 만들어도 findings 는 그대로라 가드가 통과했다.
4737
+ // → fs.readFileSync 를 spy 로 감싸 big.js 가 한 번도 읽히지 않았음을 직접 확인한다(small.js 는 양성 대조).
4738
+ // spy 는 프로세스 전역이므로 반드시 finally 에서 복원한다(복원 실패 시 이후 전 케이스가 오염된다).
4739
+ const _reads = [];
4740
+ const _origReadFileSync = fs.readFileSync;
4741
+ let _hits;
4742
+ try {
4743
+ fs.readFileSync = function (p, ...rest) { try { _reads.push(String(p)); } catch {} return _origReadFileSync.call(fs, p, ...rest); };
4744
+ _hits = _collectSecretFindings(_t15).findings || [];
4745
+ } finally { fs.readFileSync = _origReadFileSync; }
4746
+ const _bigRead = _reads.some(p => /big\.js$/.test(p)); // 선행 eager read 도 위반
4747
+ const _smallRead = _reads.some(p => /small\.js$/.test(p)); // spy 가 실제로 걸렸다는 양성 대조
4748
+ const _capOk = _hits.some(f => /small\.js/.test(f.file)) && !_hits.some(f => /big\.js/.test(f.file)) // 1MB 초과는 스캔 제외
4749
+ && _smallRead && !_bigRead; // + 애초에 읽지도 않음
4702
4750
  const _order = /statSync\(file\)[\s\S]{0,240}?size > 1024 \* 1024\)\s*continue;[\s\S]{0,600}?read(?:FileSync)?\(file\)/.test(src)
4703
4751
  && /statSync\(file\)\.size > 5 \* 1024 \* 1024\)\s*continue;[\s\S]{0,240}?readBuf\(file\)/.test(src)
4704
4752
  && /statSync\(fp2\)\.size > budget\)\s*continue;[\s\S]{0,160}?read\(fp2\)/.test(src);
@@ -4724,11 +4772,13 @@ function _selfTestCases() {
4724
4772
  return typeof m.reviewRequestCmd === 'function' && wired;
4725
4773
  } },
4726
4774
  { name: 'Karpathy 가이드라인4 (UR-0032): plan --done-when 검증가능 완료조건 저장/파싱/표시 (1.14.2)', run: () => {
4727
- const src = read(__filename);
4728
4775
  // 1.36.80 (가드 부패 수리): planAdd 의 doneWhen 기본값 한 줄을 통째로 박아둔 낡은 exact-literal 은 1.36.63 다국어화로
4729
4776
  // 제품 코드에서 사라졌고 이 가드 줄 자신에만 남아 includes 가 영원히 참이었다(자기참조 false-pass — --done-when 을 통째로 무시해도 초록).
4730
4777
  // 아래 dw 계산도 제품 파서가 아니라 이 케이스가 새로 쓴 정규식이라 아무것도 지키지 못했다.
4731
- // → plan add 를 실제 실행해 저장(plan.md)과 파싱(planListCmd)을 행위 검증. 표시/기본값은 언어별이라 불변식 정규식으로.
4778
+ // → plan add 를 실제 실행해 저장(plan.md)과 파싱(planListCmd)을 행위 검증.
4779
+ // 1.36.84 (검수 Medium#7): 표시/기본값이 아직 소스 정규식이라 기본값을 통째로 없애도(`|| ''`) 초록이었다 —
4780
+ // 이 케이스가 --done-when **있는** 경로만 실행했기 때문. → 옵션 없는 plan add 도 실제로 돌려 저장/파싱/표시
4781
+ // 세 지점에서 언어별 기본값((미정)/(unset))을 행위 검증한다. 자기 소스 읽기는 제거(더는 소스 존재로 판정하지 않음).
4732
4782
  let wired = false;
4733
4783
  const _tp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_dw_'));
4734
4784
  try {
@@ -4736,20 +4786,42 @@ function _selfTestCases() {
4736
4786
  fs.writeFileSync(path.join(_tp, '.harness', 'HARNESS_VERSION'), VERSION);
4737
4787
  const _cond = '로그인 e2e 테스트 통과';
4738
4788
  const _mtitle = 'Done-When 회귀가드';
4739
- cp.spawnSync(process.execPath, [__filename, 'plan', 'add', _mtitle, '--path', _tp, '--done-when', _cond, '--json'],
4789
+ const _mdefault = 'Done-When 기본값 회귀가드';
4790
+ const _spawn = a => cp.spawnSync(process.execPath, [__filename, ...a, '--path', _tp],
4740
4791
  { cwd: _tp, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
4792
+ // 1.36.84 (검수 M4): child 의 exit 를 버리면 "JSON 은 정상인데 exit 1" 을 못 잡는다 — status 를 단언한다.
4793
+ const _r1 = _spawn(['plan', 'add', _mtitle, '--done-when', _cond, '--json']);
4794
+ const _r2 = _spawn(['plan', 'add', _mdefault, '--json']); // 옵션 **없는** 기본값 경로도 실제 실행
4795
+ if (_r1.status !== 0 || _r2.status !== 0) throw new Error('plan add exit != 0');
4741
4796
  // 저장: 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 실제로 되돌려줌
4797
+ const _blocks = read(planPath(_tp)).replace(/\r\n/g, '\n').split(/\n(?=### M-\d{4,}\.)/);
4798
+ const _blockOf = t => _blocks.find(b => new RegExp('^### M-\\d{4,}\\. ' + t + '$', 'm').test(b)) || '';
4799
+ // 1.36.84 (검수 M4): `(미정|unset)` 합집합만 보면 **언어 매핑이 뒤바뀌어도** 통과한다(한국어에서 (unset) 저장 등).
4800
+ // 이 워크스페이스는 ko 이므로 ko 기본값을 정확히 요구한다(en 경로는 아래 _en 블록에서 따로 확인).
4801
+ const _DEFAULT_LINE = /^Done-When: \(미정\)[ \t]*$/m;
4802
+ const _stored = new RegExp('^Done-When: ' + _cond + '$', 'm').test(_blockOf(_mtitle))
4803
+ && _DEFAULT_LINE.test(_blockOf(_mdefault));
4804
+ // 파싱: 제품 파서(planListCmd)가 doneWhen 을 실제로 되돌려줌 — 기본값도 빈 값/인접줄 흡수가 아니어야
4745
4805
  let _out = ''; const _wr = process.stdout.write;
4746
4806
  try { process.stdout.write = x => { _out += x; return true; }; planListCmd(_tp, { json: true }); } finally { process.stdout.write = _wr; }
4747
4807
  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;
4808
+ const _dw = t => (_ms.find(m => m.title === t) || {}).doneWhen;
4809
+ const _parsed = _ms.length === 2 && _dw(_mtitle) === _cond && _dw(_mdefault) === '(미정)'; // ko 정확값(검수 M4)
4810
+ // 표시: 사람용 plan list 를 자식 프로세스로 실행(셀프테스트 argv 에 --json 이 있어 in-process 로는 사람용 경로가 안 나옴)
4811
+ const _human = _spawn(['plan', 'list']).stdout || '';
4812
+ const _shown = _human.includes('완료기준(Done-When): ' + _cond)
4813
+ && _human.includes('완료기준(Done-When): (미정)');
4814
+ // 1.36.84 (검수 M4): **en 분기 기본값**도 따로 검사 — ko 만 보면 en 기본값을 ''로 없애도 통과했다(실측).
4815
+ let _enOk = false;
4816
+ // 언어는 수동 파일이 아니라 실제 init(--language en)으로만 확정된다(수동 .harness/LANGUAGE·manifest 로는 미적용 — 실측).
4817
+ const _te = _selftestFixture('en'); // 1.36.85: 공유 en 픽스처
4818
+ try {
4819
+ if (!_te) { _enOk = false; throw new Error('fixture'); }
4820
+ const _re = cp.spawnSync(process.execPath, [__filename, 'plan', 'add', 'EN default guard', '--path', _te, '--json'],
4821
+ { cwd: _te, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
4822
+ _enOk = _re.status === 0 && /^Done-When: \(unset\)[ \t]*$/m.test(read(planPath(_te)).replace(/\r\n/g, '\n'));
4823
+ } catch { _enOk = false; } finally { try { fs.rmSync(_te, { recursive: true, force: true }); } catch {} }
4824
+ wired = _stored && _parsed && _shown && _enOk;
4753
4825
  } catch {} finally { try { fs.rmSync(_tp, { recursive: true, force: true }); } catch {} }
4754
4826
  return wired;
4755
4827
  } },
@@ -5003,6 +5075,82 @@ function _selfTestCases() {
5003
5075
  && src.includes("failJson(has('--json'), 'rule_not_found'");
5004
5076
  return restoreOk && planOk && fileReOk && analyzersOk && archiveOk && jsonOk;
5005
5077
  } },
5078
+ { name: 'brainstorm 줄번호 정확성 + 두 경로 동등 (1.36.85): 동일 내용 블록이 각자 위치로 보고 · 사람용/--json 일치 (행위)', run: () => {
5079
+ // 사고: 줄번호를 `text.indexOf(block)` 로 구해, 내용이 같은 블록이 둘이면 **둘 다 첫 번째 줄번호**를 보고했다.
5080
+ // (실측: 3행·10행의 동일 lesson 블록이 둘 다 line=3). 오프셋 누적으로 교체하며 이 가드를 세운다.
5081
+ // 함께 잡는 것: 이 수정 중 `_blocksWithOffset` import 를 빠뜨려 brainstorm 이 통째로 죽었는데도
5082
+ // selftest 338/338 이 초록이었다 — 실행 경로가 어떤 케이스에도 없었다. 그래서 **실제 명령**을 돌린다.
5083
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_bsline_'));
5084
+ try {
5085
+ mkdirp(path.join(d, '.harness'));
5086
+ writeUtf8(path.join(d, '.harness', 'HARNESS_VERSION'), VERSION);
5087
+ // (검수 1.36.85 M3) 1차판은 "서로 다르고 양수" 만 봐서, `_lineOfOffset` 을 `offset + 1` 로 바꿔
5088
+ // 엉뚱한 값이 나와도 통과했다(실측). **정확한 줄번호**를 단언한다.
5089
+ const KW = 'zzq' + 'unique';
5090
+ // 3행과 10행에 동일 내용 블록(사이에 다른 블록) — 기대 line = [3, 10]
5091
+ writeUtf8(path.join(d, '.harness', 'lessons.md'),
5092
+ `# Lessons\n\n### 2026-01-01\n- Lesson: ${KW} 교훈\n- Tag: t\n\n### 2026-02-02\n- Lesson: 사이에 낀 다른 것\n\n### 2026-01-01\n- Lesson: ${KW} 교훈\n- Tag: t\n`);
5093
+ // 같은 제목의 decision 도 3행·10행 — findIndex 기반 오보(검수 M1) 회귀 차단.
5094
+ // **코드펜스 블록을 앞에 둔다**: decision 추출은 펜스를 길이 보존 마스킹으로 제외하는데,
5095
+ // 길이가 바뀌는 방식(삭제)으로 되돌리면 이후 모든 오프셋이 밀린다 — 펜스가 없는 픽스처로는 그 회귀를 못 잡는다.
5096
+ const _fence = String.fromCharCode(96).repeat(3);
5097
+ writeUtf8(path.join(d, '.harness', 'decisions.md'),
5098
+ `# Decisions\n\n${_fence}md\n### 2099-12-31 — 펜스 안 예시(집계 제외)\n${_fence}\n\n### 2026-01-01 — ${KW} 결정\n- Decision: ${KW} 채택\n- Reason: r\n\n### 2026-02-02 — 다른 결정\n- Decision: x\n\n### 2026-01-01 — ${KW} 결정\n- Decision: ${KW} 채택\n- Reason: r\n`);
5099
+ const _run = (a) => cp.spawnSync(process.execPath, [__filename, ...a, '--path', d],
5100
+ { encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024 });
5101
+ const rj = _run(['brainstorm', KW, '--json']);
5102
+ if (rj.status !== 0) return false; // 명령 자체가 죽으면 실패(위 사고 재발 차단)
5103
+ const j = JSON.parse(rj.stdout.slice(rj.stdout.indexOf('{')));
5104
+ const _lines = (arr) => (arr || []).map(x => x.line);
5105
+ const lessonsOk = JSON.stringify(_lines(j.hits && j.hits.lessonsExplicit)) === '[3,10]';
5106
+ const decisionsOk = JSON.stringify(_lines(j.hits && j.hits.decisions)) === '[7,14]';
5107
+ // 사람용 경로는 별도 구현이라 같은 입력에 같은 건수 + 같은 줄번호를 내야 한다(중복 구현 드리프트 가드)
5108
+ const rh = _run(['brainstorm', KW]);
5109
+ const ht = rh.stdout || '';
5110
+ const humanOk = rh.status === 0 && /관련 lessons \(2\)/.test(ht) && /관련 결정 \(2\)/.test(ht)
5111
+ && /lessons\.md:3\b/.test(ht) && /lessons\.md:10\b/.test(ht)
5112
+ && /decisions\.md:7\b/.test(ht) && /decisions\.md:14\b/.test(ht);
5113
+ return lessonsOk && decisionsOk && humanOk;
5114
+ } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
5115
+ } },
5116
+ { name: '자기소스 검사 동결 (1.36.84): selftest 가 자기 소스를 읽어 검증하는 케이스는 더 늘지 않는다 — 신규는 행위검사로 (메타가드)', run: () => {
5117
+ // 왜 "탐지"가 아니라 "동결"인가:
5118
+ // 1.36.82~83 에서 자기참조 공허 가드를 탐지하려 세 라운드 연속 탐지기를 고쳤는데,
5119
+ // 그때마다 외부 검수가 새 우회를 찾아냈다(직접 호출 · fs.readFileSync · indexOf · 정규식 .test ·
5120
+ // alias/재할당/구조분해 · 표식 복사 · UI 문자열 · **읽는 파일의 무관한 위치에서 만족되는 리터럴**).
5121
+ // 마지막 형태는 존재 검사로는 원리적으로 판별 불가다(health 가드가 실제로 그랬다 — 커밋된 시크릿이
5122
+ // 있는데도 healthy:true 가 되게 깨뜨려도 337/337 초록이었다).
5123
+ // → 우회를 쫓는 대신 **부패할 수 있는 형태가 늘지 못하게** 막는다.
5124
+ //
5125
+ // **이 가드가 보장하는 것과 보장하지 않는 것** (검수 H1/H2 실측 — 과장하지 않는다):
5126
+ // 보장: `_selfTestCases` 영역에서 **지원 철자 2종**(io 의 read 헬퍼 / fs 의 readFileSync 를 __filename 에 직접
5127
+ // 적용한 형태)의 lexical 출현 횟수가 기준치를 넘지 않음. 들여쓰기 변경·기존 케이스 내부 추가도 걸린다.
5128
+ // 미보장: 별칭(변수에 __filename 을 담아 넘기기) · 동적 접근(대괄호 프로퍼티) · require 체인 · 구조분해 ·
5129
+ // 간접 호출(배열 순회로 넘기기). 주석/문자열 안의 같은 철자도 계수된다(오탐 방향 —
5130
+ // 그래서 이 주석은 지원 철자를 그대로 적지 않는다. 적었더니 실제로 카운트가 올라 자기 자신을 막았다).
5131
+ // "어떤 형태든 막는다"는 lexical 방식으로 달성 불가다 — 그렇게 적지 않는다.
5132
+ // 기존 206건은 유예(grandfathered)되며 **줄이는 방향으로만** 갱신한다. 신규 가드는 행위검사로 써야 한다.
5133
+ // 자기 제외 표식은 두지 않는다 — 표식 복사가 곧 우회가 된다(검수가 실증). 이 가드 자신도 셈에 포함한다.
5134
+ const src = read(__filename);
5135
+ const s = src.indexOf('function _selfTestCases(');
5136
+ if (s < 0) return false; // 영역을 못 찾으면 fail-closed
5137
+ const nextFn = src.indexOf('\nfunction ', s + 10);
5138
+ const region = src.slice(s, nextFn < 0 ? src.length : nextFn);
5139
+ // (검수 H2, 실측) 영역 경계가 **fail-open** 이었다: 영역 안 주석에 줄머리 `function` 한 줄만 넣으면
5140
+ // nextFn 이 거기서 끊겨 영역이 306KB→108B 로 줄고 카운트 0 이 되어 통과했다.
5141
+ // → 파싱된 케이스 수가 실제 케이스 수와 다르면 무조건 실패(fail-closed).
5142
+ const parsed = region.split(/\n(?=\s*\{ name: )/).slice(1).length;
5143
+ if (parsed !== _selfTestCases().length) return false;
5144
+ // 케이스 단위가 아니라 **출현 횟수**로 센다. 청크 분할 기준으로 세면
5145
+ // (a) 이미 자기소스를 쓰는 케이스에 한 줄 더 넣기 (b) 들여쓰기를 바꿔 분할을 빠져나가는 새 케이스
5146
+ // 두 우회가 뚫린다(둘 다 실측 확인). 출현 횟수는 분할에 의존하지 않아 둘 다 막는다.
5147
+ const SELF_READ = /(?:read\(__filename\)|fs\.readFileSync\(\s*__filename)/g;
5148
+ const n = (region.match(SELF_READ) || []).length;
5149
+ // 1.36.84 실측 **215 회 출현**(이 가드 자신 포함). 늘어나면 실패한다 — 줄였으면 반드시 이 수치를 내려 적을 것.
5150
+ // 여유를 남기면 그만큼 새 자기소스 검사가 조용히 들어온다(같은 라운드에 커버리지 수리로 한 건이 줄어
5151
+ // 임계값에 여유가 생긴 것을 실측으로 발견해 딱 맞췄다).
5152
+ return n <= 215;
5153
+ } },
5006
5154
  { name: 'DB 렌즈 recall (dogfood FN, 1.36.4): 내용기반 감지 — 평범한 이름의 DB 모듈 잡고 산문 FP 0 (행위)', run: () => {
5007
5155
  const T = _isDbContentText, W = _withDbDomain;
5008
5156
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -5288,9 +5436,9 @@ function _selfTestCases() {
5288
5436
  // → 실제 CLI 를 en 으로 돌려 헤딩이 영어로 렌더되는지 **행위**로 확인하고, ko 보존은 소스로 확인한다.
5289
5437
  let en = false;
5290
5438
  {
5291
- const _ed = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_en3_'));
5439
+ const _ed = _selftestFixture('en'); // 1.36.85: init spawn(1.3s) 대신 공유 픽스처 복사(78ms)
5292
5440
  try {
5293
- cp.spawnSync(process.execPath, [__filename, 'init', _ed, '--yes', '--no-env', '--no-stale-check', '--language', 'en'], { encoding: 'utf8', timeout: 90000 });
5441
+ if (!_ed) return false; // 픽스처 준비 실패는 조용히 통과시키지 않는다
5294
5442
  cp.spawnSync(process.execPath, [__filename, 'task', 'add', 'probe', '--path', _ed], { encoding: 'utf8', timeout: 60000 });
5295
5443
  const _md = read(path.join(_ed, '.harness', 'progress-tracker.md'));
5296
5444
  const _tid = [...String(_md).matchAll(/\|\s*(T-\d{4})\s*\|/g)].map(m => m[1]).pop();
@@ -16181,11 +16329,10 @@ function _brainstormFor(root, topic) {
16181
16329
  const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [], code: [], skillHistory: [], taskLogFails: [], lessonsExplicit: [], planMilestones: [] };
16182
16330
  const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
16183
16331
  const decLines = dec.split('\n');
16184
- for (const b of _extractDecisionBlocks(dec)) {
16332
+ for (const { block: b, line: _dln } of _decisionBlocksWithOffset(dec)) {
16185
16333
  if (matches(b)) {
16186
16334
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
16187
- const lineIdx = decLines.findIndex(line => line === `### ${t}`);
16188
- const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
16335
+ const lineNo = _dln;
16189
16336
  hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16190
16337
  }
16191
16338
  }
@@ -16224,12 +16371,11 @@ function _brainstormFor(root, topic) {
16224
16371
  }
16225
16372
  }
16226
16373
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16227
- for (const block of ev.split(/\n(?=## )/)) {
16374
+ for (const { block: block, line: _ln } of _blocksWithOffset(ev, /\n(?=## )/)) {
16228
16375
  if (!block.startsWith('## ')) continue;
16229
16376
  if (matches(block)) {
16230
16377
  const t = (block.match(/^## (.+)$/m) || [, ''])[1];
16231
- const idx = ev.indexOf(block);
16232
- const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
16378
+ const lineNo = _ln;
16233
16379
  hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16234
16380
  if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
16235
16381
  }
@@ -16238,12 +16384,11 @@ function _brainstormFor(root, topic) {
16238
16384
  const histPath = path.join(root, '.harness', 'skill-suggestions.md');
16239
16385
  if (exists(histPath)) {
16240
16386
  const histTxt = read(histPath);
16241
- for (const block of histTxt.split(/\n(?=## )/)) {
16387
+ for (const { block: block, line: _ln } of _blocksWithOffset(histTxt, /\n(?=## )/)) {
16242
16388
  if (!block.startsWith('## ')) continue;
16243
16389
  const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
16244
16390
  if (h && matches(block)) {
16245
- const idx = histTxt.indexOf(block);
16246
- const lineNo = idx >= 0 ? histTxt.slice(0, idx).split('\n').length : 0;
16391
+ const lineNo = _ln;
16247
16392
  hits.skillHistory.push({ at: h[1], query: h[2], preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16248
16393
  }
16249
16394
  }
@@ -16252,12 +16397,11 @@ function _brainstormFor(root, topic) {
16252
16397
  const lp_brainstorm = lessonsPath(root);
16253
16398
  if (exists(lp_brainstorm)) {
16254
16399
  const lessonsText = read(lp_brainstorm);
16255
- for (const block of lessonsText.split(/\n(?=### )/)) {
16400
+ for (const { block: block, line: _ln } of _blocksWithOffset(lessonsText, /\n(?=### )/)) {
16256
16401
  if (!block.startsWith('### ')) continue;
16257
16402
  const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
16258
16403
  if (lessonMatch && matches(block)) {
16259
- const idx = lessonsText.indexOf(block);
16260
- const lineNo = idx >= 0 ? lessonsText.slice(0, idx).split('\n').length : 0;
16404
+ const lineNo = _ln;
16261
16405
  hits.lessonsExplicit.push({ title: lessonMatch[1].trim().slice(0, 120), preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16262
16406
  }
16263
16407
  }
@@ -16266,12 +16410,11 @@ function _brainstormFor(root, topic) {
16266
16410
  const planFile_brainstorm = planPath(root);
16267
16411
  if (exists(planFile_brainstorm)) {
16268
16412
  const planText = read(planFile_brainstorm);
16269
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
16270
- for (const b of milestoneBlocks) {
16413
+ const milestoneBlocks = _blocksWithOffset(planText, /\n(?=### M-\d{4,}\.)/);
16414
+ for (const { block: b, line: _ln } of milestoneBlocks) {
16271
16415
  const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
16272
16416
  if (m && matches(b)) {
16273
- const idx = planText.indexOf(b);
16274
- const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
16417
+ const lineNo = _ln;
16275
16418
  hits.planMilestones.push({ id: m[1], title: m[2].trim().slice(0, 120), preview: b.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16276
16419
  }
16277
16420
  }
@@ -16300,14 +16443,13 @@ function _brainstormFor(root, topic) {
16300
16443
  const fp = path.join(root, '.harness', src.file);
16301
16444
  if (!exists(fp)) continue;
16302
16445
  const txt = read(fp);
16303
- const blocks = txt.split(/\n(?=## 제거 )/);
16304
- for (const b of blocks) {
16446
+ const blocks = _blocksWithOffset(txt, /\n(?=## 제거 )/);
16447
+ for (const { block: b, line: _ln } of blocks) {
16305
16448
  const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
16306
16449
  if (!m) continue;
16307
16450
  if (matches(b)) {
16308
16451
  const headerMatch = b.match(/^### (.+)$/m);
16309
- const idx = txt.indexOf(b);
16310
- const lineNo = idx >= 0 ? txt.slice(0, idx).split('\n').length : 0;
16452
+ const lineNo = _ln;
16311
16453
  hits.archive[src.key].push({
16312
16454
  date: m[1],
16313
16455
  target: m[2],
@@ -16428,11 +16570,10 @@ function brainstormCmd(root, topic) {
16428
16570
  // decisions (1.9.14: 코드블록/Template 제외, 1.9.15: 라인 번호)
16429
16571
  const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
16430
16572
  const decLines = dec.split('\n');
16431
- for (const b of _extractDecisionBlocks(dec)) {
16573
+ for (const { block: b, line: _dln } of _decisionBlocksWithOffset(dec)) {
16432
16574
  if (matches(b)) {
16433
16575
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
16434
- const lineIdx = decLines.findIndex(line => line === `### ${t}`);
16435
- const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
16576
+ const lineNo = _dln;
16436
16577
  hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16437
16578
  }
16438
16579
  }
@@ -16476,12 +16617,11 @@ function brainstormCmd(root, topic) {
16476
16617
  }
16477
16618
  // evidence — lessons 키워드 (fail/롤백/incomplete) 동반 (1.9.15: 라인 번호)
16478
16619
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16479
- for (const block of ev.split(/\n(?=## )/)) {
16620
+ for (const { block: block, line: _ln } of _blocksWithOffset(ev, /\n(?=## )/)) {
16480
16621
  if (!block.startsWith('## ')) continue;
16481
16622
  if (matches(block)) {
16482
16623
  const t = (block.match(/^## (.+)$/m) || [, ''])[1];
16483
- const idx = ev.indexOf(block);
16484
- const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
16624
+ const lineNo = _ln;
16485
16625
  hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16486
16626
  if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
16487
16627
  }
@@ -16491,12 +16631,11 @@ function brainstormCmd(root, topic) {
16491
16631
  if (exists(histPath)) {
16492
16632
  const histTxt = read(histPath);
16493
16633
  let pos = 0;
16494
- for (const block of histTxt.split(/\n(?=## )/)) {
16634
+ for (const { block: block, line: _ln } of _blocksWithOffset(histTxt, /\n(?=## )/)) {
16495
16635
  if (!block.startsWith('## ')) { pos += block.length + 1; continue; }
16496
16636
  const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
16497
16637
  if (h && matches(block)) {
16498
- const idx = histTxt.indexOf(block);
16499
- const lineNo = idx >= 0 ? histTxt.slice(0, idx).split('\n').length : 0;
16638
+ const lineNo = _ln;
16500
16639
  hits.skillHistory.push({ at: h[1], query: h[2], preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16501
16640
  }
16502
16641
  }
@@ -16524,14 +16663,13 @@ function brainstormCmd(root, topic) {
16524
16663
  const fp = path.join(root, '.harness', src.file);
16525
16664
  if (!exists(fp)) continue;
16526
16665
  const txt = read(fp);
16527
- const blocks = txt.split(/\n(?=## 제거 )/);
16528
- for (const b of blocks) {
16666
+ const blocks = _blocksWithOffset(txt, /\n(?=## 제거 )/);
16667
+ for (const { block: b, line: _ln } of blocks) {
16529
16668
  const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
16530
16669
  if (!m) continue;
16531
16670
  if (matches(b)) {
16532
16671
  const headerMatch = b.match(/^### (.+)$/m);
16533
- const idx = txt.indexOf(b);
16534
- const lineNo = idx >= 0 ? txt.slice(0, idx).split('\n').length : 0;
16672
+ const lineNo = _ln;
16535
16673
  hits.archive[src.key].push({
16536
16674
  date: m[1],
16537
16675
  target: m[2],
@@ -16548,12 +16686,11 @@ function brainstormCmd(root, topic) {
16548
16686
  const lp_b2 = lessonsPath(root);
16549
16687
  if (exists(lp_b2)) {
16550
16688
  const lessonsText = read(lp_b2);
16551
- for (const block of lessonsText.split(/\n(?=### )/)) {
16689
+ for (const { block: block, line: _ln } of _blocksWithOffset(lessonsText, /\n(?=### )/)) {
16552
16690
  if (!block.startsWith('### ')) continue;
16553
16691
  const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
16554
16692
  if (lessonMatch && matches(block)) {
16555
- const idx = lessonsText.indexOf(block);
16556
- const lineNo = idx >= 0 ? lessonsText.slice(0, idx).split('\n').length : 0;
16693
+ const lineNo = _ln;
16557
16694
  hits.lessonsExplicit.push({ title: lessonMatch[1].trim().slice(0, 120), preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16558
16695
  }
16559
16696
  }
@@ -16561,12 +16698,11 @@ function brainstormCmd(root, topic) {
16561
16698
  const planFile_b2 = planPath(root);
16562
16699
  if (exists(planFile_b2)) {
16563
16700
  const planText = read(planFile_b2);
16564
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
16565
- for (const b of milestoneBlocks) {
16701
+ const milestoneBlocks = _blocksWithOffset(planText, /\n(?=### M-\d{4,}\.)/);
16702
+ for (const { block: b, line: _ln } of milestoneBlocks) {
16566
16703
  const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
16567
16704
  if (m && matches(b)) {
16568
- const idx = planText.indexOf(b);
16569
- const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
16705
+ const lineNo = _ln;
16570
16706
  hits.planMilestones.push({ id: m[1], title: m[2].trim().slice(0, 120), preview: b.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16571
16707
  }
16572
16708
  }
package/lib/pure-utils.js CHANGED
@@ -246,6 +246,19 @@ function _extractDecisionBlocks(text) {
246
246
  );
247
247
  }
248
248
 
249
+ // 1.36.85 (검수 M1): decision 블록 + **정확한 오프셋/줄번호**.
250
+ // 기존 소비부는 줄번호를 `decLines.findIndex(line => line === '### ' + title)` 로 구해,
251
+ // 같은 제목의 decision 이 둘이면 **둘 다 첫 번째 위치**를 보고했다(실측 [3,3], 기대 [3,10]).
252
+ // `_extractDecisionBlocks` 는 코드펜스를 **삭제**해 길이가 변하므로 오프셋을 쓸 수 없다 →
253
+ // 여기서는 같은 길이의 공백으로 **마스킹**해 원문과 1:1 대응을 유지한다(펜스 안의 `### ` 은 여전히 헤딩으로
254
+ // 인식되지 않고, 펜스 내용은 매칭에도 걸리지 않는다 — 기존 의도 보존).
255
+ function _decisionBlocksWithOffset(text) {
256
+ const raw = String(text || '');
257
+ const masked = raw.replace(/^```[^\n]*\n[\s\S]*?\n```[ \t]*$/gm, (m) => m.replace(/[^\n]/g, ' '));
258
+ return _blocksWithOffset(masked, /\n(?=### )/)
259
+ .filter(x => x.block.startsWith('### ') && !/^### (Template|템플릿)\b/.test(x.block.trim()));
260
+ }
261
+
249
262
  // 1.9.325 (UR-0025): 순수 intent 분류 — 사용자 텍스트의 precise/broad 신호로 의도 추정 (fs/상태 의존 0).
250
263
  function _classifyIntent(text) {
251
264
  if (!text || typeof text !== 'string') return { intent: 'default', signals: [] };
@@ -402,6 +415,30 @@ function _briefBlueprint(brief, version) {
402
415
  return L.join('\n');
403
416
  }
404
417
 
418
+ // 1.36.85: 블록 분할 + **정확한 오프셋**. brainstorm 계열이 줄번호를 `text.indexOf(block)` 으로 구했는데,
419
+ // 내용이 같은 블록이 둘 이상이면 항상 **첫 번째** 위치를 돌려줘 서로 다른 항목이 같은 줄번호로 보고됐다
420
+ // (실측: 3행·9행의 동일 lesson 블록이 둘 다 line=3). 순회하며 누적한 오프셋은 그 오류가 없다.
421
+ // sep 은 `/\n(?=…)/` 형태의 lookahead 분할을 전제한다 — 조각들은 '\n' 로 다시 이어붙으면 원문이 되므로
422
+ // 각 조각의 시작 = 앞 조각들의 길이 합 + (제거된 개행 수). 다른 형태의 sep 에는 쓰지 말 것.
423
+ // (검수 1.36.85 M4) `line` 도 **순회 중 누적**한다 — 블록마다 처음부터 slice+split 로 재스캔하면 O(n²) 라
424
+ // 대량 파일에서 사실상 멈춘다(실측: lesson 40k건 ≈ 62s). 누적은 O(n).
425
+ function _blocksWithOffset(text, sep) {
426
+ const out = [];
427
+ let pos = 0, line = 1;
428
+ for (const block of String(text || '').split(sep)) {
429
+ out.push({ block, offset: pos, line });
430
+ // 이 조각이 소비한 줄 수 + 분할에서 사라진 '\n' 1줄
431
+ line += (block.match(/\n/g) || []).length + 1;
432
+ pos += block.length + 1;
433
+ }
434
+ return out;
435
+ }
436
+ // 오프셋 → 1-기반 줄번호.
437
+ function _lineOfOffset(text, offset) {
438
+ if (!(offset >= 0)) return 0;
439
+ return String(text || '').slice(0, offset).split('\n').length;
440
+ }
441
+
405
442
  // 1.9.332 (UR-0025): 순수 lessons.md 파서 — 블록(### 날짜)→엔트리 {date, text, tag}. 필터는 호출측.
406
443
  function _parseLessonEntries(text) {
407
444
  const out = [];
@@ -1060,7 +1097,7 @@ function _renderPulseLine(data) {
1060
1097
  function _parseImplExports(src) {
1061
1098
  const out = new Set();
1062
1099
  const add = n => { if (n && /^[A-Za-z_$][\w$]*$/.test(n)) out.add(n); };
1063
- // 1) module.exports = { ... } — 브레이스 균형 + top-level 키
1100
+ // 1) module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset, ... } — 브레이스 균형 + top-level 키
1064
1101
  const re = /module\.exports\s*=\s*\{/g; let mm;
1065
1102
  while ((mm = re.exec(src))) {
1066
1103
  const i = src.indexOf('{', mm.index); let depth = 0, end = -1;
@@ -1251,7 +1288,7 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1251
1288
  return [...lines.slice(0, start + 1), '', ...newBodyLines, '', ...lines.slice(end)].join('\n');
1252
1289
  }
1253
1290
 
1254
- module.exports = {
1291
+ module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
1255
1292
  _parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
1256
1293
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
1257
1294
  _isSecretKey, compareVer, parseHarnessVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.83",
3
+ "version": "1.36.85",
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 {}
@@ -4628,7 +4633,10 @@ total++;
4628
4633
  let ok = false;
4629
4634
  try {
4630
4635
  const ni = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-noinit-')); // .harness 없는 비초기화 dir
4631
- const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 30000 });
4636
+ // 1.36.85: selftest 소스문자열 검사 **행위 검사**로 바뀌며 4초대 → 약 13초가 됐다(CLI spawn 비용).
4637
+ // 30s 는 4초 시절 기준이라 전체 e2e 부하에서 타임아웃으로 터졌다(격리 재현 3/3 통과 = 검사 자체는 정상).
4638
+ // 여유를 넉넉히 준다 — 이 블록이 재는 것은 "비초기화 dir 에서도 통과하는가"이지 속도가 아니다.
4639
+ const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 180000 });
4632
4640
  const sout = (sr.stdout || '') + (sr.stderr || '');
4633
4641
  const selftestOk = sr.status === 0 && /전체 \d+건 통과/.test(sout) && !/설치 손상/.test(sout);
4634
4642
  const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 30000 });
@@ -5041,7 +5049,8 @@ total++;
5041
5049
  {
5042
5050
  let ok = false;
5043
5051
  try {
5044
- const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { encoding: 'utf8', timeout: 30000 });
5052
+ // 1.36.85: 행위검사 전환으로 selftest 13s 4초 시절 기준 30s 는 전체 부하에서 터진다.
5053
+ const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { encoding: 'utf8', timeout: 180000 });
5045
5054
  const j = JSON.parse(r.stdout);
5046
5055
  ok = j.ok === true && j.pass === j.total && j.fail === 0 && j.total >= 112 && r.status === 0;
5047
5056
  } catch {}