leerness 1.35.14 → 1.35.16

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,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.35.16 — 2026-07-07 — codex 협업 라운드 ⑥: lazy detect(안티-치트) 적대적 헌트 — 우회/FP/FN 5종 수정
4
+
5
+ **사용자 지시 "codex와 협업" 라운드 ⑥ — gate 임베드 `lazy detect`(안티-게으름/안티-치트) 적대적 헌트.** 이 체크의 주 사용자는 AI 에이전트 자신이라 **우회 인센티브가 직접적**. 자체 프로브 + codex(gpt-5.5 xhigh) 독립 헌터로 바이패스(FN)+FP 양방향 사냥. 재현으로 확정한 것만 수정.
6
+
7
+ ### 확정 수정
8
+ - **FP — `no_test_run` 이 비-JS 프로젝트를 오탐.** `hasTestRun` 정규식이 JS+pytest 중심 → **Go/Rust/Ruby/PHP/.NET/Java/Elixir/Swift/Dart/C 프로젝트가 `cargo test`/`go test`/`rspec`/`phpunit`/`dotnet test`/`mvn`/`gradle` 등으로 실제 테스트를 돌려도** no_test_run 발화. done 작업이 있는 active 프로젝트에선 **blocking → gate 거짓 실패**. 러너 목록 확장(negative finding 억제라 FP-safe). 재현 확정.
9
+ - **FN #6 + FP #10 — TODO 스캐너 따옴표 상태 오판.** 구 `isInsideQuote`(per-line 따옴표 패리티)가 (a) 큰따옴표 안의 아포스트로피(`"don't"`)를 여는-따옴표로 세어 **뒤의 진짜 TODO 를 스킵(FN·고의 우회 벡터)**, (b) **여러 줄 템플릿 리터럴 안의 TODO 를 문자열 밖으로 오판(FP·blocking)**. 파일 전체를 문자 단위로 걸으며 실제 문자열 경계를 추적(백틱은 줄 넘김, `'`/`"`는 개행 종료)하는 상태머신으로 교체 — 문자열 밖 TODO 만 카운트. 둘 다 재현 확정.
10
+ - **우회 #3 — `evidence_missing` 이 status 를 `=== 'done'` 로만 검사.** `_hasDoneWork` 는 `/^(done|completed|verified)$/i` 인데 evidence 검사는 정확-`'done'` 만 → **`status=completed`(유효한 CLI 값)로 두면 done-work 로는 세면서 evidence 는 검사 안 하던 우회**. 동일 done-집합(대소문자무관)으로 정합. 재현 확정.
11
+ - **우회 #4 — 쓰레기 evidence 통과.** trivial evidence 토큰(`n/a`/`tbd`/`todo`/`wip`/`none`/`x`/점·물음표만) 추가 — 비-empty 껍데기 문자열로 통과하던 구멍 축소(lazy 는 lenient, verify-claim 이 strict 담당이므로 최소한만).
12
+ - **우회 #9 — blocked 행 좁은 검사.** status 대소문자무관(`Blocked`) + nextAction trivial 토큰(빈값/`-`/`n/a`/`none`/`tbd`)도 `blocker_no_next_action` 으로 감지.
13
+
14
+ ### 미채택 (by-design / 저빈도)
15
+ - 대소문자 TODO(`todo`/`@todo`)는 `const todo` 등 FP 위험으로 대소문자-민감 유지(codex #7 동의). 무-done 회피(#5)는 fresh-프로젝트 leniency(1.9.323)의 의도된 트레이드오프. TODO 파일경로 substring 억제(#8)는 coarse-but-intended.
16
+
17
+ ### 검증
18
+ - selftest 273. e2e-core **24**(신규 lazy 픽스처 4). full e2e **384**. 게시본 1.35.16 재실증.
19
+
20
+ ## 1.35.15 — 2026-07-07 — codex 협업 라운드 ⑤: encoding check 적대적 헌트 — 파괴적 --apply + FP/FN 5종 수정
21
+
22
+ **사용자 지시 "codex와 협업" 라운드 ⑤ — gate 임베드 `encoding check` + 변형 `env encoding-check --apply` 적대적 헌트.** 한국어-우선 사용자에게 CP949 오손은 실사용 영향이 큼. 자체 프로브 + 실제 cmd.exe 런타임 실측 + codex(gpt-5.5 xhigh, 웹검색 포함) 독립 헌터 교차.
23
+
24
+ ### 확정 수정 (자체 프로브 — 읽기전용 gate 체크)
25
+ - **FP — 순수-ASCII `.bat` 이 gate 를 거짓 실패.** `.bat missing chcp 65001` 이 내용과 무관하게 모든 `.bat` 에 발화 → `@echo off` 만 있는 ASCII 배치도 exit 1. 비-ASCII 바이트가 있을 때만 경고하도록 게이팅(CP949 오손 위험이 실제로 있을 때만).
26
+ - **FN — `.cmd` 가 gate 에서 미스캔.** `.cmd` 가 `SCAN_TEXT_EXT` 에 없어 encodingCheck/scanSecrets 가 `.cmd` 를 전혀 안 봄(반면 `env encoding-check` 는 `.cmd` 스캔 → 불일치). `.cmd` 추가 + `.bat` 와 동일 규칙(chcp 검사 · BOM 예외) 적용.
27
+
28
+ ### 확정 수정 (codex 독립 헌터 교차 — 재현 후 채택)
29
+ - **codex #4 DESTRUCTIVE — `--apply` 가 CP949 파일을 손상.** `env encoding-check --apply` 가 비-ASCII+no-BOM 파일에 transcode 없이 UTF-8 BOM 만 붙임 → CP949 본문에 BOM 을 붙이면 'UTF-8 이라 주장하는 CP949' 손상본(mojibake 악화). **재현 확정**(CP949 `.ps1` → BOM+CP949). 수정: **유효 UTF-8 본문일 때만 BOM 추가**, 비-UTF-8 은 skip(수동 transcode 안내). 추가로 `.bat/.cmd` 는 BOM 이 cmd.exe 코드페이지를 안 바꾸므로 skip(첫 줄 `chcp 65001` 안내) — 이제 `--apply` 는 유효-UTF-8 `.ps1`(PS5.1 이 BOM 으로 UTF-8 인식) 에만 작용. `.sh`/shebang 가드(1.9.409)와 동류.
30
+ - **codex #7 FN — BOM 이 roundtrip 경고를 억제.** 읽기전용 체크의 `&& !hasBOM` 가 BOM+깨진본문(예: 위 --apply 가 만든 손상본)을 통째 통과시킴. 제거 → BOM 있어도 본문이 invalid UTF-8 이면 탐지(유효 UTF-8+BOM 은 byte-exact roundtrip 이라 오탐 0, 실증).
31
+ - **codex #6 FN — NUL 을 첫 4096B 만 검사.** 전체 버퍼(≤5MB, 이미 로드됨) 스캔으로 확대 — 긴 ASCII 헤더 뒤 NUL 미탐 차단.
32
+
33
+ ### 맹신 X 반증 / 미채택
34
+ - codex #2 "BOM 이 cmd.exe 첫 명령을 깨뜨림"(구형 Windows SuperUser 인용) → **실제 Win11 cmd.exe 실측 결과 BOM 있는 .bat 정상 실행**(refuted, 플랫폼 의존). 파괴적 아님 → --apply 의 .bat/.cmd 는 BOM 미추가로 정리(무익하고 구형에선 위험할 수 있어서, 파괴 근거로는 미채택). #8(C2A1 우연 유효-UTF-8)은 CP949 디코더 없이 불가(0-deps) → by-design.
35
+
36
+ ### 검증
37
+ - selftest 273. e2e-core **20**(신규 CLI 픽스처 4: 순수-ASCII .bat clean · mojibake .cmd 탐지 · --apply CP949 무손상 · --apply 유효 .ps1 BOM). full e2e **384**. 게시본 1.35.15 재실증.
38
+
3
39
  ## 1.35.14 — 2026-07-06 — codex 협업 라운드 ④: scan secrets 적대적 헌트 — 하드코딩 자격증명 FN 2종 수정
4
40
 
5
41
  **사용자 지시 "codex와 협업" 라운드 ④ — 보안-임계 `scan secrets` 적대적 FP/FN 헌트.** gate 에 임베드된 보안 가드라 FN=시크릿 유출, FP=정직 빌드 파손. codex(gpt-5.5 xhigh)를 독립 헌터로 백그라운드 실행하고, Claude 자체 프로브 매트릭스(24 픽스처)를 로컬 스캐너에 병렬로 돌려 교차. **하드코딩 자격증명 패턴의 고빈도 FN·FP 5종 확정·수정.**
package/README.md CHANGED
@@ -125,7 +125,7 @@ MIT
125
125
  <!-- leerness:project-readme:start -->
126
126
  ## Leerness Project Harness
127
127
 
128
- 이 프로젝트는 Leerness v1.35.14 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
128
+ 이 프로젝트는 Leerness v1.35.16 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
129
129
 
130
130
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
131
131
 
@@ -179,7 +179,7 @@ leerness memory restore decision <date|title>
179
179
 
180
180
  ### MCP server (외부 AI 통합)
181
181
 
182
- Leerness v1.35.14는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
182
+ Leerness v1.35.16는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
183
183
 
184
184
  ```jsonc
185
185
  // 카테고리별
@@ -200,7 +200,7 @@ Leerness v1.35.14는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
200
200
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
201
201
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
202
202
 
203
- 현재 누적: **70 라운드 (1.9.40 → 1.35.14)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
203
+ 현재 누적: **70 라운드 (1.9.40 → 1.35.16)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
204
204
 
205
205
  ### 성능 가이드 (1.9.140 측정)
206
206
 
@@ -238,6 +238,6 @@ leerness release pack --close --auto-main-push
238
238
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
239
239
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
240
240
 
241
- Last synced by Leerness v1.35.14: 2026-07-06
241
+ Last synced by Leerness v1.35.16: 2026-07-07
242
242
  <!-- leerness:project-readme:end -->
243
243
 
package/bin/leerness.js CHANGED
@@ -32,7 +32,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
32
32
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
33
33
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
34
34
 
35
- const VERSION = '1.35.14';
35
+ const VERSION = '1.35.16';
36
36
 
37
37
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
38
38
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -2590,6 +2590,17 @@ function envCmd(root, sub) {
2590
2590
  result.applied.push({ file: r.file, action: 'skipped-shebang (BOM은 shebang을 깨뜨림 — .sh는 no-BOM UTF-8 유지)' });
2591
2591
  continue;
2592
2592
  }
2593
+ // 1.35.15 (encoding 헌트, codex #2): .bat/.cmd 는 BOM 추가 금지 — cmd.exe 는 BOM 으로 코드페이지를 바꾸지 않음(첫 줄 'chcp 65001' 이 올바른 해법). BOM 은 무익하고 일부 Windows 에선 첫 명령을 깨뜨림 → 거짓 수정 대신 chcp 안내.
2594
+ if (/\.(bat|cmd)$/i.test(r.file)) {
2595
+ result.applied.push({ file: r.file, action: 'skipped-batch (cmd.exe: 첫 줄에 chcp 65001 추가가 정답 — BOM은 코드페이지를 안 바꿈)' });
2596
+ continue;
2597
+ }
2598
+ // 1.35.15 (encoding 헌트, codex #4 DESTRUCTIVE): 본문이 유효 UTF-8 일 때만 BOM 추가. CP949/Latin-1 등 비-UTF-8 본문에 BOM 만 붙이면 'UTF-8 이라 주장하는 CP949' 손상본이 됨(transcode 없이 파괴적) → skip + 수동 transcode 안내. (mojibake 를 더 악화시키던 자기-파괴 차단, .sh 가드와 동류.)
2599
+ const isValidUtf8 = Buffer.from(orig.toString('utf8'), 'utf8').equals(orig);
2600
+ if (!isValidUtf8) {
2601
+ result.applied.push({ file: r.file, action: 'skipped-nonutf8 (본문이 비-UTF-8(CP949 등) — BOM 추가는 손상. UTF-8 로 먼저 transcode 필요)' });
2602
+ continue;
2603
+ }
2593
2604
  const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
2594
2605
  const fixed = Buffer.concat([bom, orig]);
2595
2606
  fs.writeFileSync(fullPath, fixed);
@@ -8232,7 +8243,8 @@ function isSkippedRel(rel, extras = []) {
8232
8243
  if (segs.some(s => SCAN_SKIP_DIRS.has(s))) return true; // SCAN_SKIP_DIRS 는 Set
8233
8244
  return extras.some(d => rel === d || rel.startsWith(d + '/'));
8234
8245
  }
8235
- const SCAN_TEXT_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.json','.json5','.jsonc','.md','.txt','.env','.bash','.sh','.yml','.yaml','.toml','.ini','.cfg','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php','.sql','.html','.css','.scss','.less','.xml','.bat','.ps1','']);
8246
+ // 1.35.15 (encoding 헌트 FN): '.cmd' 추가 — .bat 와 기능 동일한 Windows 배치인데 누락되어 encodingCheck/scanSecrets 가 .cmd 를 전혀 스캔 안 하던 미탐(env encoding-check 는 .cmd 를 스캔했어 불일치).
8247
+ const SCAN_TEXT_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.json','.json5','.jsonc','.md','.txt','.env','.bash','.sh','.yml','.yaml','.toml','.ini','.cfg','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php','.sql','.html','.css','.scss','.less','.xml','.bat','.cmd','.ps1','']);
8236
8248
  function* walk(root, base = root, depth = 0, extras = null) {
8237
8249
  if (depth > 12) return;
8238
8250
  if (extras === null) extras = getExtraSkipDirs(root);
@@ -8339,20 +8351,25 @@ function encodingCheck(root, opts = {}) {
8339
8351
  if (buf.length === 0) continue;
8340
8352
  if (buf.length > 5 * 1024 * 1024) continue;
8341
8353
  const fileRel = rel(root, file);
8342
- if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) { if (ext !== '.ps1' && ext !== '.bat') { warnings++; findings.push({ file: fileRel, issue: 'UTF-8 BOM' }); } } // 1.9.353 (UR-0067 외부리뷰): .ps1/.bat 는 PS5.1 호환 위해 BOM 의도적일 수 있어 예외 (env encoding 정책과 모순 해소)
8354
+ if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) { if (ext !== '.ps1' && ext !== '.bat' && ext !== '.cmd') { warnings++; findings.push({ file: fileRel, issue: 'UTF-8 BOM' }); } } // 1.9.353 (UR-0067 외부리뷰): .ps1/.bat/.cmd 는 PS5.1 호환/배치 BOM 의도적일 수 있어 예외 (1.35.15: .cmd 추가 — .bat 와 동일 취급, env encoding 정책과 정합)
8343
8355
  else if ((buf[0] === 0xFF && buf[1] === 0xFE) || (buf[0] === 0xFE && buf[1] === 0xFF)) { warnings++; findings.push({ file: fileRel, issue: 'UTF-16 BOM' }); }
8344
- let nul = false; for (let i = 0; i < Math.min(buf.length, 4096); i++) if (buf[i] === 0) { nul = true; break; }
8345
- if (nul) { warnings++; findings.push({ file: fileRel, issue: 'NUL byte (binary in text path)' }); }
8346
- if (ext === '.bat') {
8347
- const text = buf.toString('utf8').replace(/^/, '');
8348
- if (!/^@?chcp\s+65001/i.test(text.split(/\r?\n/, 1)[0] || '')) { warnings++; findings.push({ file: fileRel, issue: '.bat missing chcp 65001' }); }
8356
+ // 1.35.15 (encoding 헌트 FN #6): NUL 4096B 보던 것을 전체 버퍼(≤5MB, 이미 메모리 로드됨) 확대 ASCII 헤더 NUL 숨은 이진 파일 미탐 차단.
8357
+ if (buf.includes(0)) { warnings++; findings.push({ file: fileRel, issue: 'NUL byte (binary in text path)' }); }
8358
+ if (ext === '.bat' || ext === '.cmd') {
8359
+ // 1.35.15 (encoding 헌트 FP): 순수-ASCII 배치는 CP949 오손 위험이 없어 chcp 불필요 → 비-ASCII 바이트가 있을 때만 경고(이전엔 @echo off 만 있는 ASCII .bat 도 무조건 발화해 gate 를 거짓 실패시킴). + .cmd 도 .bat 와 동일 검사.
8360
+ let hasNonAscii = false;
8361
+ for (let i = 0; i < Math.min(buf.length, 4096); i++) if (buf[i] >= 0x80) { hasNonAscii = true; break; }
8362
+ if (hasNonAscii) {
8363
+ const text = buf.toString('utf8').replace(/^/, '');
8364
+ if (!/^@?chcp\s+65001/i.test(text.split(/\r?\n/, 1)[0] || '')) { warnings++; findings.push({ file: fileRel, issue: `${ext} missing chcp 65001` }); }
8365
+ }
8349
8366
  }
8350
8367
  // 1.9.353 (UR-0067 외부리뷰): 가-힣 게이트 제거 — CP949/Latin-1 등 invalid UTF-8 은 한글 포함 여부와 무관하게 round-trip 불일치로 감지(이전: 한글 없으면 통과 = false negative). 유효 UTF-8 은 byte-exact round-trip 이라 오탐 없음.
8368
+ // 1.35.15 (encoding 헌트 FN #7): '&& !hasBOM' 제거 — BOM 이 있어도 본문이 깨진(CP949) 파일은 round-trip 불일치라 탐지해야 함. 기존 가드는 BOM+CP949 본문(예: --apply 가 CP949 파일에 BOM 만 붙여 만든 손상본)을 통째 통과시켰음. 유효 UTF-8(+BOM 포함)은 byte-exact round-trip 이라 오탐 0(실증).
8351
8369
  try {
8352
8370
  const text = buf.toString('utf8');
8353
8371
  const reBuf = Buffer.from(text, 'utf8');
8354
- const hasBOM = buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
8355
- if (!reBuf.equals(buf) && !hasBOM) {
8372
+ if (!reBuf.equals(buf)) {
8356
8373
  warnings++; findings.push({ file: fileRel, issue: 'invalid UTF-8 roundtrip (CP949/mojibake 의심)' });
8357
8374
  }
8358
8375
  } catch {}
@@ -8384,9 +8401,15 @@ function lazyDetect(root, opts = {}) {
8384
8401
  const rows = readProgressRows(root);
8385
8402
  // 1.9.1 P6: evidence가 단독 plan:M-XXXX 한 줄일 때만 검증 부족 처리.
8386
8403
  // "tests:32/32 (plan:M-0002)" 같이 검증 키워드를 같이 적은 경우는 통과.
8387
- for (const r of rows) if (r.status === 'done' && (!r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4}\s*$/.test(r.evidence))) {
8388
- issues++; _warn(`done row without verifiable evidence: ${r.id} (${r.request})`,
8389
- { kind: 'evidence_missing', severity: 'warn', taskId: r.id, request: r.request });
8404
+ // 1.35.16 (lazy 헌트 우회 #3+#4): (1) evidence 검사를 _hasDoneWork 와 동일한 done-집합(done|completed|verified, 대소문자무관)으로 정합 — 기존 `=== 'done'` status=completed/verified/Done 을 놓쳐, done-work 로는 세면서 evidence 안 보던 우회. (2) trivial evidence 토큰(n/a, tbd, todo, wip, none, x, 점만/물음표만) 추가 — 비-empty 쓰레기 문자열로 통과하던 구멍 축소.
8405
+ for (const r of rows) {
8406
+ if (!/^(done|completed|verified)$/i.test(r.status || '')) continue;
8407
+ const _ev = (r.evidence || '').trim();
8408
+ const _trivial = !_ev || /^(user-request|n\/?a|tbd|todo|wip|none|nil|x|-+|\.+|\?+)$/i.test(_ev) || /^plan:M-\d{4}$/i.test(_ev);
8409
+ if (_trivial) {
8410
+ issues++; _warn(`done row without verifiable evidence: ${r.id} (${r.request})`,
8411
+ { kind: 'evidence_missing', severity: 'warn', taskId: r.id, request: r.request });
8412
+ }
8390
8413
  }
8391
8414
  if (rows.length === 0) { issues++; _warn('progress-tracker is empty (no tasks tracked)',
8392
8415
  { kind: 'progress_empty', severity: 'warn' }); }
@@ -8400,17 +8423,10 @@ function lazyDetect(root, opts = {}) {
8400
8423
  { kind: 'handoff_empty', severity: 'warn' });
8401
8424
  }
8402
8425
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
8403
- const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress)\b/i.test(ev);
8426
+ // 1.35.16 (lazy 헌트 FP): JS+pytest 중심 목록에 비-JS 러너 추가 — Go/Rust/Ruby/PHP/.NET/Java/Elixir/Swift/Dart/C 등이 실제 테스트를 돌려도 no_test_run 오탐(active 프로젝트에선 blocking → gate 거짓 실패)하던 것 차단. (negative finding 억제라 추가는 FP-safe.)
8427
+ const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress|go test|cargo test|cargo nextest|rspec|rake test|rake spec|phpunit|dotnet test|mvn test|mvn verify|gradle test|gradlew test|bun test|deno test|mocha|ctest|make test|make check|mix test|swift test|flutter test|dart test|tox|unittest|nose2)\b/i.test(ev);
8404
8428
  if (!hasTestRun) { issues++; _warn('review-evidence.md has no recorded test/typecheck/lint run',
8405
8429
  { kind: 'no_test_run', severity: 'warn' }); }
8406
- // 1.9.4 C: TODO/FIXME가 string literal 안에 있으면 제외 (정규식 패턴 자체 등 false positive).
8407
- function isInsideQuote(line, idx) {
8408
- const pre = line.slice(0, idx);
8409
- const sq = (pre.match(/(?<!\\)'/g) || []).length;
8410
- const dq = (pre.match(/(?<!\\)"/g) || []).length;
8411
- const bq = (pre.match(/(?<!\\)`/g) || []).length;
8412
- return (sq % 2 === 1) || (dq % 2 === 1) || (bq % 2 === 1);
8413
- }
8414
8430
  // 1.9.7 C: TODO 자동 추적 강화 — 위치+텍스트 캡처, known-todos 비교, --auto-track 등록
