leerness 1.36.82 → 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 +42 -0
- package/README.md +4 -4
- package/bin/leerness.js +344 -36
- package/package.json +1 -1
- package/scripts/e2e.js +12 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
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
|
+
|
|
26
|
+
## 1.36.83 — 2026-07-28 — 자기참조 가드 부채 상환 (탐지 가능 범위 12 → 0) · 소스 문자열 검사를 행위 검사로
|
|
27
|
+
|
|
28
|
+
1.36.82 가 `baseline 12` 로 **유예**했던 자기참조 소스가드를 갚고 baseline 을 **0 으로 조였다**(유예 수치를 남기면 그 안에서 조용히 썩는다).
|
|
29
|
+
|
|
30
|
+
**주장의 정확한 범위** — 검수(25회전)가 "잔여 0" 이 과장임을 반례로 보였고, 그 지적이 맞았다. 정확히는 **"직접 선언된 `read(__filename)` 수신자의 codeish 리터럴 `.includes` 탐지 결과 0"** 이다. 메타가드가 아직 못 보는 형태가 남아 있다: `read(__filename).includes(...)` 직접 호출 · `fs.readFileSync(__filename)` · `indexOf`/정규식 `.test` · alias/재할당/구조분해 · 표식 복사. 이번 라운드에 실측으로 드러난 세 사각지대(UI 문자열처럼 codeish 가 아닌 리터럴 · `.test(정규식)` 형태 · 읽는 파일엔 있지만 **무관한 위치**에서 만족되는 리터럴)는 해당 가드를 개별 수리했으나, 메타가드의 일반 탐지로는 아직 잡히지 않는다. 다음 라운드 과제로 남긴다.
|
|
31
|
+
|
|
32
|
+
- **12건을 현재 코드 기준으로 재작성 — 대부분 소스 문자열 검사를 행위 검사로 교체.** 실제 junction 을 만들어 migrate 복사가 심링크를 따라가지 않는지, 70KB `.env.production` 의 시크릿이 탐지되는지, `plan add --done-when` 이 저장·파싱되는지를 **직접 실행해** 확인한다. 문자열은 리팩터로 사라지지만 동작은 사라지지 않는다. 각 가드는 "지키던 동작을 실제로 깨뜨렸을 때 실패한다"를 변이로 증명했고, **대조군**(같은 변이에서 원본 가드는 통과)까지 붙였다 — 예: 심링크 미추종은 `lstatSync`→`statSync` 한 글자로 기능이 완전히 깨지는데 종전 가드는 337/337 초록이었다. 보고서에 적힌 것과 **다른 방식의 변이**(skip 기록 제거 / env 정규식 축소)로도 정확히 실패함을 확인해 과적합이 아님을 검증했다.
|
|
33
|
+
- **메타가드를 파일-인지로 강화 — 3건이 더 드러났다.** 리터럴이 "제품 어딘가"에 있으면 통과시키던 판정을, **가드가 실제로 읽는 파일** 기준으로 바꿨다. `read(__filename)`(=bin)을 검사하는데 구현이 `lib/` 로 옮겨갔다면 그 가드는 여전히 자기 줄만 매칭한다: drift 의 "최신 Last generated"(→`lib/drift.js`) · decision 필드 파싱(→`lib/pure-utils.js`) · constraints 호출부(인자가 하나 늘어 정확 리터럴이 어긋남). 셋 다 수리.
|
|
34
|
+
- **파일을 넘나든 자기참조** — `scripts/e2e.js` 의 constraints 단언이 bin 에서 리터럴을 찾는데, 그 문자열의 **유일한 출처가 bin 안의 selftest 가드 줄**이었다. 위 수정으로 그 줄이 사라지자 게이트가 실패해 공허함이 드러났다(부작용이 아니라 의도한 효과). 불변식으로 교체하고, 같은 클래스가 `e2e.js`/`e2e-core.js` 에 더 있는지 전수 확인(1건이 유일).
|
|
35
|
+
- **도구가 세 번 틀렸고 세 번 다 개별 확인으로 잡았다**: ① `$'` 가 `String.replace` 치환문자열에서 "매치 이후 문자열"로 해석돼 패치 하나가 잘렸다(문법 검사가 잡아 자동 원복 → 나머지 12건도 원문 그대로인지 전수 확인 후 함수 치환자로 재적용) ② 탐지기의 **단어 경계 누락**으로 `s.includes` 가 `tps.includes` 에 걸려 정상 가드를 결함으로 지목(일괄 수정했다면 멀쩡한 가드를 망가뜨렸다) ③ 템플릿 보간 리터럴(`` `_requireInit(root, '${l}')` ``)은 런타임 치환이라 원문 비교가 불가능한데 "제품에 없음"으로 오탐 — 이 클래스를 판정에서 제외.
|
|
36
|
+
**검수 25회전이 잡은 것** (전건 직접 재현):
|
|
37
|
+
|
|
38
|
+
- **High — 내가 이 라운드에 만든 회귀**: 템플릿 보간을 판정에서 제외하면서 `${` 가 든 리터럴을 **따옴표까지 싸잡아** 제외해 16개 가드가 무방비가 됐다. 보간은 **백틱에서만** 일어난다 — 따옴표 안의 `'${...}'` 는 그냥 문자열이라 검사해야 한다. 검수가 시연한 우회(skill 덮어쓰기 보호를 `if (false)` 로 무력화)가 수정 후 정확히 차단됨을 실측.
|
|
39
|
+
- **High — health 보안 연결이 무방비**: 가드가 bin 을 읽는데 검증 대상은 `lib/health.js` 에 있었고, `_collectSecretFindings(root)`/`committedSecrets` 는 bin 의 **무관한 다른 호출**이, 한국어 문구는 가드 자신의 줄이 만족시켰다. 실제로 스캐너 연결을 끊어 **커밋된 시크릿이 있는데도 `healthy: true`** 가 나오게 만들어도 337/337 초록이었다(실측 확정). → 실제 시크릿을 심은 워크스페이스로 `health --json` 을 돌려 `committedSecrets≥1 · critical · healthy:false` 를 행위 검증.
|
|
40
|
+
- **Medium — "잔여 0" 반례 3건**: `## File check`/`## Test count`(실제 출력은 이모지가 들어가 정확 리터럴이 어긋남) · `/# leerness doctor/.test(src)`(정규식이 자기 줄을 매칭, 실제 헤더는 `lib/diagnostics.js`). 셋 다 행위 검사/올바른 파일로 교체.
|
|
41
|
+
- 검수가 지적한 나머지(신규 행위검사의 커버리지 부족 — `.env.local` 미포함 · 선행 eager read 미탐 · lesson 파서 미검증 · `--done-when` 기본값 경로 · e2e exit 무시 · README 관리블록 버전)는 **다음 라운드 과제로 명시**한다. 이번 라운드에 고친 것만 고쳤다고 적는다.
|
|
42
|
+
|
|
43
|
+
- 검증: selftest 337 · e2e 406/406 · 가드별 변이 증명(대조군 포함) · 교차 변이로 과적합 아님 확인 · 메타가드 4방향(따옴표/백틱 자기참조 차단 · 정상 가드 무오탐) · baseline 경계 시험(12 통과 / 11 실패 → 수치가 추정이 아니라 측정값임을 확인한 뒤 0 으로 전환) · 검수 시연 우회 2건 차단 실측.
|
|
44
|
+
|
|
3
45
|
## 1.36.82 — 2026-07-28 — 공허한 가드 클래스 스윕: 날조된 완료 주장이 통과하던 fail-open · 안내만 되고 없던 플래그 · 자기참조 소스가드
|
|
4
46
|
|
|
5
47
|
1.36.81 의 교훈("셋업이 조용히 실패해도 부정 단언은 통과한다")을 **클래스로 확장**해, "통과하지만 아무것도 증명하지 않는 가드"를 전수 스윕했다(5개 렌즈 · 변이 테스트로 실행 확정). 보고 8건 중 직접 재현한 것만 반영했고, **수치가 과장된 보고는 정정**했다(자기참조 가드: 보고 42건 → 실측 12건. 내 1차 탐지기도 `lib/` 를 빼먹어 39건으로 과대보고했다가 교정).
|
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 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.
|
|
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.82는 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.
|
|
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.
|
|
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.
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -3442,7 +3442,31 @@ function _selfTestCases() {
|
|
|
3442
3442
|
const forceGuard = s.includes("const effForce = opts.force && !_USER_STATE.has(f);") && s.includes("'.harness/progress-tracker.md', '.harness/plan.md', '.harness/task-log.md'");
|
|
3443
3443
|
const skillGuard = s.includes('유효하지 않은 skill name (path traversal/경로 문자 차단)') && s.includes('이미 설치된 skill: ${skillId} — 내용이 다릅니다');
|
|
3444
3444
|
const settingsGuard = s.includes('settings.local.json 이 손상돼(JSON 파싱 실패) hook 설치를 중단');
|
|
3445
|
-
|
|
3445
|
+
// 1.36.83 (공허가드 스윕): 종전 정확-리터럴 가드는 이 줄 자신만 매칭해 기능을 지워도 초록이었다.
|
|
3446
|
+
// 불변식 정규식(심링크면 push 후 중단) + **행위검사**(실제 junction/symlink 를 따라가지 않는지)로 대체.
|
|
3447
|
+
// 판별력: lstat→stat 로 되돌리면 소스 정규식은 그대로 통과하지만 행위검사가 잡는다(실측).
|
|
3448
|
+
let symlinkGuard = /isSymbolicLink\(\)\s*\)\s*\{[^\n]*skippedFiles\.push\([^\n]*symlink/.test(s);
|
|
3449
|
+
{
|
|
3450
|
+
const _st = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sym_'));
|
|
3451
|
+
let _linked = false;
|
|
3452
|
+
try {
|
|
3453
|
+
fs.mkdirSync(path.join(_st, '.harness'), { recursive: true });
|
|
3454
|
+
fs.writeFileSync(path.join(_st, '.harness', 'HARNESS_VERSION'), VERSION);
|
|
3455
|
+
fs.mkdirSync(path.join(_st, 'outside'), { recursive: true });
|
|
3456
|
+
fs.writeFileSync(path.join(_st, 'outside', 'a.txt'), 'A');
|
|
3457
|
+
fs.symlinkSync(path.join(_st, 'outside'), path.join(_st, '.harness', 'loop'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
3458
|
+
_linked = true;
|
|
3459
|
+
} catch {} // 심링크 생성 권한 없는 환경 → 소스 불변식만으로 판정(false-BLOCK 회피)
|
|
3460
|
+
if (_linked) {
|
|
3461
|
+
try {
|
|
3462
|
+
const _rep = _migrateWorkspaceDir(_st, { dryRun: true });
|
|
3463
|
+
const _skipped = _rep.skippedFiles.some(f => /^loop\b/.test(f) && f.includes('symlink'));
|
|
3464
|
+
const _notFollowed = !_rep.copiedFiles.some(f => /^loop[\\/]/.test(f));
|
|
3465
|
+
symlinkGuard = symlinkGuard && _skipped && _notFollowed;
|
|
3466
|
+
} catch { symlinkGuard = false; }
|
|
3467
|
+
}
|
|
3468
|
+
try { fs.rmSync(_st, { recursive: true, force: true }); } catch {}
|
|
3469
|
+
}
|
|
3446
3470
|
const copySafe = /function copyRecursiveSafe[\s\S]{0,400}lstatSync/.test(s);
|
|
3447
3471
|
return forceGuard && skillGuard && settingsGuard && symlinkGuard && copySafe;
|
|
3448
3472
|
} },
|
|
@@ -3872,13 +3896,26 @@ function _selfTestCases() {
|
|
|
3872
3896
|
} },
|
|
3873
3897
|
{ name: 'MCP notification 준수: id없는 요청 무응답 가드 + ping {} (UR-0049 설치리뷰 1.9.313)', run: () => { const src = read(__filename); const guard = src.includes("const isNotification = !('id' in req)") && src.includes("req.method.startsWith('notifications/')") && src.includes('if (isNotification) return;'); const ping = src.includes("req.method === 'ping'") && /ping[\s\S]{0,140}result: \{\} \}/.test(src); return guard && ping; } },
|
|
3874
3898
|
{ name: 'PowerShell 감지: pwsh7(channel/Documents\\PowerShell/install) + ps5.1 영구경로 과경고 안함 (UR-0052 설치리뷰 1.9.314)', run: () => { const f = _detectPwshFromEnv; const pwsh7a = f({ POWERSHELL_DISTRIBUTION_CHANNEL: 'MSI:Windows 10' }).version === '7'; const pwsh7b = f({ PSModulePath: 'C:\\Users\\me\\Documents\\PowerShell\\Modules' }).version === '7'; const pwsh7c = f({ PSModulePath: 'C:\\Program Files\\PowerShell\\7\\Modules' }).version === '7'; const noFalsePs5 = f({ PSModulePath: 'C:\\Users\\me\\Documents\\WindowsPowerShell\\Modules' }).isPowerShell === false; const cmdSys = f({ PSModulePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\Modules' }).isPowerShell === false; const empty = f({}).isPowerShell === false; const src = read(__filename); const wired = src.includes('const fromEnv = _detectPwshFromEnv()') && src.includes('const pwshEnv = _detectPwshFromEnv()'); return pwsh7a && pwsh7b && pwsh7c && noFalsePs5 && cmdSys && empty && wired; } },
|
|
3875
|
-
{ name: 'doc/surface 정합: doctor 명령 + stale MCP 카운트 동적화(commands/banner) (UR-0054 설치리뷰 1.9.315)', run: () => { const src = read(__filename); const doctorOk = typeof doctorCmd === 'function' && /cmd === 'doctor'/.test(src) && /# leerness doctor/.test(
|
|
3876
|
-
{ name: 'drift 마커 버그: session-handoff 프론트매터는 ^--- 일 때만 + drift 최신 Last generated (1.9.316)', run: () => { const src = read(__filename); const scSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'session-close.js')); const writeFix = scSrc.includes('if (/^---\\r?\\n/.test(cur))') && scSrc.includes('writeUtf8(handoffPath(root), frontmatter + block)');
|
|
3899
|
+
{ name: 'doc/surface 정합: doctor 명령 + stale MCP 카운트 동적화(commands/banner) (UR-0054 설치리뷰 1.9.315)', run: () => { const src = read(__filename); /* 1.36.83 (검수 Medium#1): `/# leerness doctor/.test(src)` 는 **이 가드 줄 자신**을 매칭했다 — 실제 헤더는 lib/diagnostics.js 에 있다. 구현 파일을 읽는다. */ const doctorOk = typeof doctorCmd === 'function' && /cmd === 'doctor'/.test(src) && /# leerness doctor/.test(read(path.join(path.dirname(__filename), '..', 'lib', 'diagnostics.js'))); const dynCount = /MCP 도구: \$\{_mcpToolCount\(\)\}/.test(src) && /외부 AI 통합 \(MCP \$\{_mcpToolCount\(\)\} 도구\)/.test(src); return doctorOk && dynCount; } },
|
|
3900
|
+
{ name: 'drift 마커 버그: session-handoff 프론트매터는 ^--- 일 때만 + drift 최신 Last generated (1.9.316)', run: () => { const src = read(__filename); const scSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'session-close.js')); const writeFix = scSrc.includes('if (/^---\\r?\\n/.test(cur))') && scSrc.includes('writeUtf8(handoffPath(root), frontmatter + block)'); /* 1.36.82: 이 가드는 bin(src)을 검사했지만 구현은 lib/drift.js 로 옮겨갔다 — 리터럴이 가드 자신의 줄에만 남아
|
|
3901
|
+
자기참조로 통과했다(원 버그를 되살려도 초록). 읽는 파일을 실제 구현 위치로 바로잡는다. */
|
|
3902
|
+
const drSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'drift.js'));
|
|
3903
|
+
const readFix = drSrc.includes('matchAll(/Last generated') && drSrc.includes('allGen[allGen.length - 1]'); return writeFix && readFix; } },
|
|
3877
3904
|
{ name: '텔레메트리 분리: 내부 auto-call(LEERNESS_INTERNAL) usage 집계 제외 + 주요 spawn 마킹 (UR-0051 설치리뷰 1.9.317)', run: () => { const src = read(__filename); const guard = src.includes("process.env.LEERNESS_INTERNAL !== '1'"); const marked = (src.match(/LEERNESS_INTERNAL: '1'/g) || []).length >= 10; const reviewMarked = /'review-request'[\s\S]{0,200}LEERNESS_INTERNAL: '1'/.test(src); return guard && marked && reviewMarked; } },
|
|
3878
3905
|
{ name: 'lib/pure-utils: HTML 파싱 유틸 3종 모듈 분리 + 동작 + 인라인 제거 (UR-0025 1.9.318)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._htmlToText === 'function' && typeof m._extractTitle === 'function' && typeof m._extractLinks === 'function'; const work = m._htmlToText('<p>Hello <b>World</b></p>') === 'Hello World' && m._extractTitle('<html><title>My & Page</title></html>') === 'My & Page' && m._extractLinks('<a href="/a">A</a><a href="https://other.com/b">B</a>', 'https://x.com/').length === 1; const moved = m._htmlToText === _htmlToText && !/^function _htmlToText\(html\) \{/m.test(read(__filename)); return fnOk && work && moved; } },
|
|
3879
3906
|
{ name: 'MCP ToolRegistry 일치성: 모든 도구 def 가 dispatch case 보유 + 고아 case 0 + requiredTier 완비 (UR-0044 1.9.319)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const missing = tools.filter(t => !src.includes("case '" + t.name + "':")); const cases = [...src.matchAll(/case '(leerness_[a-z_]+)':/g)].map(m => m[1]); const defNames = new Set(tools.map(t => t.name)); const orphans = [...new Set(cases)].filter(c => !defNames.has(c)); const tierOk = tools.every(t => typeof t.requiredTier === 'string' && PERMISSION_TIERS.includes(t.requiredTier)); return tools.length >= 83 && missing.length === 0 && orphans.length === 0 && tierOk; } },
|
|
3880
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; } },
|
|
3881
|
-
{ 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() === '보안';
|
|
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
|
+
구현 파일을 읽고, 소스 문자열만이 아니라 **실제 파서 동작**으로도 확인한다(빈 필드가 다음 줄을 먹지 않는가). */
|
|
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 는 보존)로 직접 검증한다. */
|
|
3915
|
+
const parsed = require('../lib/pure-utils')._extractDecisionBlocks ? true : false;
|
|
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() === '보안'; })();
|
|
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; } },
|
|
3882
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; } },
|
|
3883
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; } },
|
|
3884
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; } },
|
|
@@ -3890,7 +3927,7 @@ function _selfTestCases() {
|
|
|
3890
3927
|
{ name: 'lib/pure-utils: project-brief config 분리(_BRIEF_FIELDS/_briefFilled) + 인라인 제거 (UR-0025 1.9.330)', run: () => { const m = require('../lib/pure-utils'); const cfgOk = Array.isArray(m._BRIEF_FIELDS) && m._BRIEF_FIELDS.length === 10 && m._BRIEF_FIELDS[0].key === 'intro'; const work = m._briefFilled({ intro: 'x', features: ['a'] }) === 2 && m._briefFilled({}) === 0; const src = read(__filename); const moved = m._briefFilled === _briefFilled && m._BRIEF_FIELDS === _BRIEF_FIELDS && !/^const _BRIEF_FIELDS = \[/m.test(src) && !/^function _briefFilled\(/m.test(src); return cfgOk && work && moved; } },
|
|
3891
3928
|
{ name: 'lib/pure-utils: brief 빌더 분리(_briefReadmeBlock/_briefBlueprint + BRIEF 마커, VERSION 주입) (UR-0025 1.9.331)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._briefReadmeBlock === 'function' && typeof m._briefBlueprint === 'function' && m.BRIEF_START.includes('project-brief:start'); const b = { project: 'X', intro: 'i', features: ['f1'] }; const rb = m._briefReadmeBlock(b); const bp = m._briefBlueprint(b, '9.9.9'); const work = rb.includes(m.BRIEF_START) && rb.includes(m.BRIEF_END) && /f1/.test(rb) && /Blueprint/.test(bp) && /leerness v9\.9\.9/.test(bp); const src = read(__filename); const moved = m._briefBlueprint === _briefBlueprint && m.BRIEF_START === BRIEF_START && !/^function _briefReadmeBlock\(/m.test(src) && !/^function _briefBlueprint\(/m.test(src) && !/^const BRIEF_START =/m.test(src); return fnOk && work && moved; } },
|
|
3892
3929
|
{ name: 'lib/pure-utils: lessons.md 파서 분리(_parseLessonEntries) + 인라인 제거 (UR-0025 1.9.332)', run: () => { const m = require('../lib/pure-utils'); const r = m._parseLessonEntries('### 2026-06-05\n- Lesson: A\n- Tag: t\n\n### 2026-06-04\n- Lesson: B'); const work = r.length === 2 && r[0].text === 'A' && r[0].tag === 't' && r[1].tag === null && r[0].date === '2026-06-05'; const src = read(__filename); const moved = m._parseLessonEntries === _parseLessonEntries && !/^function _parseLessonEntries\(/m.test(src) && src.includes('_parseLessonEntries(read(mp))'); return work && moved; } },
|
|
3893
|
-
{ name: 'UR-0025 심층: constraints catalog→lib/catalogs + _matchConstraints→pure-utils 분리 (1.9.333) + i18n en(1.31.2)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_PLATFORM_CONSTRAINTS && Object.keys(c._DEFAULT_PLATFORM_CONSTRAINTS.platforms).length === 6 && !!c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe; const r = m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'stripe 결제'); const work = r.matched.length === 1 && r.matched[0].platform === 'stripe' && r.totalPlatforms === 6 && m._matchConstraints(null, 'x').matched.length === 0; const _H = /[가-힣]/; const enSug = (m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'generic api integration widget', 'en').suggestions || [])[0] || ''; const koSug = (m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'generic api integration widget', 'ko').suggestions || [])[0] || ''; const i18nOk = c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe.constraints.some(x => x.detailEn && !_H.test(x.detailEn)) && enSug.length > 0 && !_H.test(enSug) && _H.test(koSug); const src = read(__filename); const moved = _DEFAULT_PLATFORM_CONSTRAINTS === c._DEFAULT_PLATFORM_CONSTRAINTS && _matchConstraints === m._matchConstraints && !/const _DEFAULT_PLATFORM_CONSTRAINTS = \{/.test(src) &&
|
|
3930
|
+
{ name: 'UR-0025 심층: constraints catalog→lib/catalogs + _matchConstraints→pure-utils 분리 (1.9.333) + i18n en(1.31.2)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_PLATFORM_CONSTRAINTS && Object.keys(c._DEFAULT_PLATFORM_CONSTRAINTS.platforms).length === 6 && !!c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe; const r = m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'stripe 결제'); const work = r.matched.length === 1 && r.matched[0].platform === 'stripe' && r.totalPlatforms === 6 && m._matchConstraints(null, 'x').matched.length === 0; const _H = /[가-힣]/; const enSug = (m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'generic api integration widget', 'en').suggestions || [])[0] || ''; const koSug = (m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'generic api integration widget', 'ko').suggestions || [])[0] || ''; const i18nOk = c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe.constraints.some(x => x.detailEn && !_H.test(x.detailEn)) && enSug.length > 0 && !_H.test(enSug) && _H.test(koSug); const src = read(__filename); const moved = _DEFAULT_PLATFORM_CONSTRAINTS === c._DEFAULT_PLATFORM_CONSTRAINTS && _matchConstraints === m._matchConstraints && !/const _DEFAULT_PLATFORM_CONSTRAINTS = \{/.test(src) && /_matchConstraints\(_loadPlatformConstraints\(root\), text/.test(src); /* 1.36.82: 호출부가 lang 인자를 얻으며 정확 리터럴이 어긋나 자기 줄만 매칭했다 — 인자 추가에 견디는 불변식으로 */ return catOk && work && i18nOk && moved; } },
|
|
3894
3931
|
{ name: 'UR-0025 심층(Codex 위임·검증): intent domain catalog→lib/catalogs + _matchDomain→pure-utils 분리 (1.9.334)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_DOMAIN_CATALOG && Object.keys(c._DEFAULT_DOMAIN_CATALOG.domains).length === 5 && !!c._DEFAULT_DOMAIN_CATALOG.domains.game; const r = m._matchDomain(c._DEFAULT_DOMAIN_CATALOG, 'unity 게임'); const work = r.domain === 'game' && Array.isArray(r.components) && m._matchDomain(c._DEFAULT_DOMAIN_CATALOG, 'zzz없음').domain === null && m._matchDomain(null, 'x').domain === null; const src = read(__filename); const moved = _DEFAULT_DOMAIN_CATALOG === c._DEFAULT_DOMAIN_CATALOG && _matchDomain === m._matchDomain && !/const _DEFAULT_DOMAIN_CATALOG = \{/.test(src) && src.includes('_matchDomain(_loadDomainCatalog(root), text)'); return catOk && work && moved; } },
|
|
3895
3932
|
{ name: 'UR-0025 심층: LSP catalog→lib/catalogs(_LSP_LANG_PATTERNS) + _detectLspLang/_matchLspSymbols→pure-utils 분리 (1.9.335)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._LSP_LANG_PATTERNS && Object.keys(c._LSP_LANG_PATTERNS).length === 5 && Array.isArray(c._LSP_LANG_PATTERNS.javascript); const langOk = m._detectLspLang('a.py') === 'python' && m._detectLspLang('b.go') === 'go' && m._detectLspLang('c.md') === 'javascript'; const sy = m._matchLspSymbols(c._LSP_LANG_PATTERNS, 'function alpha(){}\nclass Beta{}', 'javascript'); const work = sy.length === 2 && sy[0].name === 'alpha' && sy[0].kind === 'function' && sy[1].kind === 'class' && m._matchLspSymbols(null, 'x', 'javascript').length === 0; const src = read(__filename); const moved = _LSP_LANG_PATTERNS === c._LSP_LANG_PATTERNS && _detectLspLang === m._detectLspLang && _matchLspSymbols === m._matchLspSymbols && !/const _LSP_LANG_PATTERNS = \{/.test(src) && !/function _detectLspLang\(/.test(src); return catOk && langOk && work && moved; } },
|
|
3896
3933
|
{ name: 'UR-0025 심층(Codex 위임·검증): anti-laziness catalog→lib/catalogs(OPTIMISM_PATTERNS) + optimism 순수로직→pure-utils 분리 (1.9.336)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = Array.isArray(c.OPTIMISM_PATTERNS) && c.OPTIMISM_PATTERNS.length === 10 && c.OPTIMISM_PATTERNS[0].kind === 'API'; const ev = 'API 호출 완료, POST /users'; const sus = m._detectOptimism(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const conf = m._computeConfidence(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const work = sus.some(s => s.kind === 'API' && s.severity === 'high') && conf < 0.5 && m._computeConfidence(c.OPTIMISM_PATTERNS, '정리함', 'x') === 1 && m._detectOptimism(null, ev, 'x').length === 0 && m._extractUrlClaims('POST /a').length === 1 && m._verifyUrlClaim({ path: '/a' }, 'has /a') === true; const src = read(__filename); const moved = OPTIMISM_PATTERNS === c.OPTIMISM_PATTERNS && _puDetectOptimism === m._detectOptimism && !/const OPTIMISM_PATTERNS = \[/.test(src) && !/function _extractUrlClaims\(/.test(src); return catOk && work && moved; } },
|
|
@@ -3941,7 +3978,16 @@ function _selfTestCases() {
|
|
|
3941
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; } },
|
|
3942
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; } },
|
|
3943
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; } },
|
|
3944
|
-
{ 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);
|
|
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; } },
|
|
3945
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); } },
|
|
3946
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; } },
|
|
3947
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; } },
|
|
@@ -3962,7 +4008,25 @@ function _selfTestCases() {
|
|
|
3962
4008
|
{ name: '7번째 버그헌트 P1-B (UR-0105): verify-claim/optimism-check/honesty-check --json 에러 구조화 (1.9.400)', run: () => { const src = read(__filename); const vc = /function verifyClaimCmd[\s\S]{0,1200}?failJson\(_j, 'not_found'/.test(src); const oc = /function optimismCheckCmd[\s\S]{0,700}?failJson\(_j, 'not_found'/.test(src); const hc = /function honestyCheckCmd[\s\S]{0,900}?failJson\(has\('--json'\), 'not_found'/.test(src); return vc && oc && hc; } }, // 1.30.5: {0,400}→{0,700} (F4 가 missing_args 라인을 en/ko 로 늘려 not_found 가 창 밖) · 1.33.2: vc {0,700}→{0,1200} (opts.collect 가드 라인이 not_found 를 더 밀어냄)
|
|
3963
4009
|
{ name: '7번째 버그헌트 P1-C (UR-0106): 시크릿 FN — gitignore 부정(!) + placeholder substring 정밀화 (1.9.401)', run: () => { const m = require('../lib/pure-utils'); const gm = m._gitignoreMatch; const negOk = gm('*.example\n!.env.example', '.env.example') === false && gm('*.log', 'a.log') === true && gm('a.log\n!a.log', 'a.log') === false && gm('.env', '.env') === true; const ph = m._isPlaceholderSecret; const phOk = ph('sk-EXAMPLEab12cd34ef56gh78ij90kl') === false && ph('sk-proj-realKEYexample9988776655') === false && ph('your-key-here') === true && ph('changeme') === true && ph('example') === true && ph('xxxxxxxxxxxxxxxxxxxxxxxxxxxx') === true; return negOk && phOk; } },
|
|
3964
4010
|
{ name: '7번째 버그헌트 P1-A 잔여 (UR-0108): decisions/lessons MD projection 개행 주입 차단 _lineSafe (1.9.402)', run: () => { const m = require('../lib/pure-utils'); if (m._lineSafe !== _lineSafe) return false; const lsOk = _lineSafe('a\nb\r\nc') === 'a b c'; const md = m._renderDecisionsMd([{ date: '2026-06-07', title: 'real\n### 2099-01-01 — FAKE\n- Decision: forged', decision: 'd', reason: 'r' }]); const re = m._decisionsFromMd(md); const noInject = re.length === 1 && !/^### 2099-01-01 — FAKE/m.test(md); const lmd = m._renderLessonsMd([{ date: '2026-06-07', text: 'l1\n### FAKE\n- Lesson: x', tag: 't' }]); const lre = m._parseLessonEntries(lmd); const lNoInject = lre.length === 1; return lsOk && noInject && lNoInject; } },
|
|
3965
|
-
{ name: '7번째 버그헌트 P2 (UR-0107): api-skill show/drop 에러 exit code 1 (1.9.403)', run: () => {
|
|
4011
|
+
{ name: '7번째 버그헌트 P2 (UR-0107): api-skill show/drop 에러 exit code 1 (1.9.403)', run: () => {
|
|
4012
|
+
// 낡은 소스-리터럴(인라인 `process.exitCode = 1`) 대신 **행위 검사**: 1.36.74 에서 에러 경로가 failJson 으로
|
|
4013
|
+
// 리팩터되며 리터럴이 가드 자기 줄에만 남아 공허참이 됐다. 실제로 호출해 exit code 를 확인한다.
|
|
4014
|
+
if (typeof apiSkillCmd !== 'function') return false;
|
|
4015
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_apisk_'));
|
|
4016
|
+
const save = process.argv; const _w = process.stdout.write; const _e = process.stderr.write; const savedExit = process.exitCode;
|
|
4017
|
+
const c = {};
|
|
4018
|
+
try {
|
|
4019
|
+
process.stdout.write = () => true; process.stderr.write = () => true;
|
|
4020
|
+
const ex = (tail, sub) => { process.argv = ['node', 'h'].concat(tail); process.exitCode = 0; const p = apiSkillCmd(tmp, sub); if (p && typeof p.catch === 'function') p.catch(() => {}); return process.exitCode || 0; };
|
|
4021
|
+
c.showNoId = ex(['api-skill', 'show'], 'show');
|
|
4022
|
+
c.dropNoId = ex(['api-skill', 'drop'], 'drop');
|
|
4023
|
+
c.showNf = ex(['api-skill', 'show', 'NOPE'], 'show');
|
|
4024
|
+
c.dropNf = ex(['api-skill', 'drop', 'NOPE'], 'drop');
|
|
4025
|
+
c.addNoUrl = ex(['api-skill', 'add'], 'add');
|
|
4026
|
+
c.list = ex(['api-skill', 'list'], 'list');
|
|
4027
|
+
} catch (e) { return false; } finally { process.stdout.write = _w; process.stderr.write = _e; process.argv = save; process.exitCode = savedExit; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
4028
|
+
return c.showNoId === 1 && c.dropNoId === 1 && c.showNf === 1 && c.dropNf === 1 && c.addNoUrl === 1 && c.list === 0;
|
|
4029
|
+
} },
|
|
3966
4030
|
{ name: '7번째 버그헌트 P2 (UR-0105 잔여): reuse autodetect / creds check --json 에러 구조화 (1.9.404)', run: () => { const src = read(__filename); const reuseOk = src.includes("failJson(has('--json'), 'no_scan_dir'"); const credsOk = src.includes("failJson(has('--json'), 'no_service'"); return reuseOk && credsOk; } },
|
|
3967
4031
|
{ name: '8번째 버그헌트 회귀수정 (UR-0109): 긴 서술형 placeholder FP 차단(마커 우선) + 실키 FN 유지 (1.9.405)', run: () => { const m = require('../lib/pure-utils'); const ph = m._isPlaceholderSecret; const fpFixed = ph('your-super-secret-api-key-example-value') === true && ph('this-is-just-an-example-placeholder-value') === true && ph('example-api-key-do-not-use-1234567890') === true; const fnKept = ph('sk-EXAMPLEab12cd34ef56gh78ij90kl') === false && ph('sk-proj-realKEYexample9988776655') === false; const realKept = ph('a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6') === false; const shortPh = ph('your-api-key-here') === true && ph('changeme') === true; return fpFixed && fnKept && realKept && shortPh; } },
|
|
3968
4032
|
{ name: '8번째 버그헌트 (UR-0110): rule/decision/lesson add 동시쓰기 _withLock 직렬화 (UR-0043 갭 메움) (1.9.406)', run: () => { const src = read(__filename); const L = '_withLock('; const ruleLock = src.includes(L + 'rulesPath' + '(root), () =>'); const decLock = src.includes(L + 'decisionsJsonPath' + '(root), () =>'); const lesLock = src.includes(L + 'lessonsJsonPath' + '(root), () =>'); return ruleLock && decLock && lesLock; } },
|
|
@@ -3972,10 +4036,56 @@ function _selfTestCases() {
|
|
|
3972
4036
|
{ name: '8번째 버그헌트 (UR-0114): absRoot 비문자열(--path 값없음 boolean true) → cwd 폴백(raw TypeError 차단) (1.9.410)', run: () => { const io = require('../lib/io'); const cwd = process.cwd(); const tBool = io.absRoot(true) === cwd; const tEmpty = io.absRoot('') === cwd; const tUndef = io.absRoot(undefined) === cwd; const tSpace = io.absRoot(' ') === cwd; const tReal = io.absRoot(os.tmpdir()) === path.resolve(os.tmpdir()); return tBool && tEmpty && tUndef && tSpace && tReal; } },
|
|
3973
4037
|
{ name: '8번째 버그헌트 (UR-0115): lazy detect --auto-track 단일 RMW 배치(O(T×N)→O(N+T)) (1.9.411)', run: () => { const src = read(__filename); const batched = src.includes("8번째 버그헌트, UR-0115") && /has\('--auto-track'\)[\s\S]{0,500}?_withLock\(progressPath\(root\), \(\) => \{[\s\S]{0,1200}?writeProgressRows/.test(src); const noPerTodoUpsert = !/for \(const t of newTodos\) \{\s*const id = nextId\(root, 'T'\);/.test(src); return batched && noPerTodoUpsert; } },
|
|
3974
4038
|
{ name: '6번째 외부평가 Opus P1 (UR-0100): list-family(decision/feature/plan/runs/team list) positional path 지원 (조용한 cwd 오독 차단) (1.9.412)', run: () => { const src = read(__filename); const L = '_resolveRoot('; const decOk = src.includes("decisionListCmd(absRoot(" + L + "args[2]))"); const planOk = src.includes("planListCmd(absRoot(" + L + "args[2]))"); const featOk = src.includes("featureListCmd(absRoot(" + L + "args[2]))"); const runsOk = src.includes("runsListCmd(absRoot(" + L + "args[2]))"); const teamOk = src.includes(L + "args[1] === 'list' ? args[2] : null)"); return decOk && planOk && featOk && runsOk && teamOk; } },
|
|
3975
|
-
{ name: '6번째 외부평가 codex P2 (UR-0101): action 명령(task/decision/rule/lesson add) --json 구조화 출력 (1.9.413)', run: () => {
|
|
4039
|
+
{ name: '6번째 외부평가 codex P2 (UR-0101): action 명령(task/decision/rule/lesson add) --json 구조화 출력 (1.9.413)', run: () => {
|
|
4040
|
+
// 낡은 소스-리터럴(task 의 status 표현이 _normTaskStatus 로 바뀌며 썩음) 대신 **행위 검사**:
|
|
4041
|
+
// 4개 add 명령을 --json 으로 실제 호출해 구조화 페이로드(단일 JSON 문서)를 파싱·검증한다.
|
|
4042
|
+
if (typeof taskAdd !== 'function' || typeof decisionAdd !== 'function' || typeof lessonSave !== 'function' || typeof ruleAdd !== 'function') return false;
|
|
4043
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_addjson_'));
|
|
4044
|
+
const save = process.argv; const _w = process.stdout.write; const savedExit = process.exitCode;
|
|
4045
|
+
let t = null, d = null, l = null, r = null, rDup = null;
|
|
4046
|
+
try {
|
|
4047
|
+
fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
|
|
4048
|
+
fs.writeFileSync(path.join(tmp, '.harness', 'HARNESS_VERSION'), VERSION);
|
|
4049
|
+
const cap = (argv, fn) => {
|
|
4050
|
+
let out = ''; process.argv = argv; process.stdout.write = s => { out += s; return true; };
|
|
4051
|
+
try { fn(); } finally { process.stdout.write = _w; }
|
|
4052
|
+
const line = out.split(/\r?\n/).map(x => x.trim()).filter(x => x.startsWith('{')).pop();
|
|
4053
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
4054
|
+
};
|
|
4055
|
+
t = cap(['node', 'h', 'task', 'add', 'JSON계약 T', '--json', '--no-review'], () => taskAdd(tmp, 'JSON계약 T'));
|
|
4056
|
+
d = cap(['node', 'h', 'decision', 'add', 'JSON계약 D', '--json'], () => decisionAdd(tmp, 'JSON계약 D'));
|
|
4057
|
+
l = cap(['node', 'h', 'lesson', 'save', 'JSON계약 L', '--json', '--tag', 'tg'], () => lessonSave(tmp, 'JSON계약 L'));
|
|
4058
|
+
r = cap(['node', 'h', 'rule', 'add', 'JSON계약 R', '--json', '--trigger', 'every-session'], () => ruleAdd(tmp, 'JSON계약 R'));
|
|
4059
|
+
rDup = cap(['node', 'h', 'rule', 'add', 'JSON계약 R', '--json', '--trigger', 'every-session'], () => ruleAdd(tmp, 'JSON계약 R'));
|
|
4060
|
+
} catch (e) { return false; } finally { process.stdout.write = _w; process.argv = save; process.exitCode = savedExit; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
4061
|
+
const taskJ = !!t && t.ok === true && /^T-\d{4}/.test(String(t.id)) && t.status === 'requested' && t.request === 'JSON계약 T';
|
|
4062
|
+
const decJ = !!d && d.ok === true && d.title === 'JSON계약 D';
|
|
4063
|
+
const lesJ = !!l && l.ok === true && l.text === 'JSON계약 L' && l.tag === 'tg';
|
|
4064
|
+
const ruleJ = !!r && r.ok === true && r.rule === 'JSON계약 R' && r.trigger === 'every-session' && r.skipped === false && !!rDup && rDup.skipped === true;
|
|
4065
|
+
return taskJ && decJ && lesJ && ruleJ;
|
|
4066
|
+
} },
|
|
3976
4067
|
{ name: '9th 외부평가 Codex P2 (UR-0121 잔여): health 보안 정직화(커밋 시크릿 반영) + status scope:install (1.9.418)', run: () => {
|
|
3977
4068
|
const src = read(__filename);
|
|
3978
|
-
|
|
4069
|
+
// 1.36.83 (검수 High#1, 실측 확정): 이 세 단언은 bin 을 읽지만 검증 대상은 lib/health.js 에 있다 —
|
|
4070
|
+
// `_collectSecretFindings(root)`/`committedSecrets` 는 bin 의 **무관한 다른 호출**이 만족시키고
|
|
4071
|
+
// 한국어 문구는 이 가드 줄 자신이 만족시킨다. 실제로 lib/health.js 의 스캐너 연결을 제거해
|
|
4072
|
+
// 커밋된 시크릿이 있는데도 healthy:true 가 나오게 만들어도 selftest 는 337/337 초록이었다.
|
|
4073
|
+
// → 실제 시크릿을 심은 워크스페이스로 healthCmd 를 돌려 **행위**로 검증한다.
|
|
4074
|
+
let healthWired = false;
|
|
4075
|
+
{
|
|
4076
|
+
const _hd = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_health_'));
|
|
4077
|
+
try {
|
|
4078
|
+
mkdirp(path.join(_hd, '.harness'));
|
|
4079
|
+
writeUtf8(path.join(_hd, '.harness', 'HARNESS_VERSION'), VERSION);
|
|
4080
|
+
// 다른 selftest 케이스가 "placeholder 아님"으로 단언하는 실 탐지 대상 값(분할 표기로 자기참조 회피)
|
|
4081
|
+
writeUtf8(path.join(_hd, 'cfg.js'), 'const k = "' + 'AKIA' + 'JQXMP7RZ2KL9WXYZ' + '";\n');
|
|
4082
|
+
const _r = cp.spawnSync(process.execPath, [__filename, 'health', _hd, '--json'],
|
|
4083
|
+
{ encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 });
|
|
4084
|
+
const _j = JSON.parse(_r.stdout.slice(_r.stdout.indexOf('{')));
|
|
4085
|
+
const _s = (_j.checks || {}).security || {};
|
|
4086
|
+
healthWired = _s.committedSecrets >= 1 && _s.critical === true && _j.healthy === false;
|
|
4087
|
+
} catch { healthWired = false; } finally { try { fs.rmSync(_hd, { recursive: true, force: true }); } catch {} }
|
|
4088
|
+
}
|
|
3979
4089
|
const statusScope = src.includes("scope: 'install'") && src.includes('healthyMeaning');
|
|
3980
4090
|
return healthWired && statusScope && typeof healthCmd === 'function' && typeof status === 'function';
|
|
3981
4091
|
} },
|
|
@@ -4043,10 +4153,23 @@ function _selfTestCases() {
|
|
|
4043
4153
|
return expOk && delegated && movedToLib;
|
|
4044
4154
|
} },
|
|
4045
4155
|
{ name: '10th 외부평가 Sonnet P2: rule add flag/경로 break(_parseAddTitle) — trigger 값/경로 흡수 차단 (1.9.426)', run: () => {
|
|
4046
|
-
|
|
4047
|
-
|
|
4156
|
+
// 낡은 소스-리터럴(디스패처 한 줄 그대로)은 1.30.4/1.9.445 리팩터로 썩어 자기참조 공허참이 됐다.
|
|
4157
|
+
// 대신 **실행 검사**: rule add 를 실제 spawn 해 (a) 제목이 --trigger 값/후행 경로를 흡수하지 않는지,
|
|
4158
|
+
// (b) positional path 가 root 로 쓰이고 cwd 는 오염되지 않는지 확인한다.
|
|
4048
4159
|
const m = require('../lib/pure-utils');
|
|
4049
4160
|
const u = m._parseAddTitle(['rule', 'add', '세션', '점검', '--trigger', 'every-session', '/p'], 2) === '세션 점검';
|
|
4161
|
+
const proj = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_ruleproj_'));
|
|
4162
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_rulecwd_'));
|
|
4163
|
+
let wired = false;
|
|
4164
|
+
try {
|
|
4165
|
+
for (const d of [proj, cwd]) { fs.mkdirSync(path.join(d, '.harness'), { recursive: true }); fs.writeFileSync(path.join(d, '.harness', 'HARNESS_VERSION'), VERSION); }
|
|
4166
|
+
const r = cp.spawnSync(process.execPath, [__filename, 'rule', 'add', '세션', '점검', '--trigger', 'every-session', proj, '--json'], { encoding: 'utf8', cwd, timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1' } });
|
|
4167
|
+
const line = (r.stdout || '').split(/\r?\n/).map(x => x.trim()).filter(x => x.startsWith('{')).pop();
|
|
4168
|
+
const j = JSON.parse(line);
|
|
4169
|
+
const titleOk = j.ok === true && j.rule === '세션 점검' && j.trigger === 'every-session';
|
|
4170
|
+
const rootOk = fs.existsSync(path.join(proj, '.harness', 'rules.md')) && !fs.existsSync(path.join(cwd, '.harness', 'rules.md'));
|
|
4171
|
+
wired = titleOk && rootOk;
|
|
4172
|
+
} catch (e) { wired = false; } finally { for (const d of [proj, cwd]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } }
|
|
4050
4173
|
return wired && u;
|
|
4051
4174
|
} },
|
|
4052
4175
|
{ name: '클린룸 (UR-0184): feature add/show/link/impact positional-path 와이어 + 미초기화 게이트 + _taskPositionalPath 값-플래그 skip (1.36.2)', run: () => {
|
|
@@ -4330,7 +4453,24 @@ function _selfTestCases() {
|
|
|
4330
4453
|
{ name: 'UR-0151: decision/lesson/rule add positional path 지원(_taskPositionalPath 재사용, cwd 오염 차단) (1.9.445)', run: () => {
|
|
4331
4454
|
const src = read(__filename);
|
|
4332
4455
|
// 1.12.1 (UR-0008): 멀티라인 exact-string includes 는 공백/줄바꿈/환경에 취약(클린룸 selftest false-alarm) → 공백 유연 정규식(\s+)으로 견고화.
|
|
4333
|
-
|
|
4456
|
+
// 1.36.80 (가드 부패 수리): 낡은 exact-literal "…, _parseAddTitle(args, 2))" 은 1.30.4 리팩터(_desc 변수 추출)로 제품 코드에서
|
|
4457
|
+
// 사라졌고 이 가드 줄 자신에만 남아 includes 가 영원히 참이었다(자기참조 false-pass — dispatch 를 통째로 process.cwd() 로 바꿔도 초록).
|
|
4458
|
+
// → 소스 문자열 대신 실제 CLI 실행으로 "positional 경로에 기록 + cwd 미오염"을 행위 검증한다.
|
|
4459
|
+
let rule = false;
|
|
4460
|
+
const _rt = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_ur0151_'));
|
|
4461
|
+
try {
|
|
4462
|
+
const _proj = path.join(_rt, 'proj'), _cwdDir = path.join(_rt, 'cwd');
|
|
4463
|
+
fs.mkdirSync(path.join(_proj, '.harness'), { recursive: true });
|
|
4464
|
+
fs.writeFileSync(path.join(_proj, '.harness', 'HARNESS_VERSION'), VERSION); // init 게이트 통과용 최소 마커
|
|
4465
|
+
fs.mkdirSync(_cwdDir, { recursive: true }); // 미초기화 — cwd 로 새면 write 자체가 차단됨
|
|
4466
|
+
const _rtitle = 'UR-0151 positional path 회귀가드';
|
|
4467
|
+
cp.spawnSync(process.execPath, [__filename, 'rule', 'add', _rtitle, _proj, '--trigger', 'every-session', '--json'],
|
|
4468
|
+
{ cwd: _cwdDir, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
|
|
4469
|
+
const _inProj = readRules(_proj).some(r => r.rule === _rtitle && r.status === 'active');
|
|
4470
|
+
const _cwdRules = path.join(_cwdDir, '.harness', 'rules.md');
|
|
4471
|
+
const _inCwd = exists(_cwdRules) && read(_cwdRules).includes(_rtitle);
|
|
4472
|
+
rule = _inProj && !_inCwd;
|
|
4473
|
+
} catch {} finally { try { fs.rmSync(_rt, { recursive: true, force: true }); } catch {} }
|
|
4334
4474
|
const lesson = /if \(cmd === 'lesson'\) \{\s+const root = absRoot\(arg\('--path', null\) \|\| _taskPositionalPath\(args, 2\) \|\| process\.cwd\(\)\)/.test(src);
|
|
4335
4475
|
const decision = /if \(cmd === 'decision'\) \{\s+const root = absRoot\(arg\('--path', null\) \|\| _taskPositionalPath\(args, 2\) \|\| process\.cwd\(\)\)/.test(src);
|
|
4336
4476
|
// rule add 의 --trigger 값은 경로 아님(path-like 아님) + 값-플래그 제외
|
|
@@ -4552,8 +4692,44 @@ function _selfTestCases() {
|
|
|
4552
4692
|
} },
|
|
4553
4693
|
{ name: '15th 잔여 클러스터 (UR-0017~0021): api-skill CRLF + shell-guard 공백없는&& + stat-before-read + 중첩skip + requirements 디렉티브 (1.12.5)', run: () => {
|
|
4554
4694
|
const src = read(__filename);
|
|
4555
|
-
|
|
4556
|
-
|
|
4695
|
+
// 1.36.80 (가드 부패 수리): 낡은 exact-literal 두 개(_loadAPISkill 손상-fallback 객체 리터럴 / 스캔 루프의 1MB stat 한 줄)는
|
|
4696
|
+
// 각각 1.36.74(손상 frontmatter 표시 필드 추가) · 1.36.56(stat 1회화 리팩터)로 제품 코드에서 사라졌고 이 가드 줄 자신에만 남아
|
|
4697
|
+
// includes 가 영원히 참이었다(자기참조 false-pass). → CRLF 정규화/손상 fallback 은 _loadAPISkill 실호출,
|
|
4698
|
+
// 1MB 상한은 _collectSecretFindings 실호출로 행위 검증하고, "stat 이 read 보다 먼저"라는 순서만 불변식 정규식으로 확인한다.
|
|
4699
|
+
let apiCrlf = false, statBeforeRead = false;
|
|
4700
|
+
const _t15 = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_g15_'));
|
|
4701
|
+
try {
|
|
4702
|
+
const _sk = path.join(_t15, '.harness', 'api-skills');
|
|
4703
|
+
fs.mkdirSync(_sk, { recursive: true });
|
|
4704
|
+
fs.writeFileSync(path.join(_sk, 'crlf.md'), '---\r\nid: crlf\r\nname: CRLF Skill\r\nurls:\r\n - https://ex.test/doc\r\n---\r\n본문\r\n');
|
|
4705
|
+
fs.writeFileSync(path.join(_sk, 'broken.md'), '---\nid: broken\n닫는 구분자 없음 — frontmatter 손상\n');
|
|
4706
|
+
const _s1 = _loadAPISkill(_t15, 'crlf'); // CRLF: raw read 였다면 frontmatter 전부 유실
|
|
4707
|
+
const _s2 = _loadAPISkill(_t15, 'broken'); // 손상: body 없으면 _matchAPISkills 의 s.body.slice 크래시
|
|
4708
|
+
apiCrlf = !!_s1 && _s1.name === 'CRLF Skill' && _s1.urls.length === 1 && _s1.urls[0] === 'https://ex.test/doc' && !/\r/.test(String(_s1.body))
|
|
4709
|
+
&& !!_s2 && typeof _s2.body === 'string' && _s2.body.length > 0 && Array.isArray(_s2.urls) && _s2.name === 'broken';
|
|
4710
|
+
const _sec = 'module.exports={apiKey:"sk-test-1234567890abcdefghijklmnopqrstuvwxyz"};';
|
|
4711
|
+
fs.writeFileSync(path.join(_t15, 'small.js'), _sec);
|
|
4712
|
+
fs.writeFileSync(path.join(_t15, 'big.js'), '// ' + 'x'.repeat(1024 * 1024) + '\n' + _sec);
|
|
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; // + 애초에 읽지도 않음
|
|
4728
|
+
const _order = /statSync\(file\)[\s\S]{0,240}?size > 1024 \* 1024\)\s*continue;[\s\S]{0,600}?read(?:FileSync)?\(file\)/.test(src)
|
|
4729
|
+
&& /statSync\(file\)\.size > 5 \* 1024 \* 1024\)\s*continue;[\s\S]{0,240}?readBuf\(file\)/.test(src)
|
|
4730
|
+
&& /statSync\(fp2\)\.size > budget\)\s*continue;[\s\S]{0,160}?read\(fp2\)/.test(src);
|
|
4731
|
+
statBeforeRead = _capOk && _order;
|
|
4732
|
+
} catch {} finally { try { fs.rmSync(_t15, { recursive: true, force: true }); } catch {} }
|
|
4557
4733
|
const nestedSkip = src.includes('segs.some(s => SCAN_SKIP_DIRS.has(s))');
|
|
4558
4734
|
const an = require('../lib/analyzers');
|
|
4559
4735
|
const sg = an._shellGuardAnalyze('npm run build&&npm test', { shell: 'powershell', psVersion: 5 });
|
|
@@ -4574,12 +4750,59 @@ function _selfTestCases() {
|
|
|
4574
4750
|
return typeof m.reviewRequestCmd === 'function' && wired;
|
|
4575
4751
|
} },
|
|
4576
4752
|
{ name: 'Karpathy 가이드라인4 (UR-0032): plan --done-when 검증가능 완료조건 저장/파싱/표시 (1.14.2)', run: () => {
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4753
|
+
// 1.36.80 (가드 부패 수리): planAdd 의 doneWhen 기본값 한 줄을 통째로 박아둔 낡은 exact-literal 은 1.36.63 다국어화로
|
|
4754
|
+
// 제품 코드에서 사라졌고 이 가드 줄 자신에만 남아 includes 가 영원히 참이었다(자기참조 false-pass — --done-when 을 통째로 무시해도 초록).
|
|
4755
|
+
// 아래 dw 계산도 제품 파서가 아니라 이 케이스가 새로 쓴 정규식이라 아무것도 지키지 못했다.
|
|
4756
|
+
// → plan add 를 실제 실행해 저장(plan.md)과 파싱(planListCmd)을 행위 검증.
|
|
4757
|
+
// 1.36.84 (검수 Medium#7): 표시/기본값이 아직 소스 정규식이라 기본값을 통째로 없애도(`|| ''`) 초록이었다 —
|
|
4758
|
+
// 이 케이스가 --done-when **있는** 경로만 실행했기 때문. → 옵션 없는 plan add 도 실제로 돌려 저장/파싱/표시
|
|
4759
|
+
// 세 지점에서 언어별 기본값((미정)/(unset))을 행위 검증한다. 자기 소스 읽기는 제거(더는 소스 존재로 판정하지 않음).
|
|
4760
|
+
let wired = false;
|
|
4761
|
+
const _tp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_dw_'));
|
|
4762
|
+
try {
|
|
4763
|
+
fs.mkdirSync(path.join(_tp, '.harness'), { recursive: true });
|
|
4764
|
+
fs.writeFileSync(path.join(_tp, '.harness', 'HARNESS_VERSION'), VERSION);
|
|
4765
|
+
const _cond = '로그인 e2e 테스트 통과';
|
|
4766
|
+
const _mtitle = 'Done-When 회귀가드';
|
|
4767
|
+
const _mdefault = 'Done-When 기본값 회귀가드';
|
|
4768
|
+
const _spawn = a => cp.spawnSync(process.execPath, [__filename, ...a, '--path', _tp],
|
|
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');
|
|
4774
|
+
// 저장: milestone 블록에 Done-When 줄 + 제목이 --done-when 값을 흡수하지 않음(nonFlagArgs withValue 회귀가드)
|
|
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 을 실제로 되돌려줌 — 기본값도 빈 값/인접줄 흡수가 아니어야
|
|
4783
|
+
let _out = ''; const _wr = process.stdout.write;
|
|
4784
|
+
try { process.stdout.write = x => { _out += x; return true; }; planListCmd(_tp, { json: true }); } finally { process.stdout.write = _wr; }
|
|
4785
|
+
let _ms = []; try { _ms = JSON.parse(_out).milestones || []; } catch {}
|
|
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;
|
|
4804
|
+
} catch {} finally { try { fs.rmSync(_tp, { recursive: true, force: true }); } catch {} }
|
|
4805
|
+
return wired;
|
|
4583
4806
|
} },
|
|
4584
4807
|
{ name: '16th 버그헌트 F1/F2: scan secrets 패턴당 멀티매치(break 제거) + task/rule list 파이프 셀안전 (1.15.1)', run: () => {
|
|
4585
4808
|
const src = read(__filename);
|
|
@@ -4589,8 +4812,18 @@ function _selfTestCases() {
|
|
|
4589
4812
|
} },
|
|
4590
4813
|
{ name: '외부클린룸 C2/C3/C4: gate --json 단일객체 + memory search --json + about .harness 정합 (1.16.1)', run: () => {
|
|
4591
4814
|
const src = read(__filename);
|
|
4592
|
-
|
|
4593
|
-
|
|
4815
|
+
// 자기참조 제거: 낡은 리터럴("const jsonMode = has('--json'); // 외부리뷰 C2")은 리팩터로 제품 코드에서 사라져 가드 자기 줄에서만 매치했다.
|
|
4816
|
+
// gate() 함수 소스만(셀프테스트 영역 제외) 불변식 정규식으로 검사 — 단일 집계 객체 + 단계 출력 억제.
|
|
4817
|
+
const _gateSrc = gate.toString();
|
|
4818
|
+
const c2 = /const jsonMode = has\('--json'\)/.test(_gateSrc)
|
|
4819
|
+
&& /if \(jsonMode\) process\.stdout\.write = \(\) => true;/.test(_gateSrc)
|
|
4820
|
+
&& /if \(jsonMode\) \{ log\(JSON\.stringify\(\{[^\n}]*ok: bad === 0, total: checks\.length, failed: bad, checks\b/.test(_gateSrc);
|
|
4821
|
+
// 자기참조 제거: 낡은 리터럴('JSON.stringify({ version: VERSION, query, total, includeCode')은 필드 추가 리팩터로
|
|
4822
|
+
// 제품 코드에서 사라졌다(가드 자기 줄만 매치). memorySearch() 함수 소스만 불변식 정규식으로 검사.
|
|
4823
|
+
const _msSrc = memorySearch.toString();
|
|
4824
|
+
const c3 = /const jsonMode = has\('--json'\)/.test(_msSrc)
|
|
4825
|
+
&& /if \(!jsonMode\) log\(/.test(_msSrc)
|
|
4826
|
+
&& /if \(jsonMode\) \{ log\(JSON\.stringify\(\{ version: VERSION, query,[^\n]*\btotal,[^\n]*\bincludeCode:[^\n]*\bresults\b/.test(_msSrc);
|
|
4594
4827
|
const _badDir = '.leern' + 'ess/ 에 영속화 (state start'; // 자기참조 회피: 분할 — about state 줄이 .leerness 로 남아있으면 감지
|
|
4595
4828
|
const c4 = src.includes('상태/결정/진행을 .harness/ 에 영속화 (task/decision') && !src.includes('상태/결정/진행을 ' + _badDir);
|
|
4596
4829
|
return c2 && c3 && c4;
|
|
@@ -4821,6 +5054,44 @@ function _selfTestCases() {
|
|
|
4821
5054
|
&& src.includes("failJson(has('--json'), 'rule_not_found'");
|
|
4822
5055
|
return restoreOk && planOk && fileReOk && analyzersOk && archiveOk && jsonOk;
|
|
4823
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
|
+
} },
|
|
4824
5095
|
{ name: 'DB 렌즈 recall (dogfood FN, 1.36.4): 내용기반 감지 — 평범한 이름의 DB 모듈 잡고 산문 FP 0 (행위)', run: () => {
|
|
4825
5096
|
const T = _isDbContentText, W = _withDbDomain;
|
|
4826
5097
|
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
@@ -4984,7 +5255,8 @@ function _selfTestCases() {
|
|
|
4984
5255
|
if (s < 0) return false;
|
|
4985
5256
|
const nextFn = src.indexOf('\nfunction ', s + 10);
|
|
4986
5257
|
const e = nextFn < 0 ? src.length : nextFn;
|
|
4987
|
-
|
|
5258
|
+
const binOutside = src.slice(0, s) + src.slice(e); // bin 에서 selftest 영역만 제외한 부분
|
|
5259
|
+
let outside = binOutside;
|
|
4988
5260
|
for (const sub of ['lib', 'scripts']) {
|
|
4989
5261
|
const dir = path.join(path.dirname(__filename), '..', sub);
|
|
4990
5262
|
if (!exists(dir)) continue;
|
|
@@ -4996,23 +5268,35 @@ function _selfTestCases() {
|
|
|
4996
5268
|
// 케이스 **시작 경계**로 분할한다(줄머리 ` { name:`) — 종결 형태에 의존하지 않는다.
|
|
4997
5269
|
const chunks = region.split(/\n(?= \{ name: )/).slice(1);
|
|
4998
5270
|
// 백틱 리터럴도 스캔한다(1차판은 따옴표만 봐서 `.includes(\`…\`)` 로 우회 가능했다).
|
|
4999
|
-
|
|
5271
|
+
// 1.36.82 (자체 발견): **파일-인지** 판정 — 리터럴이 "제품 어딘가"에 있으면 통과시키던 것을 고친다.
|
|
5272
|
+
// 가드가 read(__filename)(=bin) 을 검사하는데 그 구현이 lib/ 로 옮겨갔다면 그 가드는 여전히 자기 줄만
|
|
5273
|
+
// 매칭한다(실측 3건: drift 최신 Last generated · decision 필드 파싱 · constraints 호출부).
|
|
5274
|
+
// bin 을 읽는 변수의 리터럴은 **bin 안에서만** 찾고, 다른 변수(lib 파일 등)는 전체에서 찾는다.
|
|
5275
|
+
// 수신 변수 매칭에 단어 경계는 필수 — 없으면 `s.includes` 가 `tps.includes` 에 걸려 오탐이 난다(자체 실측).
|
|
5276
|
+
const litRe = /(\w+)\.includes\((['"`])((?:\\.|(?!\2)[\s\S])*?)\2\)/g;
|
|
5000
5277
|
const codeish = (L) => /[(){}=;]|=>|\|\||&&/.test(L) && /[a-zA-Z_$]{3,}/.test(L);
|
|
5001
5278
|
let offenders = 0;
|
|
5002
5279
|
for (const body of chunks) {
|
|
5003
5280
|
if (body.includes(MARK)) continue; // 이 메타가드 자신 (표식은 소스에 그대로 존재)
|
|
5004
5281
|
if (!/read\(__filename\)/.test(body)) continue;
|
|
5282
|
+
const binVars = new Set([...body.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*read\(__filename\)/g)].map(m => m[1]));
|
|
5005
5283
|
litRe.lastIndex = 0;
|
|
5006
5284
|
let lm;
|
|
5007
5285
|
while ((lm = litRe.exec(body)) !== null) {
|
|
5008
|
-
const L = lm[
|
|
5009
|
-
if (L.length
|
|
5286
|
+
const L = lm[3].replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
5287
|
+
if (L.length < 20 || !codeish(L)) continue;
|
|
5288
|
+
// 템플릿 보간은 **백틱에서만** 일어난다 — 백틱 needle 의 `${...}` 는 런타임 치환이라 원문 비교가 불가능하므로
|
|
5289
|
+
// 제외한다(정상 가드 오탐 1건 실측). 그러나 따옴표 리터럴의 '${...}' 는 그냥 문자열이므로 반드시 검사한다.
|
|
5290
|
+
// (검수 High#2) 1차판이 따옴표까지 싸잡아 제외해 16개 가드가 무방비가 됐다 — 실측 우회 재현됨.
|
|
5291
|
+
if (lm[2] === '`' && L.includes('${')) continue;
|
|
5292
|
+
const haystack = binVars.has(lm[1]) ? binOutside : outside;
|
|
5293
|
+
if (!haystack.includes(L)) { offenders++; break; }
|
|
5010
5294
|
}
|
|
5011
5295
|
}
|
|
5012
|
-
// 1.36.
|
|
5013
|
-
//
|
|
5014
|
-
//
|
|
5015
|
-
return offenders
|
|
5296
|
+
// 1.36.83: 잔여 부채 **0** — 1.36.82 가 baseline 12 로 유예했던 것을 이 라운드에서 전부 수리했다
|
|
5297
|
+
// (각 가드는 지키던 동작을 실제로 깨뜨렸을 때 실패함을 변이로 증명). 이제 하나라도 생기면 즉시 실패한다.
|
|
5298
|
+
// 유예 수치를 남겨두면 그 안에서 조용히 썩으므로, 갚은 뒤에는 반드시 0 으로 조인다.
|
|
5299
|
+
return offenders === 0;
|
|
5016
5300
|
} },
|
|
5017
5301
|
{ name: '동봉 문서의 낡을 수치 주장 금지 (1.36.82): README/docs 의 MCP 도구·selftest 케이스 수가 실측과 불일치 시 실패 (메타가드)', run: () => {
|
|
5018
5302
|
// 사고: docs/interoperability.md 가 "86 도구"(3릴리스 낡음), README.ko.md 가 "selftest 210 케이스"(실제 335) 를
|
|
@@ -5088,8 +5372,22 @@ function _selfTestCases() {
|
|
|
5088
5372
|
} },
|
|
5089
5373
|
{ name: 'CLI 영어화 Phase 3 (1.21.2, UR-0010): verify-claim 출력 t() 경유 + 한국어 기본 보존 (소스 가드)', run: () => {
|
|
5090
5374
|
const src = read(__filename);
|
|
5091
|
-
|
|
5092
|
-
|
|
5375
|
+
// 1.36.83 (검수 Medium#1, 실측 확정): '## File check' / '## Test count' 는 **가드 자신의 줄에만** 존재했다 —
|
|
5376
|
+
// 실제 출력은 이모지가 들어간 '## 📂 File check (0 claimed)' / '## 🧪 Test count' 라 정확 리터럴이 어긋난다.
|
|
5377
|
+
// → 실제 CLI 를 en 으로 돌려 헤딩이 영어로 렌더되는지 **행위**로 확인하고, ko 보존은 소스로 확인한다.
|
|
5378
|
+
let en = false;
|
|
5379
|
+
{
|
|
5380
|
+
const _ed = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_en3_'));
|
|
5381
|
+
try {
|
|
5382
|
+
cp.spawnSync(process.execPath, [__filename, 'init', _ed, '--yes', '--no-env', '--no-stale-check', '--language', 'en'], { encoding: 'utf8', timeout: 90000 });
|
|
5383
|
+
cp.spawnSync(process.execPath, [__filename, 'task', 'add', 'probe', '--path', _ed], { encoding: 'utf8', timeout: 60000 });
|
|
5384
|
+
const _md = read(path.join(_ed, '.harness', 'progress-tracker.md'));
|
|
5385
|
+
const _tid = [...String(_md).matchAll(/\|\s*(T-\d{4})\s*\|/g)].map(m => m[1]).pop();
|
|
5386
|
+
cp.spawnSync(process.execPath, [__filename, 'progress', 'update', _tid, '--status', 'done', '--evidence', 'bin/leerness.js', '--path', _ed], { encoding: 'utf8', timeout: 60000 });
|
|
5387
|
+
const _o = (cp.spawnSync(process.execPath, [__filename, 'verify-claim', _tid, '--path', _ed, '--language', 'en'], { encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 }).stdout) || '';
|
|
5388
|
+
en = /^##[^\n]*File check/m.test(_o) && /^##[^\n]*Test count/m.test(_o) && /^##[^\n]*Summary/m.test(_o) && !/[가-힣]/.test(_o.split('\n').filter(l => /^##/.test(l)).join('\n'));
|
|
5389
|
+
} catch { en = false; } finally { try { fs.rmSync(_ed, { recursive: true, force: true }); } catch {} }
|
|
5390
|
+
}
|
|
5093
5391
|
const koPreserved = src.includes('## 종합') && src.includes('구현 실체 (done 기본)'); // ko 원문이 t() ko 인자로 남아 e2e(ko) 무회귀
|
|
5094
5392
|
return en && koPreserved;
|
|
5095
5393
|
} },
|
|
@@ -5131,7 +5429,13 @@ function _selfTestCases() {
|
|
|
5131
5429
|
const sc = read(path.join(path.dirname(__filename), '..', 'lib', 'session-close.js'));
|
|
5132
5430
|
const rowsEn = sc.includes("t('- 없음', '- none')") && sc.includes('_retroOneLine(agg, uiLang)');
|
|
5133
5431
|
const retroEn = bin.includes('function _retroOneLine(agg, lang)') && bin.includes('`done ${done}/${total}') && bin.includes('decisions ${agg.decisionBlocks} accumulated');
|
|
5134
|
-
|
|
5432
|
+
// 자기참조 제거: 1.36.30 에서 자동 생성 대상이 roadmap.html → leerness.html(온톨로지 그래프)로 바뀌며
|
|
5433
|
+
// 'roadmap.html auto-updated (${trigger})' 는 제품 코드에서 사라졌다(가드 자기 줄만 매치).
|
|
5434
|
+
// _autoRoadmap() 함수 소스만 검사 — en/ko 두 분기가 모두 살아있는지(영어화 정직성).
|
|
5435
|
+
const _arSrc = _autoRoadmap.toString();
|
|
5436
|
+
const roadmapEn = /_uiLang\(root\) === 'en'/.test(_arSrc)
|
|
5437
|
+
&& /ontology graph auto-updated \(\${trigger}\)/.test(_arSrc)
|
|
5438
|
+
&& /온톨로지 그래프 자동 갱신 \(\${trigger}\)/.test(_arSrc);
|
|
5135
5439
|
const koPreserved = bin.includes('완료 ${done}/${total}') && bin.includes('온톨로지 그래프 자동 갱신 (${trigger})') && sc.includes("t('- 없음', '- none')"); // ko 인자 보존
|
|
5136
5440
|
return rowsEn && retroEn && roadmapEn && koPreserved;
|
|
5137
5441
|
} },
|
|
@@ -5422,8 +5726,12 @@ function _selfTestCases() {
|
|
|
5422
5726
|
// F-07: 단수형 별칭 매핑 존재
|
|
5423
5727
|
const aliasOk = s.includes("({ decision: 'decisions', lesson: 'lessons', plans: 'plan' })[surface]");
|
|
5424
5728
|
// F-02: 비율형 완전-통과 → declaredTestCount 승격 + 신뢰 경계 필드
|
|
5425
|
-
|
|
5426
|
-
|
|
5729
|
+
// 자기참조 제거: 낡은 리터럴('...denom) declaredTestCount = declaredPass.denom')은 블록화 리팩터
|
|
5730
|
+
// ({ declaredTestCount = ...; _ratioPromoted = true; })로 제품 코드에서 사라졌다(가드 자기 줄만 매치).
|
|
5731
|
+
// verifyClaimCmd() 함수 소스만 불변식 정규식으로 검사 — 전부-통과(N/N)만 승격.
|
|
5732
|
+
const _vcSrc = verifyClaimCmd.toString();
|
|
5733
|
+
const ratioOk = /declaredTestCount == null && declaredPass && declaredPass\.num === declaredPass\.denom\)[\s\S]{0,40}?declaredTestCount = declaredPass\.denom/.test(_vcSrc)
|
|
5734
|
+
&& /semanticVerified: false/.test(_vcSrc) && /\? 'executed' : 'static'/.test(_vcSrc);
|
|
5427
5735
|
// F-08: Unix .env 0600
|
|
5428
5736
|
const envOk = /mergeEnvFile[\s\S]{0,600}chmodSync\(p, 0o600\)/.test(s);
|
|
5429
5737
|
return langOk && aliasOk && ratioOk && envOk;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
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
|
@@ -4144,13 +4144,22 @@ total++;
|
|
|
4144
4144
|
const harnessSrc = fs.readFileSync(path.resolve(__dirname, '..', 'bin', 'leerness.js'), 'utf8');
|
|
4145
4145
|
// 1.9.334: catalogs import 블록 추출 후 이름 포함 확인(순서/추가 비의존 — 이후 import 추가 허용)
|
|
4146
4146
|
const _catImp = (harnessSrc.match(/const \{[\s\S]*?\} = require\('\.\.\/lib\/catalogs'\)/) || [''])[0];
|
|
4147
|
-
|
|
4147
|
+
// 1.36.83 (공허가드 스윕 후속): 이 정확 리터럴은 **bin 안의 selftest 가드 줄**에만 존재해 통과하고 있었다 —
|
|
4148
|
+
// 파일을 넘나든 자기참조(e2e 가 bin 을 읽는데, 그 문자열의 유일한 출처가 bin 의 다른 가드였다).
|
|
4149
|
+
// 1.36.83 이 그 가드를 불변식으로 바꾸자 리터럴이 사라져 이 단언의 공허함이 드러났다.
|
|
4150
|
+
// 호출부는 실제로 `_matchConstraints(_loadPlatformConstraints(root), text, lang)` 이므로 인자 추가에 견디는 불변식으로 바꾼다.
|
|
4151
|
+
const movedOut = !/const _DEFAULT_PLATFORM_CONSTRAINTS = \{/.test(harnessSrc) && /_matchConstraints\(_loadPlatformConstraints\(root\), text/.test(harnessSrc)
|
|
4148
4152
|
&& _catImp.includes('_DEFAULT_PLATFORM_CONSTRAINTS');
|
|
4149
4153
|
// 소비 명령 회귀: constraints check (review-request 도 _checkRequestConstraints 사용)
|
|
4150
4154
|
const cd = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-con-'));
|
|
4151
4155
|
cp.spawnSync(process.execPath, [CLI, 'init', cd, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
|
|
4152
|
-
|
|
4153
|
-
|
|
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';
|
|
4154
4163
|
ok = work && movedOut && cmdOk;
|
|
4155
4164
|
fs.rmSync(cd, { recursive: true, force: true });
|
|
4156
4165
|
} catch {}
|