leerness 1.36.116 → 1.36.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/leerness.js +47 -14
- package/lib/audit.js +21 -8
- package/lib/pure-utils.js +60 -26
- package/package.json +1 -1
- package/scripts/e2e.js +158 -11
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.117 하네스를 사용합니다. 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.117는 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.116는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
197
197
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
198
198
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
199
199
|
|
|
200
|
-
현재 누적: **v1.9.x → 1.36.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.117 릴리스 태그 이력** (수백 라운드) · _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.117: 2026-08-14
|
|
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.117';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -1581,7 +1581,8 @@ async function install(root, opts = {}) {
|
|
|
1581
1581
|
// (검수 P2-A): 여기서 예외를 warning 으로만 삼키면 MCP 미등록인데 설치는 성공으로 끝난다 — 눈에 띄게 알린다.
|
|
1582
1582
|
try {
|
|
1583
1583
|
const m = _mergeMcpJson(root);
|
|
1584
|
-
if (m.
|
|
1584
|
+
if (m.broken) warn(`.mcp.json 의 leerness 항목에 command 가 없어 서버가 뜨지 않습니다 — 사용자 설정이라 보존했습니다. 직접 고치거나 항목을 지운 뒤 다시 실행하세요`);
|
|
1585
|
+
else if (m.preserved) log(` = .mcp.json 의 기존 leerness 항목을 그대로 두었습니다(사용자 설정으로 판단)`);
|
|
1585
1586
|
else log(` ✓ .mcp.json ${m.action} — Claude Code 가 leerness MCP 를 인식합니다`);
|
|
1586
1587
|
} catch (e) { warn(`✗ .mcp.json 에 MCP 를 등록하지 못했습니다 — leerness 도구가 보이지 않습니다: ${(e && e.message) || e}`); }
|
|
1587
1588
|
}
|
|
@@ -23740,16 +23741,27 @@ function _mergeMcpJson(root, rel) {
|
|
|
23740
23741
|
}
|
|
23741
23742
|
}
|
|
23742
23743
|
obj.mcpServers = obj.mcpServers || {};
|
|
23743
|
-
const prev = obj.mcpServers.leerness;
|
|
23744
|
-
const already = !!prev;
|
|
23745
23744
|
// 1.36.116 (검수 P1): 기존 `leerness` 항목을 무조건 덮어써서 사용자의 래퍼·버전 고정·env 가 유실됐다.
|
|
23746
|
-
// 이 저장소의 계약은 false-DROP 이 버그다. **우리가 만든
|
|
23747
|
-
//
|
|
23748
|
-
|
|
23745
|
+
// 이 저장소의 계약은 false-DROP 이 버그다. **우리가 만든 형태**일 때만 갱신하고 그 밖은 보존한다.
|
|
23746
|
+
// 1.36.117 (검수 P1 2건 추가):
|
|
23747
|
+
// ① `!e.env && !e.cwd` 로만 걸렀더니 `envFile`·`disabled` 같은 **모르는 키**가 붙은 항목을 우리 것으로 보고
|
|
23748
|
+
// 통째로 갈아치워 그 키들이 사라졌다. 우리 것으로 인정하려면 `command`/`args` **외의 키가 없어야** 한다.
|
|
23749
|
+
// ② 존재 판정을 `!!prev` 로 했더니 `{"leerness": false}` / `null` / `""` 처럼 **명시적으로 적힌 값**을
|
|
23750
|
+
// '없음' 으로 보고 덮어쓰면서 `created` 라고 보고했다. 존재는 키 유무로 판단한다.
|
|
23751
|
+
const hasEntry = Object.prototype.hasOwnProperty.call(obj.mcpServers, 'leerness');
|
|
23752
|
+
const prev = hasEntry ? obj.mcpServers.leerness : undefined;
|
|
23753
|
+
const _isObj = (e) => !!e && typeof e === 'object' && !Array.isArray(e);
|
|
23754
|
+
const _oursExact = (e) => _isObj(e) && e.command === 'npx'
|
|
23749
23755
|
&& Array.isArray(e.args) && e.args.filter(a => a !== '-y').join(' ') === 'leerness mcp serve'
|
|
23750
|
-
&&
|
|
23756
|
+
&& Object.keys(e).every(k => k === 'command' || k === 'args');
|
|
23757
|
+
const _runnable = (e) => _isObj(e) && typeof e.command === 'string' && !!e.command.trim();
|
|
23751
23758
|
const _rel = String(rel || '.mcp.json');
|
|
23752
|
-
if (
|
|
23759
|
+
if (hasEntry && !_oursExact(prev)) {
|
|
23760
|
+
// 보존하되, **실행 불가능한 항목이면 성공이라고 말하지 않는다** — 그건 조용한 거짓 성공이다.
|
|
23761
|
+
const broken = !_runnable(prev);
|
|
23762
|
+
return { file: _rel, action: broken ? 'preserved-broken' : 'preserved', preserved: true, broken };
|
|
23763
|
+
}
|
|
23764
|
+
const already = hasEntry;
|
|
23753
23765
|
obj.mcpServers.leerness = { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'] };
|
|
23754
23766
|
mkdirp(path.dirname(f));
|
|
23755
23767
|
writeUtf8(f, JSON.stringify(obj, null, 2) + '\n');
|
|
@@ -23860,8 +23872,16 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
23860
23872
|
// 1.36.116 (검수 #11): adapter 가 manifest 의 설치 등급을 **안 읽고** 항상 standard 템플릿을 만들었다 —
|
|
23861
23873
|
// `init --minimal` 로 232B 로 줄여 놓은 CLAUDE.md 가 adapter 한 번에 1099B standard 로 되돌아간다.
|
|
23862
23874
|
// 사용자가 고른 침투성 등급을 다른 명령이 조용히 뒤집으면 그 설정은 의미가 없다.
|
|
23875
|
+
// ⚠ 손상 manifest 를 조용히 삼키면 minimal 설치가 standard 로 **되돌아간다**(사용자가 고른 등급을 소리 없이 뒤집는다).
|
|
23876
|
+
// 이 저장소의 손상-스토어 계약대로 원본 보존 + 중단이다(검수 P2).
|
|
23863
23877
|
let _instMode = null;
|
|
23864
|
-
|
|
23878
|
+
{
|
|
23879
|
+
const _mf = path.join(root, '.harness', 'manifest.json');
|
|
23880
|
+
if (exists(_mf)) {
|
|
23881
|
+
_assertStoreParsable(_mf, '.harness/manifest.json');
|
|
23882
|
+
try { _instMode = (JSON.parse(read(_mf)) || {}).mode || null; } catch { _instMode = null; }
|
|
23883
|
+
}
|
|
23884
|
+
}
|
|
23865
23885
|
// ⚠ 옵션이 둘로 갈린다: `minimal:true` 는 **어떤 키를 만들지**, `mode:'minimal'` 은 **템플릿을 줄일지** 다.
|
|
23866
23886
|
// 처음에 `minimal:true` 만 넘겨서 이 수정이 무동작이었다(크기 그대로 1099B).
|
|
23867
23887
|
// 그 다음엔 둘 다 넘겼다가 검수가 모순을 짚었다: `MINIMAL_SKIP_KEYS` 에 이 어댑터의 SKILL.md 가 들어 있어
|
|
@@ -23893,7 +23913,10 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
23893
23913
|
if (json) { log(JSON.stringify({ adapter: tool, files: results }, null, 2)); return; }
|
|
23894
23914
|
ok(`adapter ${tool} (${a.label}) 적용 — ${results.length}개 파일`);
|
|
23895
23915
|
for (const r of results) log(` ${r.action}: ${r.file}`);
|
|
23896
|
-
|
|
23916
|
+
// 1.36.117 (검수 P1): 깨진 항목을 보존했을 때도 "직접 호출 가능" 이라고 말하고 있었다 — 그건 거짓 성공이다.
|
|
23917
|
+
const _brk = results.filter(r => r.broken);
|
|
23918
|
+
if (_brk.length) _brk.forEach(r => warn(`${r.file} 의 leerness 항목에 command 가 없어 서버가 뜨지 않습니다 — 사용자 설정이라 보존했습니다`));
|
|
23919
|
+
else if (a.mcp) log(` ℹ MCP 등록 — 이 도구가 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`);
|
|
23897
23920
|
return;
|
|
23898
23921
|
}
|
|
23899
23922
|
|
|
@@ -26764,11 +26787,16 @@ async function main() {
|
|
|
26764
26787
|
const snippet = { mcpServers: { leerness: entry } };
|
|
26765
26788
|
// 1.36.116 (검수 P1): `!!leerness` 만 보면 `{"leerness":{}}` 처럼 **실행 불가능한 껍데기**도 "이미 등록됨" 이 된다 —
|
|
26766
26789
|
// Desktop 은 그걸로 아무것도 못 띄우는데 사용자는 다 됐다고 믿는다. 실행 가능한 형태인지까지 본다.
|
|
26790
|
+
// 1.36.117 (검수 P2): `[]`·`false` 처럼 **적혀는 있으나 실행 불가능한** 값이 'none'(미등록)으로 분류돼
|
|
26791
|
+
// "추가하세요" 라고만 안내했고, `command: " "` 는 'ok'(녹색)로 나왔다. 존재는 키 유무로, 유효는 trim 으로 본다.
|
|
26767
26792
|
const _regState = () => {
|
|
26768
26793
|
try {
|
|
26769
|
-
const
|
|
26770
|
-
if (!
|
|
26771
|
-
|
|
26794
|
+
const ms = JSON.parse(read(cfg)).mcpServers;
|
|
26795
|
+
if (!ms || typeof ms !== 'object' || Array.isArray(ms)) return ms === undefined ? 'none' : 'broken';
|
|
26796
|
+
if (!Object.prototype.hasOwnProperty.call(ms, 'leerness')) return 'none';
|
|
26797
|
+
const e = ms.leerness;
|
|
26798
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) return 'broken';
|
|
26799
|
+
return (typeof e.command === 'string' && e.command.trim()) ? 'ok' : 'broken';
|
|
26772
26800
|
} catch { return 'unreadable'; }
|
|
26773
26801
|
};
|
|
26774
26802
|
if (_mj) { const st = exists(cfg) ? _regState() : 'none'; log(JSON.stringify({ ok: true, configPath: cfg, exists: exists(cfg), registered: st === 'ok', registeredState: st, snippet, entry }, null, 2)); return; }
|
|
@@ -26801,6 +26829,11 @@ async function main() {
|
|
|
26801
26829
|
log('');
|
|
26802
26830
|
('"leerness": ' + JSON.stringify(entry, null, 2)).split('\n').forEach(l => log(' ' + l));
|
|
26803
26831
|
log('');
|
|
26832
|
+
// 1.36.117 (검수 P2): 파일에 내용은 있는데 `mcpServers` 키 자체가 없는 흔한 경우를 ⓐ·ⓑ 어느 쪽도 안 다뤘다.
|
|
26833
|
+
// ⓐ 를 따르면 기존 설정이 날아가고, ⓑ 는 넣을 자리가 없다.
|
|
26834
|
+
log(_en ? ` (If the file has content but no ${'"mcpServers"'} key, add ${'"mcpServers": { … }'} at the top level with the entry above inside it.)`
|
|
26835
|
+
: ` (내용은 있는데 ${'"mcpServers"'} 키가 없다면, 최상위에 ${'"mcpServers": { … }'} 를 만들고 그 안에 위 항목을 넣으세요.)`);
|
|
26836
|
+
log('');
|
|
26804
26837
|
log(dm(_en ? ` ⓘ This command never edits the app config for you — it is a system setting, so you apply it yourself.`
|
|
26805
26838
|
: ` ⓘ 이 명령은 앱 설정을 자동으로 바꾸지 않습니다 — 시스템 설정이라 직접 넣으시는 편이 안전합니다.`));
|
|
26806
26839
|
log(dm(_en ? ` ⓘ Claude Code (terminal/IDE) reads the project's .mcp.json, so it needs no separate registration.`
|
package/lib/audit.js
CHANGED
|
@@ -34,9 +34,11 @@ function _collectGuardInputs(root) {
|
|
|
34
34
|
};
|
|
35
35
|
let packageScripts = {};
|
|
36
36
|
let ownEntries = new Set();
|
|
37
|
+
let workspaces = false;
|
|
37
38
|
try {
|
|
38
39
|
const pkg = JSON.parse(rd(path.join(root, 'package.json')) || '{}');
|
|
39
40
|
packageScripts = pkg.scripts || {};
|
|
41
|
+
workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces.length > 0 : !!(pkg.workspaces && pkg.workspaces.packages);
|
|
40
42
|
// 1.36.116 (실측): 패키지 **자신의 진입점**(bin/main)은 러너가 아니라 제품이다. leerness 에서 실제로 물렸다 —
|
|
41
43
|
// `bin/leerness.js` 가 러너로 수집되고, 그 안의 *남의 프로젝트용* 생성 코드에 `test:smoke` 문자열이 있어서
|
|
42
44
|
// 자기 자신의 진짜 고아가 '누군가 부른다' 로 뒤집혔다(자기참조 4회차).
|
|
@@ -45,13 +47,21 @@ function _collectGuardInputs(root) {
|
|
|
45
47
|
else if (pkg.bin && typeof pkg.bin === 'object') ent.push(...Object.values(pkg.bin));
|
|
46
48
|
if (typeof pkg.main === 'string') ent.push(pkg.main);
|
|
47
49
|
ownEntries = new Set(ent.filter(x => typeof x === 'string').map(x => x.replace(/^\.\//, '').replace(/\\/g, '/')));
|
|
48
|
-
} catch {}
|
|
50
|
+
} catch { readErrors++; } // package.json 이 깨져 있으면 '스크립트 0개' 가 아니라 **못 읽었다** 고 말해야 한다(검수 P2)
|
|
49
51
|
// `viaScript` = 이 파일을 러너로 담게 만든 npm 스크립트 이름(CI 설정 파일이면 null).
|
|
50
52
|
// 1.36.116 (검수 P2): 아무도 안 부르는 wrapper 가 참조하는 파일도 러너로 담기고, 그 텍스트에 잎 가드 이름이
|
|
51
53
|
// 있으면 잎이 루트가 됐다 — BFS 재작성이 막으려던 바로 그 우회로가 한 단계 밖에 그대로 있었다.
|
|
52
54
|
// 판정기가 "그 wrapper 가 실제로 도달 가능한가" 를 따질 수 있도록 출처를 같이 준다.
|
|
55
|
+
// ⚠ 출처는 **여럿**일 수 있다. 파일 단위로 하나만 기억했더니 `package.json` 의 **선언 순서**가 판정을 바꿨다:
|
|
56
|
+
// 죽은 wrapper 가 `test` 보다 위에 있으면 둘이 같은 파일을 불러도 '죽은 참조' 로 고정돼
|
|
57
|
+
// 그 파일이 부르는 정상 가드가 고아로 나왔다(실측 재현). 참조한 스크립트를 전부 모은다.
|
|
53
58
|
const runners = [];
|
|
54
|
-
const push = (file, viaScript) => {
|
|
59
|
+
const push = (file, viaScript) => {
|
|
60
|
+
const found = runners.find(r => r.file === file);
|
|
61
|
+
if (found) { if (viaScript && !found.viaScripts.includes(viaScript)) found.viaScripts.push(viaScript); return; }
|
|
62
|
+
const t = rd(path.join(root, file));
|
|
63
|
+
if (t != null) runners.push({ file, text: t, viaScript: viaScript || null, viaScripts: viaScript ? [viaScript] : [] });
|
|
64
|
+
};
|
|
55
65
|
try { for (const f of fs.readdirSync(path.join(root, '.github', 'workflows'))) if (/\.ya?ml$/.test(f)) push(`.github/workflows/${f}`); } catch {}
|
|
56
66
|
// 1.36.116 (검수 #5): 정확히 `Makefile` 과 훅 2개만 읽었다 — `GNUmakefile`·소문자 `makefile`·`commit-msg` 를
|
|
57
67
|
// 놓치면 그 러너가 부르는 정상 가드가 전부 '아무도 안 부름' 으로 뒤집힌다(미수집은 곧 오탐이다).
|
|
@@ -64,9 +74,10 @@ function _collectGuardInputs(root) {
|
|
|
64
74
|
// 같은 흔한 배치를 놓치고, 그 안의 동적 열거자를 못 보면 다시 오탐이 폭발한다. 디렉터리 이름을 넓힌다.
|
|
65
75
|
const _FILE_RE = /[\w./-]*(?:scripts|tools|ci|bin|tasks)\/[\w.-]+\.(?:m?js|cjs|ts|sh)/g;
|
|
66
76
|
// CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
|
|
77
|
+
// ⚠ 자기 진입점 제외는 **CI 설정이 직접 부르는 경우까지** 막으면 안 된다(검수 P2) — 그 파일이 실제로
|
|
78
|
+
// 가드를 배선하고 있으면 그걸 못 봐서 정상 가드가 고아로 뒤집힌다. CI 가 부르면 그건 러너가 맞다.
|
|
67
79
|
for (const r of runners.slice()) for (const m of String(r.text).matchAll(_FILE_RE)) {
|
|
68
|
-
|
|
69
|
-
if (!ownEntries.has(rel)) push(rel, null);
|
|
80
|
+
push(m[0].replace(/^\.\//, ''), null);
|
|
70
81
|
}
|
|
71
82
|
for (const [name, body] of Object.entries(packageScripts)) for (const m of String(body || '').matchAll(_FILE_RE)) {
|
|
72
83
|
const rel = m[0].replace(/^\.\//, '');
|
|
@@ -87,7 +98,7 @@ function _collectGuardInputs(root) {
|
|
|
87
98
|
}
|
|
88
99
|
};
|
|
89
100
|
walk(path.join(root, 'scripts'), 'scripts', 0);
|
|
90
|
-
return { packageScripts, runners, scriptFiles, readErrors, skippedLarge };
|
|
101
|
+
return { packageScripts, runners, scriptFiles, readErrors, skippedLarge, workspaces };
|
|
91
102
|
}
|
|
92
103
|
// 1.36.108 (T-0097): 락은 harness 가 주입한다(clarify/referee/routing 과 같은 관례) — lib 에 복제본을 두지 않는다.
|
|
93
104
|
// 주입이 없으면 그냥 실행한다(하위호환). 주입 누락은 e2e 락 가드가 실행으로 잡는다.
|
|
@@ -194,14 +205,16 @@ function audit(root, opts = {}, deps = {}) {
|
|
|
194
205
|
orphanScripts: g.orphanScripts, orphanFiles: g.orphanFiles, scanIncomplete: g.scanIncomplete,
|
|
195
206
|
guardCount: g.guardCount, dynamicRunners: g.dynamicRunners, enumPrefixes: g.enumPrefixes, reason: g.reason,
|
|
196
207
|
});
|
|
197
|
-
} else if (g.scanIncomplete) {
|
|
208
|
+
} else if (g.scanIncomplete || g.deferred || g.monorepoOutOfScope) {
|
|
198
209
|
// 1.36.116 (검수 P2): 고아가 0이면 발견을 아예 안 내보내서, 사용자 입장에서 **'깨끗함'과 '일부를 못 읽음'이
|
|
199
210
|
// 같은 출력**이었다. 스캔이 불완전했다는 사실 자체가 보고 대상이다(그래야 0을 믿을지 정할 수 있다).
|
|
211
|
+
// 1.36.117: 판정 **보류**(turbo/nx)와 monorepo 범위 밖도 같은 클래스다 — 조용한 0건이면 안 된다.
|
|
200
212
|
warnings++;
|
|
201
213
|
warn(`가드 배선 점검이 불완전합니다 — 0건이라는 결과를 그대로 믿지 마세요`);
|
|
202
214
|
log(` 근거: ${g.reason}`);
|
|
203
|
-
_finding('orphan_guard_scan_incomplete', 'warn', 'guard wiring scan could not
|
|
204
|
-
scanIncomplete: g.scanIncomplete,
|
|
215
|
+
_finding('orphan_guard_scan_incomplete', 'warn', 'guard wiring scan could not cover everything', {
|
|
216
|
+
scanIncomplete: g.scanIncomplete, deferred: g.deferred, monorepoOutOfScope: g.monorepoOutOfScope,
|
|
217
|
+
guardCount: g.guardCount, reason: g.reason,
|
|
205
218
|
});
|
|
206
219
|
}
|
|
207
220
|
} catch {}
|
package/lib/pure-utils.js
CHANGED
|
@@ -1082,22 +1082,34 @@ function _detectOrphanGuards(input) {
|
|
|
1082
1082
|
// "이 패키지의 스크립트 이름에 실제로 붙는가" 다. 길이는 1글자만 막는다.
|
|
1083
1083
|
const _plausible = (p) => !!p && (/[:._-]$/.test(p) || p.length >= 2)
|
|
1084
1084
|
&& _scriptNames.some(n => n !== p && n.startsWith(p));
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1085
|
+
// 열거자 판정도 **실제로 도는 러너**에서만 해야 한다. 아무도 안 부르는 스크립트가 참조하는 열거자 파일이
|
|
1086
|
+
// "전부 태운다" 로 잡히면 경고 전체가 꺼진다 — turbo 우회로와 같은 클래스다(검수·자체 헌트 양쪽에서 재현).
|
|
1087
|
+
// liveness 는 도달성에 의존하고 도달성은 열거 범위에 의존하므로, 아래 고정점 반복 안에서 함께 돌린다.
|
|
1088
|
+
// 두 집합 모두 **커지기만** 하므로 수렴한다.
|
|
1089
|
+
let enumCoversAll = false;
|
|
1090
|
+
const _scannedRunners = new Set();
|
|
1091
|
+
const _scanEnumerators = (isLiveRunner) => {
|
|
1092
|
+
let added = false;
|
|
1093
|
+
for (const r of runners) {
|
|
1094
|
+
if (_scannedRunners.has(r.file) || !isLiveRunner(r)) continue;
|
|
1095
|
+
_scannedRunners.add(r.file);
|
|
1096
|
+
const raw = String(r.text || '');
|
|
1097
|
+
if (!(_READS_PKG_RE.test(raw) && _ENUMS_SCRIPTS_RE.test(raw))) continue;
|
|
1098
|
+
// 접두는 **원문**에서 뽑는다(가린 텍스트에는 문자열이 지워져 있다)
|
|
1099
|
+
let m; const re = new RegExp(_ENUM_PREFIX_RE.source, 'g');
|
|
1100
|
+
const rawFound = [], valid = [];
|
|
1101
|
+
while ((m = re.exec(raw))) { const p = m[1] || m[2] || m[3]; if (p) { rawFound.push(p); if (_plausible(p)) valid.push(p); } }
|
|
1102
|
+
// 접두 후보는 많은데 이 패키지의 스크립트와 하나도 안 맞으면, 그건 열거자의 필터가 아니라 남의 코드다 —
|
|
1103
|
+
// "전부 덮는다" 로 올리면 이 경고가 통째로 죽으므로 **열거자로 치지 않는다**.
|
|
1104
|
+
if (rawFound.length && !valid.length) continue;
|
|
1105
|
+
dynamicRunners.push(r.file);
|
|
1106
|
+
for (const p of valid) coveredPrefixes.add(p);
|
|
1107
|
+
// 접두를 못 뽑았으면 "전부 덮는다" 로 본다 — 편향은 false-PASS 다(오탐이 이 경고를 죽인다).
|
|
1108
|
+
if (!valid.length) enumCoversAll = true;
|
|
1109
|
+
added = true;
|
|
1110
|
+
}
|
|
1111
|
+
return added;
|
|
1112
|
+
};
|
|
1101
1113
|
|
|
1102
1114
|
// ② 이름 언급 — 호출 문법을 파싱하지 않고 **이름이 러너 텍스트에 나오는가**로 본다.
|
|
1103
1115
|
// `spawn("npm",["run","test:unit"])` 같은 배열 형태를 문법 파싱으로 잡으려다 실측에서 놓쳤다.
|
|
@@ -1173,10 +1185,15 @@ function _detectOrphanGuards(input) {
|
|
|
1173
1185
|
for (const p of ['pre' + n, 'post' + n]) if (Object.prototype.hasOwnProperty.call(scripts, p) && !reachable.has(p)) queue.push(p);
|
|
1174
1186
|
}
|
|
1175
1187
|
};
|
|
1176
|
-
|
|
1177
|
-
|
|
1188
|
+
// 출처가 여럿이면 **하나라도 살아 있으면** 그 러너는 실제로 돈다. 하나만 보면 선언 순서가 판정을 바꾼다(실측).
|
|
1189
|
+
const _liveRunner = (r) => {
|
|
1190
|
+
const via = Array.isArray(r.viaScripts) && r.viaScripts.length ? r.viaScripts : (r.viaScript ? [r.viaScript] : []);
|
|
1191
|
+
return via.length === 0 || via.some(n => reachable.has(n) || coveredByEnum(n));
|
|
1192
|
+
};
|
|
1193
|
+
for (let pass = 0; pass <= runners.length + 2; pass++) {
|
|
1178
1194
|
_bfs();
|
|
1179
|
-
|
|
1195
|
+
// 열거 범위가 넓어지면 더 많은 스크립트가 live 가 되고, 그러면 더 많은 러너가 유효해진다 — 같은 반복 안에서 돈다.
|
|
1196
|
+
let grew = _scanEnumerators(_liveRunner);
|
|
1180
1197
|
for (const r of runners) {
|
|
1181
1198
|
if (usedRunners.has(r.file) || !_liveRunner(r)) continue;
|
|
1182
1199
|
usedRunners.add(r.file);
|
|
@@ -1186,8 +1203,12 @@ function _detectOrphanGuards(input) {
|
|
|
1186
1203
|
// 리터럴을 또 쪼개는 것은 이 함정이 재발할 자리를 남기므로, **호출 문맥이 있는 줄**만 본다.
|
|
1187
1204
|
// 줄 단위라 거리 가정(예전에 85건 오탐을 낸 근접 창)은 쓰지 않고, `spawn("npm",["run","x"])` 같은
|
|
1188
1205
|
// 배열 형태도 같은 줄에 `npm` 이 있어 그대로 잡힌다.
|
|
1189
|
-
|
|
1190
|
-
|
|
1206
|
+
// ⚠ 줄 필터를 **CI 설정 파일에까지** 걸면 matrix/여러 줄 배선을 놓친다(검수 P2):
|
|
1207
|
+
// `script: [check:a, check:b]` 은 `npm` 이 다른 줄에 있다. CI 설정은 그 자체가 배선이므로 전문을 본다.
|
|
1208
|
+
// 줄 필터는 **코드 파일**에만 건다 — 거기서만 이름이 데이터로 등장한다(우리 e2e.js 가 그랬다).
|
|
1209
|
+
const _raw = _dropCommentLines(String(r.text || ''));
|
|
1210
|
+
const t = _isConfigRunner(r.file) ? _raw
|
|
1211
|
+
: _raw.split('\n').filter(l => /\b(?:npm|npx|yarn|pnpm|make|run|exec)\b/.test(l)).join('\n');
|
|
1191
1212
|
for (const n of names) if (!roots.has(n) && _has(t, n)) { roots.add(n); grew = true; }
|
|
1192
1213
|
grew = true;
|
|
1193
1214
|
}
|
|
@@ -1197,8 +1218,9 @@ function _detectOrphanGuards(input) {
|
|
|
1197
1218
|
const isLive = (n) => reachable.has(n) || coveredByEnum(n);
|
|
1198
1219
|
// 오케스트레이터 판정도 **실행되는 스크립트**에서만 본다 — 죽은 `"note":"echo turbo run test"` 한 줄로
|
|
1199
1220
|
// 경고 전체를 끌 수 있으면 그건 우회로다(검수 P2).
|
|
1221
|
+
// CI 설정 쪽도 **주석은 빼고** 본다 — "# turbo 로 옮길지 검토중" 한 줄로 경고 전체가 꺼지면 안 된다(검수 P2).
|
|
1200
1222
|
const unknownOrchestrator = Object.entries(scripts).some(([n, b]) => isLive(n) && _ORCH_RE.test(String(b || '')))
|
|
1201
|
-
|| runners.some(r => _isConfigRunner(r.file) && _ORCH_RE.test(String(r.text || '')));
|
|
1223
|
+
|| runners.some(r => _isConfigRunner(r.file) && _ORCH_RE.test(_dropCommentLines(String(r.text || ''))));
|
|
1202
1224
|
const orphanScripts = unknownOrchestrator ? [] : guardNames.filter(n => !isLive(n));
|
|
1203
1225
|
void mentions;
|
|
1204
1226
|
|
|
@@ -1209,17 +1231,27 @@ function _detectOrphanGuards(input) {
|
|
|
1209
1231
|
const liveScripts = Object.entries(scripts).filter(([n]) => isLive(n));
|
|
1210
1232
|
const allText = runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
|
|
1211
1233
|
+ liveScripts.map(([, b]) => String(b || '')).join('\n');
|
|
1234
|
+
// ⚠ basename 만으로 덮였다고 보면 **동명 파일이 서로를 살린다**(검수 P2): `a/check.js` 를 부르는 배선이
|
|
1235
|
+
// `b/check.js` 까지 덮어 버린다. basename 은 그 이름이 유일할 때만 근거로 쓴다.
|
|
1236
|
+
const _baseCount = new Map();
|
|
1237
|
+
for (const f of scriptFiles) { const b = String(f).split(/[\\/]/).pop(); _baseCount.set(b, (_baseCount.get(b) || 0) + 1); }
|
|
1212
1238
|
const orphanFiles = unknownOrchestrator ? [] : scriptFiles.filter(f => {
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1239
|
+
const full = String(f).replace(/\\/g, '/');
|
|
1240
|
+
if (allText.includes(full)) return false;
|
|
1241
|
+
const base = full.split('/').pop();
|
|
1242
|
+
if (_baseCount.get(base) === 1 && allText.includes(base)) return false;
|
|
1243
|
+
return true;
|
|
1215
1244
|
});
|
|
1216
1245
|
|
|
1217
1246
|
// 1.36.116 (검수 #9): 수집이 불완전했으면 그 사실을 결과에 실어야 한다 — 읽기 실패는 곧 **오탐**이 되고
|
|
1218
1247
|
// (그 러너가 부르던 정상 가드가 '아무도 안 부름' 이 된다), 사용자는 근거를 봐야 그 목록을 믿을지 정한다.
|
|
1219
1248
|
const scanIncomplete = (Number(input && input.readErrors) || 0) + (Number(input && input.skippedLarge) || 0);
|
|
1220
|
-
|
|
1249
|
+
// monorepo 는 루트 package.json 만 보므로 **범위 밖**이다. 조용히 0건이라고 하면 안 되고, 범위를 밝혀야 한다(검수 P2).
|
|
1250
|
+
const monorepoOutOfScope = !!(input && input.workspaces);
|
|
1251
|
+
const _incompleteNote = (scanIncomplete
|
|
1221
1252
|
? ` ⚠ 수집 불완전(읽기실패 ${Number(input.readErrors) || 0} · 대용량 제외 ${Number(input.skippedLarge) || 0}) — 이 목록은 과다보고일 수 있다`
|
|
1222
|
-
: ''
|
|
1253
|
+
: '')
|
|
1254
|
+
+ (monorepoOutOfScope ? ` ⚠ workspaces 저장소 — 루트 package.json 만 봤다(하위 패키지는 범위 밖)` : '');
|
|
1223
1255
|
return {
|
|
1224
1256
|
guardCount: guardNames.length,
|
|
1225
1257
|
dynamicRunners,
|
|
@@ -1227,6 +1259,8 @@ function _detectOrphanGuards(input) {
|
|
|
1227
1259
|
orphanScripts,
|
|
1228
1260
|
orphanFiles,
|
|
1229
1261
|
scanIncomplete,
|
|
1262
|
+
monorepoOutOfScope,
|
|
1263
|
+
deferred: unknownOrchestrator,
|
|
1230
1264
|
// 판정 근거를 함께 돌려준다 — 숫자만 보고 못 믿는 일이 없도록(사용자가 근거 없이 목록을 받으면 그냥 끈다)
|
|
1231
1265
|
reason: (unknownOrchestrator
|
|
1232
1266
|
? 'turbo/nx/lerna 배선은 설정 파일에 있어 스크립트 그래프로 볼 수 없다 — 판정 보류(보고 안 함)'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.117",
|
|
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
|
@@ -11305,6 +11305,76 @@ total++;
|
|
|
11305
11305
|
fs.writeFileSync(path.join(dc2, '.cursor', 'mcp.json'), brokenC);
|
|
11306
11306
|
cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc2], { cwd: dc2, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11307
11307
|
if (fs.readFileSync(path.join(dc2, '.cursor', 'mcp.json'), 'utf8') !== brokenC) bad.push('cursor:손상파일덮어씀');
|
|
11308
|
+
// 새 경로에도 **사용자 설정 보존** 계약이 걸리는지 — 경로만 바꾸고 계약을 안 가져오면 그게 데이터 손실이다.
|
|
11309
|
+
const dc3 = mk();
|
|
11310
|
+
fs.mkdirSync(path.join(dc3, '.cursor'), { recursive: true });
|
|
11311
|
+
fs.writeFileSync(path.join(dc3, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'x' }, leerness: { command: 'node', args: ['./w.js'] } } }, null, 2));
|
|
11312
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc3], { cwd: dc3, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11313
|
+
let jc3 = null; try { jc3 = JSON.parse(fs.readFileSync(path.join(dc3, '.cursor', 'mcp.json'), 'utf8')); } catch {}
|
|
11314
|
+
if (!jc3 || jc3.mcpServers.leerness.command !== 'node' || !jc3.mcpServers.other) bad.push('cursor:사용자설정미보존');
|
|
11315
|
+
}
|
|
11316
|
+
// ⑦ (검수 P1) `_ours` 경계 — 우리가 만든 **정확한 형태**만 갱신하고, 모르는 키가 하나라도 붙으면 보존한다.
|
|
11317
|
+
// 그리고 명시적으로 적힌 falsy 값을 '없음' 으로 오인해 덮어쓰면 안 된다.
|
|
11318
|
+
{
|
|
11319
|
+
const cases = [
|
|
11320
|
+
['모르는키', { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'], envFile: '.env.x' }, 'preserve'],
|
|
11321
|
+
['비활성화', { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'], disabled: true }, 'preserve'],
|
|
11322
|
+
['우리형태', { command: 'npx', args: ['leerness', 'mcp', 'serve'] }, 'update'],
|
|
11323
|
+
['false', false, 'preserve'],
|
|
11324
|
+
['null', null, 'preserve'],
|
|
11325
|
+
['빈객체', {}, 'preserve'],
|
|
11326
|
+
];
|
|
11327
|
+
for (const [label, val, expect] of cases) {
|
|
11328
|
+
const d = mk();
|
|
11329
|
+
fs.writeFileSync(path.join(d, '.mcp.json'), JSON.stringify({ mcpServers: { leerness: val } }, null, 2));
|
|
11330
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11331
|
+
let j = null; try { j = JSON.parse(fs.readFileSync(path.join(d, '.mcp.json'), 'utf8')); } catch {}
|
|
11332
|
+
const same = j && JSON.stringify(j.mcpServers.leerness) === JSON.stringify(val);
|
|
11333
|
+
if (expect === 'preserve' && !same) bad.push(`_ours:${label}을_덮어씀`);
|
|
11334
|
+
if (expect === 'update' && (!j || (j.mcpServers.leerness.args || [])[0] !== '-y')) bad.push(`_ours:${label}을_갱신안함`);
|
|
11335
|
+
// 실행 불가능한 항목을 보존했으면 **성공이라고 말하지 않는다**
|
|
11336
|
+
const out = String(r.stdout || '') + String(r.stderr || '');
|
|
11337
|
+
const runnable = !!(val && typeof val === 'object' && val.command);
|
|
11338
|
+
if (!runnable && expect === 'preserve' && !/command 가 없어/.test(out)) bad.push(`_ours:${label}에_경고없음`);
|
|
11339
|
+
if (!runnable && expect === 'preserve' && /직접 호출 가능/.test(out)) bad.push(`_ours:${label}에_거짓성공`);
|
|
11340
|
+
}
|
|
11341
|
+
}
|
|
11342
|
+
// ⑧ (검수 P3) Desktop 상태·영문 표면을 **실제로** 검증한다 — 지금까지는 필드 존재만 봤다.
|
|
11343
|
+
{
|
|
11344
|
+
const de = mk(['--language', 'en']);
|
|
11345
|
+
const hEn = String(cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', de], { cwd: de, encoding: 'utf8', timeout: 120000, env: ENV }).stdout || '');
|
|
11346
|
+
const ko = mk();
|
|
11347
|
+
const hKo = String(cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko], { cwd: ko, encoding: 'utf8', timeout: 120000, env: ENV }).stdout || '');
|
|
11348
|
+
if (/[가-힣]/.test(hEn)) bad.push(`desktop:영문표면에한국어(${(hEn.match(/[가-힣]+/g) || [])[0]})`);
|
|
11349
|
+
if (!/[가-힣]/.test(hKo)) bad.push('desktop:한국어표면이비어있음');
|
|
11350
|
+
// `mcpServers` 키가 없는 파일을 위한 안내가 있어야 한다(ⓐ 를 따르면 기존 설정이 날아간다)
|
|
11351
|
+
if (!/mcpServers[^\n]*(키가 없|no .*key)/i.test(hKo)) bad.push('desktop:mcpServers키없는경우_안내없음');
|
|
11352
|
+
// `registeredState` **네 상태**를 격리해서 실제로 잰다(검수 P3: 지금까지는 필드 존재만 봤다 —
|
|
11353
|
+
// `_regState` 를 상수 'none' 으로 바꾸는 변이가 생존했다). 사용자의 진짜 설정은 건드리지 않는다:
|
|
11354
|
+
// Windows 는 APPDATA, Linux 는 XDG_CONFIG_HOME 으로 앱 설정 경로를 임시 디렉터리에 묶는다.
|
|
11355
|
+
const _cfgVar = process.platform === 'win32' ? 'APPDATA' : (process.platform === 'linux' ? 'XDG_CONFIG_HOME' : null);
|
|
11356
|
+
if (_cfgVar) {
|
|
11357
|
+
const states = [
|
|
11358
|
+
[{ mcpServers: { leerness: { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'] } } }, 'ok'],
|
|
11359
|
+
[{ mcpServers: { leerness: {} } }, 'broken'],
|
|
11360
|
+
[{ mcpServers: { leerness: [] } }, 'broken'],
|
|
11361
|
+
[{ mcpServers: { leerness: { command: ' ' } } }, 'broken'],
|
|
11362
|
+
[{ mcpServers: { other: { command: 'x' } } }, 'none'],
|
|
11363
|
+
['{ broken', 'unreadable'],
|
|
11364
|
+
];
|
|
11365
|
+
for (const [content, expect] of states) {
|
|
11366
|
+
const home = fs.mkdtempSync(path.join(sb, 'cfg-'));
|
|
11367
|
+
fs.mkdirSync(path.join(home, 'Claude'), { recursive: true });
|
|
11368
|
+
fs.writeFileSync(path.join(home, 'Claude', 'claude_desktop_config.json'),
|
|
11369
|
+
typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
11370
|
+
const env3 = Object.assign({}, ENV); env3[_cfgVar] = home;
|
|
11371
|
+
const rr = cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko, '--json'], { cwd: ko, encoding: 'utf8', timeout: 120000, env: env3 });
|
|
11372
|
+
let jj = null; try { jj = JSON.parse(String(rr.stdout || '').trim()); } catch {}
|
|
11373
|
+
if (!jj) { bad.push(`desktop:상태검사_json아님(${expect})`); continue; }
|
|
11374
|
+
if (jj.registeredState !== expect) bad.push(`desktop:상태오분류(기대 ${expect} · 실제 ${jj.registeredState})`);
|
|
11375
|
+
if (jj.registered !== (expect === 'ok')) bad.push(`desktop:registered불일치(${expect}→${jj.registered})`);
|
|
11376
|
+
}
|
|
11377
|
+
}
|
|
11308
11378
|
}
|
|
11309
11379
|
// ④ Desktop 안내 — 앱 설정을 **자동으로 쓰지 않고** 경로와 스니펫을 준다
|
|
11310
11380
|
{
|
|
@@ -11585,7 +11655,26 @@ total++;
|
|
|
11585
11655
|
const wInc = PU._detectOrphanGuards(Object.assign({}, inp, { readErrors: 2 }));
|
|
11586
11656
|
if (!wInc.scanIncomplete) bad.push('불완전_미전파');
|
|
11587
11657
|
if (!/수집 불완전/.test(wInc.reason || '')) bad.push('불완전_근거미표기');
|
|
11658
|
+
// (검수 P2) monorepo 는 범위 밖이라는 사실을 표시해야 한다 — 조용한 0건이면 사용자가 커버됐다고 믿는다
|
|
11659
|
+
const wMono = PU._detectOrphanGuards(Object.assign({}, inp, { workspaces: true }));
|
|
11660
|
+
if (!wMono.monorepoOutOfScope || !/범위 밖/.test(wMono.reason || '')) bad.push('monorepo_범위미표시');
|
|
11588
11661
|
} finally { try { fs.rmSync(sb2, { recursive: true, force: true }); } catch {} }
|
|
11662
|
+
// (검수 P2) **audit 의 실제 보고**를 고정한다 — 순수 함수에 필드가 있어도 CLI 가 침묵하면 사용자는 모른다.
|
|
11663
|
+
{
|
|
11664
|
+
const sbA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-defer116-'));
|
|
11665
|
+
try {
|
|
11666
|
+
const dA = path.join(sbA, 'a'); fs.mkdirSync(dA, { recursive: true });
|
|
11667
|
+
fs.writeFileSync(path.join(dA, 'package.json'), JSON.stringify({ name: 'a', version: '0.1.0', scripts: { test: 'turbo run build', 'check:x': 'node x.js' } }));
|
|
11668
|
+
cp.spawnSync(process.execPath, [CLI, 'init', dA, '--yes'], { cwd: dA, encoding: 'utf8', timeout: 300000, env: ENV2 });
|
|
11669
|
+
const rA = cp.spawnSync(process.execPath, [CLI, 'audit', '--path', dA, '--json'], { cwd: dA, encoding: 'utf8', timeout: 300000, env: ENV2 });
|
|
11670
|
+
let jA = null; try { jA = JSON.parse(String(rA.stdout || '').trim()); } catch {}
|
|
11671
|
+
const fA = jA && (jA.findings || []).find(x => x.kind === 'orphan_guard_scan_incomplete');
|
|
11672
|
+
if (!fA) bad.push('보류인데_audit이_침묵');
|
|
11673
|
+
else if (!fA.deferred) bad.push('보류사유_미표기');
|
|
11674
|
+
const hA = String(cp.spawnSync(process.execPath, [CLI, 'audit', '--path', dA], { cwd: dA, encoding: 'utf8', timeout: 300000, env: ENV2 }).stdout || '');
|
|
11675
|
+
if (!/불완전/.test(hA)) bad.push('보류가_사람용출력에_없음');
|
|
11676
|
+
} finally { try { fs.rmSync(sbA, { recursive: true, force: true }); } catch {} }
|
|
11677
|
+
}
|
|
11589
11678
|
}
|
|
11590
11679
|
// ⑬ (검수 #10) 대용량 파일은 통째로 읽지 않는다 — 일반 예외와 달리 OOM/장시간 정지는 바깥 try/catch 로 회복되지 않는다.
|
|
11591
11680
|
{
|
|
@@ -11621,6 +11710,32 @@ total++;
|
|
|
11621
11710
|
scriptFiles: [],
|
|
11622
11711
|
});
|
|
11623
11712
|
if (r16b.orphanScripts.length) bad.push('산_러너파일의_잎을_오탐:' + r16b.orphanScripts.join(','));
|
|
11713
|
+
// 출처가 여럿인 파일 — **하나라도 살아 있으면** 그 러너는 돈다. 파일당 출처를 하나만 기억하면
|
|
11714
|
+
// package.json 의 **선언 순서**가 판정을 바꾼다(죽은 wrapper 가 위에 있으면 정상 가드가 고아로 나왔다, 실측).
|
|
11715
|
+
const r16c = PU._detectOrphanGuards({
|
|
11716
|
+
packageScripts: { 'dead-wrapper': 'node tools/x.js', test: 'node tools/x.js', 'check:leaf': 'node l.js' },
|
|
11717
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [] },
|
|
11718
|
+
{ file: 'tools/x.js', text: 'sh("npm run check:leaf")', viaScript: 'dead-wrapper', viaScripts: ['dead-wrapper', 'test'] }],
|
|
11719
|
+
scriptFiles: [],
|
|
11720
|
+
});
|
|
11721
|
+
if (r16c.orphanScripts.length) bad.push('출처_선언순서에_판정이_의존:' + r16c.orphanScripts.join(','));
|
|
11722
|
+
// 그리고 **실제 수집기**가 출처를 모으는지 — 순수 함수만 재면 이 결함은 안 보인다(수집기가 하나만 담고 있었다)
|
|
11723
|
+
{
|
|
11724
|
+
const sb4 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-via116-'));
|
|
11725
|
+
try {
|
|
11726
|
+
const d4 = path.join(sb4, 'o');
|
|
11727
|
+
fs.mkdirSync(path.join(d4, 'tools'), { recursive: true });
|
|
11728
|
+
fs.mkdirSync(path.join(d4, '.github', 'workflows'), { recursive: true });
|
|
11729
|
+
fs.writeFileSync(path.join(d4, 'package.json'), JSON.stringify({ name: 'o', version: '0.1.0',
|
|
11730
|
+
scripts: { 'dead-wrapper': 'node tools/x.js', test: 'node tools/x.js', 'check:leaf': 'node l.js' } }));
|
|
11731
|
+
fs.writeFileSync(path.join(d4, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm test\n');
|
|
11732
|
+
fs.writeFileSync(path.join(d4, 'tools', 'x.js'), 'sh("npm run check:leaf")\n');
|
|
11733
|
+
const vi = AUD._collectGuardInputs(d4);
|
|
11734
|
+
const rr = (vi.runners.find(r => /tools[\\/]x\.js$/.test(r.file)) || {});
|
|
11735
|
+
if (!(rr.viaScripts || []).includes('test')) bad.push('수집기가_출처를_하나만_담음');
|
|
11736
|
+
if (PU._detectOrphanGuards(vi).orphanScripts.length) bad.push('실측_선언순서_오탐');
|
|
11737
|
+
} finally { try { fs.rmSync(sb4, { recursive: true, force: true }); } catch {} }
|
|
11738
|
+
}
|
|
11624
11739
|
// ⑰ (검수 P2) 와일드카드가 있다고 실행은 아니다 — `echo "test:*"` 는 아무것도 안 돌린다.
|
|
11625
11740
|
const r17 = PU._detectOrphanGuards({
|
|
11626
11741
|
packageScripts: { test: 'echo "test:*"', 'test:secrets': 'node s.js' },
|
|
@@ -11648,6 +11763,23 @@ total++;
|
|
|
11648
11763
|
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null }], scriptFiles: [],
|
|
11649
11764
|
});
|
|
11650
11765
|
if (r19b.orphanScripts.length) bad.push('산_turbo에서_보류_미작동');
|
|
11766
|
+
// 같은 우회로가 **동적 열거자**에도 있었다: 아무도 안 부르는 스크립트가 참조하는 열거자 파일이
|
|
11767
|
+
// "전부 태운다" 로 잡히면 경고 전체가 꺼진다. 열거자 판정도 live 러너에서만 한다.
|
|
11768
|
+
const r19c = PU._detectOrphanGuards({
|
|
11769
|
+
packageScripts: { test: 'node t.js', 'dead-enum': 'node scripts/enum.mjs', 'check:leaf': 'node l.js' },
|
|
11770
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [] },
|
|
11771
|
+
{ file: 'scripts/enum.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) run(n);`, viaScript: 'dead-enum', viaScripts: ['dead-enum'] }],
|
|
11772
|
+
scriptFiles: [],
|
|
11773
|
+
});
|
|
11774
|
+
if (!r19c.orphanScripts.includes('check:leaf')) bad.push('죽은_열거자가_전체를_덮음');
|
|
11775
|
+
// 판별력 — **도달 가능한** 스크립트가 부르는 열거자는 여전히 전체를 덮어야 한다(정상 저장소를 시끄럽게 만들면 안 된다)
|
|
11776
|
+
const r19d = PU._detectOrphanGuards({
|
|
11777
|
+
packageScripts: { test: 'node scripts/enum.mjs', 'check:leaf': 'node l.js' },
|
|
11778
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [] },
|
|
11779
|
+
{ file: 'scripts/enum.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) run(n);`, viaScript: 'test', viaScripts: ['test'] }],
|
|
11780
|
+
scriptFiles: [],
|
|
11781
|
+
});
|
|
11782
|
+
if (r19d.orphanScripts.length) bad.push('산_열거자를_무시:' + r19d.orphanScripts.join(','));
|
|
11651
11783
|
// ⑳ (검수 P2) npm 이 스스로 돌리는 생명주기 스크립트(postinstall 등)는 루트다 — 그 잎을 고아라 하면 오탐이다.
|
|
11652
11784
|
const r20 = PU._detectOrphanGuards({
|
|
11653
11785
|
packageScripts: { postinstall: 'npm run check:generated', 'check:generated': 'node g.js' },
|
|
@@ -11663,22 +11795,37 @@ total++;
|
|
|
11663
11795
|
const selfRes = PU._detectOrphanGuards(selfInp);
|
|
11664
11796
|
if (!selfInp.runners.some(r => /e2e\.js$/.test(r.file))) bad.push('자기수집:e2e.js가러너에없음'); // 셋업 유효성
|
|
11665
11797
|
if (selfRes.dynamicRunners.some(f => /e2e\.js$|e2e-core\.js$/.test(f))) bad.push(`자기참조:테스트픽스처가열거자로읽힘(${selfRes.dynamicRunners.join(',')})`);
|
|
11666
|
-
|
|
11667
|
-
//
|
|
11668
|
-
//
|
|
11669
|
-
//
|
|
11670
|
-
// ⚠
|
|
11798
|
+
// 기준값은 **탐지기**를 재는 것이지 수집 정책을 재는 것이 아니다. 이 저장소의 `bin/leerness.js` 는
|
|
11799
|
+
// *다른 프로젝트용* CI 를 생성하는 코드라 `npm run test:smoke` 같은 문자열을 담고 있고,
|
|
11800
|
+
// CI 가 그 파일을 직접 실행하므로 러너로 수집된다(정상 프로젝트에선 그게 맞는 판단이다).
|
|
11801
|
+
// 그래서 기준값은 **우리 CLI 소스를 뺀 입력**으로 잰다 — 탐지기가 망가지면 여기서 잡힌다.
|
|
11802
|
+
// ⚠ `test:core`·`test:smoke` 는 실제로 어떤 러너도 부르지 않는다(ci.yml 은 `test:fast` 만 돌린다).
|
|
11803
|
+
// 나중에 CI 에 배선하면 이 단언을 **의도적으로** 갱신하라.
|
|
11804
|
+
const selfNoBin = Object.assign({}, selfInp, { runners: selfInp.runners.filter(r => !/bin[\\/]leerness\.js$/.test(r.file)) });
|
|
11805
|
+
const selfBase = PU._detectOrphanGuards(selfNoBin);
|
|
11671
11806
|
for (const expect of ['test:smoke', 'test:core']) {
|
|
11672
|
-
if (!
|
|
11807
|
+
if (!selfBase.orphanScripts.includes(expect)) bad.push(`자기기준:${expect}가고아목록에없음(${selfBase.orphanScripts.join(',') || '없음'})`);
|
|
11673
11808
|
}
|
|
11674
|
-
if (
|
|
11809
|
+
if (selfBase.orphanScripts.includes('test:fast')) bad.push('자기기준:CI가도는test:fast를오탐');
|
|
11675
11810
|
// 그리고 **불변식**으로 고정한다: 이 테스트 파일 자체가 판정을 바꾸면 안 된다.
|
|
11676
11811
|
// 리터럴 쪼개기로 막으면 다음에 누가 이름을 적는 순간 또 뚫린다 — 값이 아니라 성질을 단언한다.
|
|
11677
|
-
const selfNoE2e = PU._detectOrphanGuards(Object.assign({},
|
|
11678
|
-
{ runners:
|
|
11679
|
-
|
|
11680
|
-
|
|
11812
|
+
const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfNoBin,
|
|
11813
|
+
{ runners: selfNoBin.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
|
|
11814
|
+
// ⚠ 비교 대상을 `orphanScripts` 하나로 두면 약한 불변식이다(검수 P3) — 판정 전체를 비교한다.
|
|
11815
|
+
// `orphanFiles` 는 뺀다 — **측정해 보고 내린 결론**이다: 러너를 하나 제거하면 그 러너가 언급하던
|
|
11816
|
+
// 파일 경로가 사라지므로 파일 커버리지가 바뀌는 것이 정상이다(실측: e2e.js 를 빼면 e2e-core.js 가
|
|
11817
|
+
// 고아 파일로 뜬다 — `test:core` 가 고아라 실제로도 안 도는 파일이다).
|
|
11818
|
+
// 이 불변식이 잡으려는 것은 **스크립트 이름이 우리 테스트 데이터에서 읽히는** 자기참조다.
|
|
11819
|
+
const _shape = (x) => JSON.stringify({
|
|
11820
|
+
s: x.orphanScripts.slice().sort(),
|
|
11821
|
+
d: x.dynamicRunners.slice().sort(), p: x.enumPrefixes.slice().sort(),
|
|
11822
|
+
i: x.scanIncomplete, o: x.deferred, m: x.monorepoOutOfScope,
|
|
11823
|
+
});
|
|
11824
|
+
if (_shape(selfBase) !== _shape(selfNoE2e)) {
|
|
11825
|
+
bad.push(`자기참조:테스트파일이_판정을_바꿈(${selfBase.orphanScripts.join(',')} vs ${selfNoE2e.orphanScripts.join(',')})`);
|
|
11681
11826
|
}
|
|
11827
|
+
// 셋업 유효성 — e2e.js 가 실제로 live 러너여야 이 불변식이 의미가 있다(비어 있으면 공허하게 통과한다)
|
|
11828
|
+
if (!selfNoBin.runners.some(r => /e2e\.js$/.test(r.file))) bad.push('자기참조:불변식이_공허(e2e.js가입력에없음)');
|
|
11682
11829
|
}
|
|
11683
11830
|
// ⑭ (검수 #14) Linux 는 XDG_CONFIG_HOME 이 있으면 그쪽이 정답이다 — ~/.config 는 기본값일 뿐이다.
|
|
11684
11831
|
{
|