8415
8431
  const knownPath = path.join(root, '.harness/known-todos.json');
8416
8432
  let knownList = [];
@@ -8427,18 +8443,33 @@ function lazyDetect(root, opts = {}) {
8427
8443
  if (/[\\/]bin[\\/]harness\.js$/.test(file)) continue;
8428
8444
  let text; try { text = read(file); } catch { continue; }
8429
8445
  const lines = text.split('\n');
8430
- const tre = /\bTODO\b|\bFIXME\b|\bXXX\b/g;
8446
+ const fileRel = rel(root, file);
8447
+ // 1.35.16 (lazy 헌트 FN #6 + FP #10): 구 isInsideQuote(per-line 따옴표 패리티)는 (a) 큰따옴표 안의 아포스트로피("don't")를 여는-따옴표로 오인해 뒤의 진짜 TODO 를 스킵(FN·고의 우회 벡터), (b) 여러 줄 템플릿 리터럴 안의 TODO 를 문자열 밖으로 오판(FP·blocking). 파일 전체를 문자 단위로 걸으며 실제 문자열 상태를 추적(백틱 문자열은 줄 넘김, '/"는 개행에서 종료)해 '문자열 밖' TODO/FIXME/XXX 만 카운트.
8448
+ let open = null; // 현재 열린 문자열 종류 (null | ' | " | `)
8431
8449
  for (let i = 0; i < lines.length; i++) {
8432
- tre.lastIndex = 0;
8433
- let m;
8434
- while ((m = tre.exec(lines[i]))) {
8435
- if (isInsideQuote(lines[i], m.index)) continue;
8436
- todoCount++;
8437
- const txt = lines[i].trim().slice(0, 120);
8438
- const fileRel = rel(root, file);
8439
- const key = `${fileRel}:${i + 1}:${txt}`;
8440
- if (!knownSet.has(key)) newTodos.push({ file: fileRel, line: i + 1, text: txt });
8450
+ const line = lines[i];
8451
+ for (let k = 0; k < line.length; k++) {
8452
+ const c = line[k];
8453
+ if (open) {
8454
+ if (c === '\\') { k++; continue; } // 문자열 내부 이스케이프
8455
+ if (c === open) open = null;
8456
+ continue;
8457
+ }
8458
+ if (c === '\\') { k++; continue; }
8459
+ if (c === '"' || c === "'" || c === '`') { open = c; continue; }
8460
+ // 문자열 밖: 이 위치에서 TODO/FIXME/XXX 시작 + 앞쪽 word-boundary
8461
+ if ((c === 'T' || c === 'F' || c === 'X') && (k === 0 || !/\w/.test(line[k - 1]))) {
8462
+ const mm = /^(TODO|FIXME|XXX)\b/.exec(line.slice(k));
8463
+ if (mm) {
8464
+ todoCount++;
8465
+ const txt = line.trim().slice(0, 120);
8466
+ const key = `${fileRel}:${i + 1}:${txt}`;
8467
+ if (!knownSet.has(key)) newTodos.push({ file: fileRel, line: i + 1, text: txt });
8468
+ k += mm[1].length - 1;
8469
+ }
8470
+ }
8441
8471
  }
8472
+ if (open === '"' || open === "'") open = null; // 비-템플릿 문자열은 개행에서 종료(JS 문법상 개행 불가)
8442
8473
  }
8443
8474
  }
8444
8475
  if (todoCount > 0) {
@@ -8476,11 +8507,12 @@ function lazyDetect(root, opts = {}) {
8476
8507
  }
8477
8508
  }
8478
8509
  }
8479
- const blockers = rows.filter(r => r.status === 'blocked');
8480
- for (const b of blockers) if (b.nextAction === '없음' || /다음 액션 작성/.test(b.nextAction)) {
8510
+ // 1.35.16 (lazy 헌트 우회 #9): status 대소문자무관(Blocked) + nextAction trivial 토큰(빈값/-/n/a/none/tbd/점만) 도 blocker_no_next_action 으로 감지(기존 '없음'/'다음 액션 작성' 만).
8511
+ const blockers = rows.filter(r => /^blocked$/i.test(r.status || ''));
8512
+ for (const b of blockers) { const _na = (b.nextAction || '').trim(); if (!_na || _na === '없음' || /다음 액션 작성/.test(b.nextAction) || /^(-+|n\/?a|none|nil|tbd|todo|wip|x|\.+|\?+)$/i.test(_na)) {
8481
8513
  issues++; _warn(`blocker without nextAction: ${b.id}`,
8482
8514
  { kind: 'blocker_no_next_action', severity: 'warn', taskId: b.id });
8483
- }
8515
+ } }
8484
8516
  // 1.9.323 (UR-0054 ⑥, 설치리뷰): fresh/무작업 프로젝트 gate 통과 — 작업 흔적(done/completed/verified) 없으면
8485
8517
  // "부재" 신호(progress empty / handoff 미생성·빈 / 테스트 미실행)는 비차단(어드바이저리). active 프로젝트는 기존대로 차단.
8486
8518
  // 배경: leerness init 직후 leerness gate 가 빈 트래커·미생성 handoff 때문에 즉시 실패하던 UX 결함.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.35.14",
3
+ "version": "1.35.16",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -101,6 +101,70 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
101
101
  fs.rmSync(d, { recursive: true, force: true });
102
102
  }
103
103
 
104
+ // (6) encoding check: 순수-ASCII .bat clean(FP 차단) / Korean mojibake .cmd 탐지(FN, .cmd 스캔) (1.35.15)
105
+ {
106
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-enc-'));
107
+ // 순수-ASCII .bat(@echo off, chcp 없음)은 CP949 오손 위험이 없어 clean 이어야 함(이전엔 무조건 실패시키던 FP)
108
+ fs.writeFileSync(path.join(d, 'ascii.bat'), '@echo off\r\necho hello world\r\n', 'latin1');
109
+ assert('encoding: pure-ASCII .bat clean (no false chcp warning) → exit 0', cp.spawnSync(process.execPath, [CLI, 'encoding', 'check', d], { encoding: 'utf8', timeout: 40000 }).status === 0);
110
+ // Korean mojibake(CP949 0xC7 0xD1) .cmd 는 이제 스캔되어 탐지되어야 함(이전엔 .cmd 미스캔 FN)
111
+ fs.writeFileSync(path.join(d, 'korean.cmd'), Buffer.from([0x40, 0x65, 0x63, 0x68, 0x6f, 0x20, 0xC7, 0xD1, 0x0d, 0x0a]));
112
+ assert('encoding: Korean mojibake .cmd flagged (.cmd now scanned) → exit 1', cp.spawnSync(process.execPath, [CLI, 'encoding', 'check', d], { encoding: 'utf8', timeout: 40000 }).status === 1);
113
+ fs.rmSync(d, { recursive: true, force: true });
114
+ }
115
+
116
+ // (7) env encoding-check --apply: CP949 .ps1 을 손상시키지 않고(BOM만 붙이는 파괴 차단) 유효 UTF-8 .ps1 만 BOM 추가 (1.35.15 codex #4 DESTRUCTIVE)
117
+ {
118
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-apply-'));
119
+ // CP949 본문(Write-Host "안녕", 안=BE C8 녕=B3 E7) — --apply 가 BOM 만 붙이면 손상되므로 skip 되어 파일이 그대로여야 함
120
+ const cp949 = Buffer.concat([Buffer.from('Write-Host "', 'latin1'), Buffer.from([0xBE, 0xC8, 0xB3, 0xE7]), Buffer.from('"\r\n', 'latin1')]);
121
+ fs.writeFileSync(path.join(d, 'cp949.ps1'), cp949);
122
+ cp.spawnSync(process.execPath, [CLI, 'env', 'encoding-check', '--apply', '--path', d], { encoding: 'utf8', timeout: 40000 });
123
+ const after = fs.readFileSync(path.join(d, 'cp949.ps1'));
124
+ assert('encoding --apply: CP949 .ps1 NOT mutated (no destructive BOM-on-CP949)', after.equals(cp949) && !(after[0] === 0xEF && after[1] === 0xBB && after[2] === 0xBF));
125
+ // 유효 UTF-8 .ps1(한글, no BOM)은 정당하게 BOM 추가되어야 함
126
+ fs.writeFileSync(path.join(d, 'utf8.ps1'), Buffer.from('Write-Host "안녕"\r\n', 'utf8'));
127
+ cp.spawnSync(process.execPath, [CLI, 'env', 'encoding-check', '--apply', '--path', d], { encoding: 'utf8', timeout: 40000 });
128
+ const u = fs.readFileSync(path.join(d, 'utf8.ps1'));
129
+ const uValid = Buffer.from(u.toString('utf8'), 'utf8').equals(u);
130
+ assert('encoding --apply: valid-UTF-8 .ps1 gets BOM (still valid)', u[0] === 0xEF && u[1] === 0xBB && u[2] === 0xBF && uValid);
131
+ fs.rmSync(d, { recursive: true, force: true });
132
+ }
133
+
134
+ // (8) lazy detect: 안티-치트 우회/FP 가드 (1.35.16)
135
+ {
136
+ const BT = String.fromCharCode(96);
137
+ const jget = (d) => { const r = run(d, ['lazy', 'detect', '--json']); try { return JSON.parse(r.stdout); } catch { return null; } };
138
+ const hasK = (j, k) => !!(j && j.findings.some(f => f.kind === k));
139
+ // FP: cargo test 기록 → no_test_run 오탐 아님 (비-JS 러너 인식)
140
+ {
141
+ const d = fresh();
142
+ run(d, ['task', 'add', 'rust']); run(d, ['task', 'update', 'T-0002', '--status', 'done', '--evidence', 'src/lib.rs done']);
143
+ fs.appendFileSync(path.join(d, '.harness/review-evidence.md'), '\n```\ncargo test --all\n42 passed; 0 failed\n```\n');
144
+ assert('lazy: cargo test recognized (no false no_test_run)', !hasK(jget(d), 'no_test_run'));
145
+ fs.rmSync(d, { recursive: true, force: true });
146
+ }
147
+ // 우회 #3/#4: status=completed + trivial evidence → evidence_missing 발화
148
+ {
149
+ const d = fresh();
150
+ run(d, ['task', 'add', 'y']); run(d, ['task', 'update', 'T-0002', '--status', 'completed', '--evidence', 'n/a']);
151
+ assert('lazy: completed + n/a evidence → evidence_missing (status-consistency + trivial)', hasK(jget(d), 'evidence_missing'));
152
+ fs.rmSync(d, { recursive: true, force: true });
153
+ }
154
+ // FN #6: 아포스트로피 문자열 뒤 TODO 카운트 + FP #10: 멀티라인 템플릿 내 TODO 미카운트
155
+ {
156
+ const d = fresh();
157
+ fs.writeFileSync(path.join(d, 'a.js'), 'const m = "don' + String.fromCharCode(39) + 't"; // TODO: real\n');
158
+ const j1 = jget(d);
159
+ assert('lazy: TODO after apostrophe-string counted (FN fix)', j1 && j1.todoCount === 1);
160
+ fs.rmSync(path.join(d, 'a.js'), { force: true });
161
+ fs.writeFileSync(path.join(d, 'b.js'), 'const s = ' + BT + '\n TODO: inside template\n' + BT + ';\n');
162
+ const j2 = jget(d);
163
+ assert('lazy: TODO inside multi-line template NOT counted (FP fix)', j2 && j2.todoCount === 0);
164
+ fs.rmSync(d, { recursive: true, force: true });
165
+ }
166
+ }
167
+
104
168
  const dur = ((Date.now() - t0) / 1000).toFixed(1);
105
169
  console.log(`\nCore result: ${total - failed}/${total} passed · ${dur}s`);
106
170
  if (failed > 0) process.exit(1);