leerness 1.36.113 → 1.36.116
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 +222 -49
- package/lib/audit.js +535 -421
- package/lib/catalogs.js +505 -505
- package/lib/pure-utils.js +220 -2
- package/package.json +1 -1
- package/scripts/e2e.js +729 -1
package/scripts/e2e.js
CHANGED
|
@@ -2942,7 +2942,7 @@ total++;
|
|
|
2942
2942
|
const rCur = cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', aDir], { encoding: 'utf8', timeout: 15000 });
|
|
2943
2943
|
const cursorOk = rCur.status === 0 && fs.existsSync(path.join(aDir, '.cursor', 'rules', 'leerness.mdc'));
|
|
2944
2944
|
let mcpOk = false;
|
|
2945
|
-
try { const m = JSON.parse(fs.readFileSync(path.join(aDir, '.mcp.json'), 'utf8')); mcpOk = m.mcpServers && m.mcpServers.leerness && m.mcpServers.leerness.args.join(' ') === 'leerness mcp serve'; } catch {}
|
|
2945
|
+
try { const m = JSON.parse(fs.readFileSync(path.join(aDir, '.mcp.json'), 'utf8')); mcpOk = m.mcpServers && m.mcpServers.leerness && m.mcpServers.leerness.args.join(' ') === '-y leerness mcp serve'; } catch {}
|
|
2946
2946
|
// adapter list --json: 9종
|
|
2947
2947
|
const rList = cp.spawnSync(process.execPath, [CLI, 'adapter', 'list', '--json', '--path', aDir], { encoding: 'utf8', timeout: 15000 });
|
|
2948
2948
|
let listOk = false;
|
|
@@ -10966,6 +10966,734 @@ total++;
|
|
|
10966
10966
|
if (!ok) failed++;
|
|
10967
10967
|
}
|
|
10968
10968
|
|
|
10969
|
+
// ── 1.36.114 블록 R: **방치된 명령**에 남아 있던 두 기지(旣知) 클래스를 고정한다.
|
|
10970
|
+
// 왜 여기까지 왔나: e2e 실행 커버리지에서 17개 명령이 한 번도 안 돌고 있었다. 그 방치 구역에는
|
|
10971
|
+
// leerness 가 **다른 곳에서는 이미 고친** 결함이 그대로 남아 있었다.
|
|
10972
|
+
// 클래스 A(1.36.28): 손상된 JSON 스토어를 "빈 값" 으로 오인해 덮어써 기존 데이터를 잃는다.
|
|
10973
|
+
// 실측 유실: credentials.local.json · agent-permissions.json · wakeup-history.json · next-action-queue.json
|
|
10974
|
+
// (보호돼 있던 대조군: user-requests.json · previews.json · toggles.json — 술어 `_assertStoreParsable` 는 이미 있었다.)
|
|
10975
|
+
// 클래스 B(1.36.113): 사용자 텍스트의 개행이 목록에서 가짜 항목을 만든다. creds·constraints 가 지난 라운드 목록 밖이었다.
|
|
10976
|
+
// ⚠ 이 라운드가 만든 회귀도 여기서 잡았다: 손상 시 저장은 막혔는데 명령이 `✓ 추가` 를 찍고 exit 0 이었다.
|
|
10977
|
+
// 조용한 무동작 + 성공 표시는 조용한 유실보다 나쁘다 — 사용자는 저장됐다고 믿는다. 그래서 **세 가지를 함께** 단언한다:
|
|
10978
|
+
// 안 쓴다 · 성공이라 말하지 않는다 · 정상 스토어에서는 오차단하지 않는다.
|
|
10979
|
+
{
|
|
10980
|
+
total++;
|
|
10981
|
+
let ok = false; const dbg = {};
|
|
10982
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-neg114-'));
|
|
10983
|
+
const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_NO_PROMPT: '1' });
|
|
10984
|
+
try {
|
|
10985
|
+
let seq = 0;
|
|
10986
|
+
const mk = () => {
|
|
10987
|
+
const d = path.join(sb, 'p' + (++seq)); fs.mkdirSync(d, { recursive: true });
|
|
10988
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
10989
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10990
|
+
return d;
|
|
10991
|
+
};
|
|
10992
|
+
const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
10993
|
+
const bad = [];
|
|
10994
|
+
|
|
10995
|
+
// ── 클래스 A: 손상 스토어. 대조군(이미 보호되던 표면)을 **같은 표에** 넣어야 판정이 의미를 갖는다.
|
|
10996
|
+
const STORES = [
|
|
10997
|
+
['creds', 'credentials.local.json', ['creds', 'register', 'aaa', '--env-var', 'A_KEY'], ['creds', 'register', 'bbb', '--env-var', 'B_KEY']],
|
|
10998
|
+
['permissions', 'agent-permissions.json', ['permissions', 'set', 'extended'], ['permissions', 'set', 'basic']],
|
|
10999
|
+
['wakeup-interval', 'wakeup-history.json', ['wakeup-interval', 'set', '900'], ['wakeup-interval', 'set', '1200']],
|
|
11000
|
+
['next-action', 'next-action-queue.json', ['next-action', 'add', 'aaa'], ['next-action', 'add', 'bbb']],
|
|
11001
|
+
['requests(대조)', 'user-requests.json', ['requests', 'add', 'aaa'], ['requests', 'add', 'bbb']],
|
|
11002
|
+
['preview(대조)', 'previews.json', ['preview', 'add', 'aaa'], ['preview', 'add', 'bbb']],
|
|
11003
|
+
// 대조군 3종을 다 넣는다 — toggles 가 빠져 있어 "보호 대조군 유지" 주장이 2/3 만 증명됐다(검수 지적).
|
|
11004
|
+
['toggle(대조)', 'toggles.json', ['toggle', 'set', 'gate', 'off'], ['toggle', 'set', 'lens', 'off']],
|
|
11005
|
+
];
|
|
11006
|
+
let setupOk = 0;
|
|
11007
|
+
for (const [name, file, first, second] of STORES) {
|
|
11008
|
+
const d = mk();
|
|
11009
|
+
const r1 = R(d, first);
|
|
11010
|
+
const fp = path.join(d, '.harness', file);
|
|
11011
|
+
if (r1.status !== 0 || !fs.existsSync(fp)) { bad.push(`${name}:셋업실패`); continue; } // 조용한 셋업 실패가 '통과' 로 둔갑하지 못하게
|
|
11012
|
+
setupOk++;
|
|
11013
|
+
fs.writeFileSync(fp, '{ "broken": ');
|
|
11014
|
+
const before = fs.readFileSync(fp, 'utf8');
|
|
11015
|
+
const r2 = R(d, second);
|
|
11016
|
+
const out = String(r2.stdout || '') + String(r2.stderr || '');
|
|
11017
|
+
if (fs.readFileSync(fp, 'utf8') !== before) bad.push(`${name}:손상위덮어씀`);
|
|
11018
|
+
if (r2.status === 0 || /^\s*✓/m.test(out)) bad.push(`${name}:성공이라말함`); // 이 라운드가 낸 회귀를 고정
|
|
11019
|
+
if (/\n\s+at .*:\d+:\d+/.test(out)) bad.push(`${name}:스택노출`);
|
|
11020
|
+
// 기계 계약 — `--json` 은 **순수 JSON**(stdout 에 사람용 줄이 섞이면 JSON.parse 가 깨진다) + ok:false + exit≠0.
|
|
11021
|
+
// 이 단언이 없어서 `_guardStore` 를 떼는 변이가 살아남았다 — 그 래퍼는 async 명령에서 아예 무력이었고,
|
|
11022
|
+
// 실측에서 `next-action add --json` 이 `✗` 줄을 JSON 앞에 흘리고 있었다.
|
|
11023
|
+
// ⚠ 사유 코드는 표면마다 다르다(store_corrupt / store_invalid / error). 통일은 기존 두 표면의 계약을
|
|
11024
|
+
// 바꾸는 일이라 이 라운드에서 하지 않는다 — **이번에 고친 4종만** store_corrupt 를 요구하고, 나머지는 형태만 본다.
|
|
11025
|
+
fs.writeFileSync(fp, '{ "broken": ');
|
|
11026
|
+
const brokenJ = fs.readFileSync(fp, 'utf8');
|
|
11027
|
+
const rj = R(d, second.concat(['--json']));
|
|
11028
|
+
let parsed = null; try { parsed = JSON.parse(String(rj.stdout || '').trim()); } catch {}
|
|
11029
|
+
if (!parsed) bad.push(`${name}:json_stdout오염`);
|
|
11030
|
+
else {
|
|
11031
|
+
if (parsed.ok !== false) bad.push(`${name}:json_ok아님`);
|
|
11032
|
+
if (!/대조/.test(name) && parsed.code !== 'store_corrupt') bad.push(`${name}:json_코드(${parsed.code})`);
|
|
11033
|
+
}
|
|
11034
|
+
if (rj.status === 0) bad.push(`${name}:json_exit0`);
|
|
11035
|
+
// ⚠ JSON 경로에서도 **파일이 그대로인지** 봐야 한다. 검수 지적: 종전엔 파싱·ok·exit 만 봐서
|
|
11036
|
+
// "덮어쓰고 나서 {ok:false} 를 돌려주는" 변이가 그대로 통과했다. stderr 스택도 함께 본다.
|
|
11037
|
+
if (fs.readFileSync(fp, 'utf8') !== brokenJ) bad.push(`${name}:json경로_손상위덮어씀`);
|
|
11038
|
+
if (/\n\s+at .*:\d+:\d+/.test(String(rj.stderr || ''))) bad.push(`${name}:json_stderr스택`);
|
|
11039
|
+
// 오차단 방지 — 정상 스토어에서는 성공해야 하고, **실제로 저장돼야** 한다.
|
|
11040
|
+
// 종전엔 exit code 만 봐서 조용한 no-op(성공 코드 + 성공 메시지, 저장 없음)이 통과했다(검수 지적).
|
|
11041
|
+
const d2 = mk(); R(d2, first);
|
|
11042
|
+
const fp2 = path.join(d2, '.harness', file);
|
|
11043
|
+
const pre = fs.existsSync(fp2) ? fs.readFileSync(fp2, 'utf8') : null;
|
|
11044
|
+
if (R(d2, second).status !== 0) bad.push(`${name}:정상스토어_오차단`);
|
|
11045
|
+
const post = fs.existsSync(fp2) ? fs.readFileSync(fp2, 'utf8') : null;
|
|
11046
|
+
if (pre === null || post === null || post === pre) bad.push(`${name}:정상스토어_저장안됨`);
|
|
11047
|
+
}
|
|
11048
|
+
dbg.setupOk = setupOk;
|
|
11049
|
+
if (setupOk !== STORES.length) bad.push(`셋업 ${setupOk}/${STORES.length}`);
|
|
11050
|
+
|
|
11051
|
+
// ── 클래스 B: 개행 위조. 지난 라운드 9표면 목록 **밖**이었던 두 곳.
|
|
11052
|
+
for (const [name, add, list] of [
|
|
11053
|
+
['creds', (d, t) => R(d, ['creds', 'register', t, '--env-var', 'X_KEY']), (d) => R(d, ['creds', 'list'])],
|
|
11054
|
+
['constraints', (d, t) => R(d, ['constraints', 'add', t, '--constraint', 'k:v']), (d) => R(d, ['constraints', 'list'])],
|
|
11055
|
+
// ⚠ ID 에만 개행을 넣으면 **값 쪽 필드**를 놓친다 — 검수가 `--constraint` 의 kind/detail 이 raw 임을 지적했다.
|
|
11056
|
+
// 같은 명령이라도 인자마다 따로 확인해야 한다(1.36.113 에서 plan 의 --progress 를 놓친 것과 같은 형태).
|
|
11057
|
+
['constraints(detail)', (d, t) => R(d, ['constraints', 'add', 'PLAT', '--constraint', `auth:${t}`]), (d) => R(d, ['constraints', 'list'])],
|
|
11058
|
+
]) {
|
|
11059
|
+
const d = mk();
|
|
11060
|
+
if (add(d, 'real ZR4A\n fake ZR4F · 등록됨').status !== 0) { bad.push(`${name}:주입add실패`); continue; }
|
|
11061
|
+
const o = String(list(d).stdout || '');
|
|
11062
|
+
if (!o.includes('ZR4A')) bad.push(`${name}:항목미노출`); // 계측 판별력
|
|
11063
|
+
if (o.split('\n').some(l => l.includes('ZR4F') && !l.includes('ZR4A'))) bad.push(`${name}:개행위조`);
|
|
11064
|
+
}
|
|
11065
|
+
|
|
11066
|
+
// ── 전수 대조: `.harness` 아래 **사용자 데이터 JSON 스토어 전부**를 하나씩 손상시키고 변경 명령을 돌린다.
|
|
11067
|
+
// 표적 사냥으로는 4종만 봤는데, 이 열거식 스윕이 decisions.json · lessons.json 을 더 찾았다
|
|
11068
|
+
// (leerness 가 내세우는 '영구 메모리' 표면이 손상 위에 덮어써져 사라지고 있었다).
|
|
11069
|
+
// 목록을 박아 두면 **가드 없는 새 스토어가 추가될 때** 실패한다 — 개별 발견이 아니라 목록을 지키는 가드다.
|
|
11070
|
+
{
|
|
11071
|
+
const SEED = [['requests', 'add', 'seed'], ['preview', 'add', 'seed'], ['toggle', 'set', 'gate', 'off'],
|
|
11072
|
+
['creds', 'register', 'seed', '--env-var', 'K'], ['permissions', 'set', 'extended'], ['wakeup-interval', 'set', '900'],
|
|
11073
|
+
['next-action', 'add', 'seed'], ['constraints', 'add', 'seed', '--constraint', 'k:v'],
|
|
11074
|
+
['decision', 'add', 'seed', '--why', 'r'], ['lesson', 'save', 'seed']];
|
|
11075
|
+
const MUT = [['requests', 'add', 'x2'], ['preview', 'add', 'x2'], ['toggle', 'set', 'lens', 'off'],
|
|
11076
|
+
['creds', 'register', 'x2', '--env-var', 'K2'], ['permissions', 'set', 'basic'], ['wakeup-interval', 'set', '1200'],
|
|
11077
|
+
['next-action', 'add', 'x2'], ['constraints', 'add', 'x2', '--constraint', 'k:v'],
|
|
11078
|
+
['decision', 'add', 'x2', '--why', 'r'], ['lesson', 'save', 'x2'], ['task', 'add', 'x2'], ['plan', 'add', 'x2']];
|
|
11079
|
+
const DERIVED = new Set(['manifest.json', 'skills-lock.json', 'leerness-config.json', 'environment.json']);
|
|
11080
|
+
const seeded = () => { const d = mk(); for (const a of SEED) R(d, a); return d; };
|
|
11081
|
+
const base = seeded();
|
|
11082
|
+
const stores = [];
|
|
11083
|
+
(function w(x, rel) { for (const e of fs.readdirSync(x, { withFileTypes: true })) {
|
|
11084
|
+
const p = path.join(x, e.name), r = rel ? rel + '/' + e.name : e.name;
|
|
11085
|
+
if (e.isDirectory()) { if (!/cache|archive|skills$/.test(e.name)) w(p, r); }
|
|
11086
|
+
else if (/\.json$/.test(e.name)) stores.push(r); } })(path.join(base, '.harness'), '');
|
|
11087
|
+
const userStores = stores.filter(f => !DERIVED.has(path.basename(f)));
|
|
11088
|
+
dbg.stores = userStores.length;
|
|
11089
|
+
// 계측 판별력 — 스토어가 갑자기 줄면(시드 실패 등) "유실 0" 이 공허해진다. 실측 9종 이상.
|
|
11090
|
+
if (userStores.length < 9) bad.push(`스토어열거부족(${userStores.length})`);
|
|
11091
|
+
for (const rel of userStores) {
|
|
11092
|
+
const d = seeded();
|
|
11093
|
+
const fp = path.join(d, '.harness', rel.replace(/\//g, path.sep));
|
|
11094
|
+
if (!fs.existsSync(fp)) continue;
|
|
11095
|
+
fs.writeFileSync(fp, '{ "broken": ');
|
|
11096
|
+
const broken = fs.readFileSync(fp, 'utf8');
|
|
11097
|
+
for (const a of MUT) {
|
|
11098
|
+
R(d, a);
|
|
11099
|
+
if (fs.existsSync(fp) && fs.readFileSync(fp, 'utf8') !== broken) { bad.push(`전수:${rel}←${a.join(' ')}`); break; }
|
|
11100
|
+
}
|
|
11101
|
+
}
|
|
11102
|
+
}
|
|
11103
|
+
|
|
11104
|
+
// ── "손상 ≠ 없음": 읽기 계열 명령이 손상 스토어를 **빈 값**으로 오인해 엉뚱한 진단을 내지 않는가.
|
|
11105
|
+
// 검수가 지적한 형태다 — `creds refresh` 가 "등록된 서비스 없음", `next-action take` 가 "큐 비어있음" 이라 했다.
|
|
11106
|
+
// 사용자는 등록이 지워졌다고 믿고 다시 등록하거나 handoff 를 또 돌린다. 원인(손상)은 끝까지 안 보인다.
|
|
11107
|
+
// ⚠ 대조군이 반드시 필요하다 — **정상 스토어에서 진짜로 없는** 경우까지 store_corrupt 라 하면 오차단이다.
|
|
11108
|
+
{
|
|
11109
|
+
const codeOf = (r) => { try { return (JSON.parse(String(r.stdout || '').trim()) || {}).code; } catch { return null; } };
|
|
11110
|
+
const seedCreds = (d) => R(d, ['creds', 'register', 'svc', '--env-var', 'K']);
|
|
11111
|
+
const corrupt = (d, f) => fs.writeFileSync(path.join(d, '.harness', f), '{ "broken": ');
|
|
11112
|
+
// (a) 손상 → store_corrupt 로 진단
|
|
11113
|
+
for (const [label, seed, file, act] of [
|
|
11114
|
+
['creds check', seedCreds, 'credentials.local.json', ['creds', 'check']],
|
|
11115
|
+
['creds refresh', seedCreds, 'credentials.local.json', ['creds', 'refresh', 'svc']],
|
|
11116
|
+
['next-action take', (d) => R(d, ['next-action', 'add', 'aaa']), 'next-action-queue.json', ['next-action', 'take', '0']],
|
|
11117
|
+
]) {
|
|
11118
|
+
const d = mk(); if (seed(d).status !== 0) { bad.push(`손상진단:${label}:셋업실패`); continue; }
|
|
11119
|
+
corrupt(d, file);
|
|
11120
|
+
const r = R(d, act.concat(['--json']));
|
|
11121
|
+
if (r.status === 0) bad.push(`손상진단:${label}:exit0`);
|
|
11122
|
+
if (codeOf(r) !== 'store_corrupt') bad.push(`손상진단:${label}:코드(${codeOf(r)})`);
|
|
11123
|
+
}
|
|
11124
|
+
// (b) 손상 → 사람용 읽기(list)도 조용히 "없음" 이라 하지 않는다
|
|
11125
|
+
{ const d = mk(); seedCreds(d); corrupt(d, 'credentials.local.json');
|
|
11126
|
+
const r = R(d, ['creds', 'list']);
|
|
11127
|
+
if (r.status === 0) bad.push('손상진단:creds list:조용히통과'); }
|
|
11128
|
+
// (c) 대조군 — 정상 스토어에서 **진짜 없는** 경우는 store_corrupt 가 아니어야 한다(오차단 방지)
|
|
11129
|
+
{ const d = mk(); seedCreds(d);
|
|
11130
|
+
const r = R(d, ['creds', 'refresh', 'nosuch', '--json']);
|
|
11131
|
+
if (codeOf(r) === 'store_corrupt') bad.push('손상진단:대조군_미등록을_손상이라함'); }
|
|
11132
|
+
{ const d = mk(); // 빈 registry(정상)는 그냥 "없음" 이어야 한다
|
|
11133
|
+
const r = R(d, ['creds', 'list']);
|
|
11134
|
+
if (r.status !== 0) bad.push('손상진단:대조군_빈registry_오차단'); }
|
|
11135
|
+
{ const d = mk(); R(d, ['next-action', 'add', 'aaa']); R(d, ['next-action', 'take', '0']);
|
|
11136
|
+
const r = R(d, ['next-action', 'take', '0', '--json']); // 정상인데 비어 있음 → 손상 아님
|
|
11137
|
+
if (codeOf(r) === 'store_corrupt') bad.push('손상진단:대조군_빈큐를_손상이라함'); }
|
|
11138
|
+
}
|
|
11139
|
+
|
|
11140
|
+
dbg.bad = bad.slice(0, 12);
|
|
11141
|
+
ok = bad.length === 0;
|
|
11142
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
11143
|
+
console.log(ok ? `✓ R(1.36.114) 방치 명령의 기지 클래스: 손상 스토어 6종(안 씀·성공이라 안 함·스택 없음·정상 오차단 없음·--json 계약) · 사용자 스토어 ${dbg.stores}종 전수 무유실 · 개행 위조 0(creds·constraints)`
|
|
11144
|
+
: '✗ 1.36.114 방치 명령 클래스 실패 ' + JSON.stringify(dbg));
|
|
11145
|
+
if (!ok) failed++;
|
|
11146
|
+
}
|
|
11147
|
+
|
|
11148
|
+
// ── 1.36.115 블록 S (사용자 보고): "leerness 가 설치된 프로젝트인데 Claude Code / Desktop 이 leerness 를 참조하지 않는다".
|
|
11149
|
+
// 원인은 세 갈래였고 전부 재현됐다:
|
|
11150
|
+
// ① 최소 등급의 CLAUDE.md 가 "AGENTS.md 를 따르세요" 한 줄 — Claude Code 가 읽는 파일에 **지시가 없었다**.
|
|
11151
|
+
// (P-0015 등급 도입 때 내가 만든 회귀다. 적재량을 줄인다며 도구를 쓰게 만드는 문장을 들어냈다.)
|
|
11152
|
+
// ② 스킬을 `.claude/skills/leerness.md`(평면)로 깔았다 — 규약은 `<name>/SKILL.md` 다.
|
|
11153
|
+
// 실측 증거: 이 저장소와 부모 프로젝트 양쪽에 평면 파일이 있는데 세션의 스킬 목록에는 없었고,
|
|
11154
|
+
// 같은 위치의 `.claude/commands/*.md` 는 전부 잡혔다.
|
|
11155
|
+
// ③ `init` 이 `.mcp.json` 을 만들지 않았다 — `adapter list` 는 claude 에 "[+.mcp.json]" 이라 광고하는데
|
|
11156
|
+
// 문서화된 진입점인 init 은 그 배선을 빼먹어, init 만 한 사용자의 Claude Code 는 도구를 하나도 못 봤다.
|
|
11157
|
+
// 이 가드는 "설치하면 도구가 leerness 를 쓰게 된다" 는 **제품의 기본 약속**을 계약으로 고정한다.
|
|
11158
|
+
{
|
|
11159
|
+
total++;
|
|
11160
|
+
let ok = false; const dbg = {};
|
|
11161
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-wire115-'));
|
|
11162
|
+
const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_NO_PROMPT: '1' });
|
|
11163
|
+
try {
|
|
11164
|
+
let seq = 0;
|
|
11165
|
+
const mk = (args) => {
|
|
11166
|
+
const d = path.join(sb, 'p' + (++seq)); fs.mkdirSync(d, { recursive: true });
|
|
11167
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
11168
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', ...(args || [])], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11169
|
+
return d;
|
|
11170
|
+
};
|
|
11171
|
+
const bad = [];
|
|
11172
|
+
// ① 두 등급 **모두** CLAUDE.md 가 handoff 를 직접 지시해야 한다(AGENTS.md 로 미루지 않는다)
|
|
11173
|
+
for (const [label, args, lang] of [['standard', [], 'ko'], ['minimal', ['--mode', 'minimal'], 'ko'],
|
|
11174
|
+
['standard-en', ['--language', 'en'], 'en'], ['minimal-en', ['--mode', 'minimal', '--language', 'en'], 'en']]) {
|
|
11175
|
+
const d = mk(args);
|
|
11176
|
+
const p = path.join(d, 'CLAUDE.md');
|
|
11177
|
+
if (!fs.existsSync(p)) { bad.push(`${label}:CLAUDE.md없음`); continue; }
|
|
11178
|
+
const t = fs.readFileSync(p, 'utf8');
|
|
11179
|
+
if (!/leerness handoff/.test(t)) bad.push(`${label}:handoff지시없음`);
|
|
11180
|
+
if (!/session close/.test(t)) bad.push(`${label}:sessionclose지시없음`);
|
|
11181
|
+
if (!/verify-claim/.test(t)) bad.push(`${label}:verify-claim지시없음`);
|
|
11182
|
+
// 계측 판별력: 파일이 비어 있으면 위 단언이 공허해진다
|
|
11183
|
+
if (t.length < 100) bad.push(`${label}:CLAUDE.md너무짧음(${t.length})`);
|
|
11184
|
+
// 1.36.116 (검수 #16-1): 위 세 단언은 **토큰 존재**만 본다 — 영문 프로젝트에 한국어 문장이 섞여도 통과한다.
|
|
11185
|
+
// 이 저장소의 최빈 i18n 실패는 오역이 아니라 **기존 언어 표면 오염**이고, 이번 라운드에도 실제로 났다
|
|
11186
|
+
// (전역 치환이 _GROUP_USAGE_EN 에 한국어를 넣었다). 언어별 대칭을 조합마다 잰다.
|
|
11187
|
+
if (lang === 'en' && /[가-힣]/.test(t)) bad.push(`${label}:영문표면에한국어(${(t.match(/[가-힣]+/g) || [])[0]})`);
|
|
11188
|
+
if (lang === 'ko' && !/[가-힣]/.test(t)) bad.push(`${label}:한국어표면이비어있음`);
|
|
11189
|
+
}
|
|
11190
|
+
// ② 스킬은 Claude Code 규약(<name>/SKILL.md)으로 놓이고 frontmatter 가 유효해야 한다
|
|
11191
|
+
{
|
|
11192
|
+
const d = mk();
|
|
11193
|
+
const sp = path.join(d, '.claude', 'skills', 'leerness', 'SKILL.md');
|
|
11194
|
+
if (!fs.existsSync(sp)) bad.push('skill:SKILL.md없음');
|
|
11195
|
+
else {
|
|
11196
|
+
const t = fs.readFileSync(sp, 'utf8');
|
|
11197
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(t);
|
|
11198
|
+
if (!fm) bad.push('skill:frontmatter없음');
|
|
11199
|
+
// 1.36.116 (검수 #16-5): `name:` 이 비어 있지 않은지만 봤다 — `name: wrong-skill` 도 통과한다.
|
|
11200
|
+
// Claude Code 는 디렉터리명과 frontmatter name 으로 스킬을 찾으므로 값 자체가 계약이다.
|
|
11201
|
+
else { if (!/^name:\s*leerness\s*$/m.test(fm[1])) bad.push('skill:name이leerness아님'); if (!/^description:\s*\S/m.test(fm[1])) bad.push('skill:description없음'); }
|
|
11202
|
+
if (!/leerness handoff/.test(t)) bad.push('skill:본문에 handoff 없음');
|
|
11203
|
+
}
|
|
11204
|
+
// 평면 파일을 새로 만들지 않는다(구버전 잔재는 별개 — 지우지 않되 새로 만들지도 않는다)
|
|
11205
|
+
if (fs.existsSync(path.join(d, '.claude', 'skills', 'leerness.md'))) bad.push('skill:평면파일을새로만듦');
|
|
11206
|
+
// ⚠ `init` 만 밟으면 안 된다. 실제로 `lib/catalogs.js` 의 어댑터 목록이 옛 평면 경로를 유지하고 있어
|
|
11207
|
+
// `adapter claude` 가 이 수정을 되돌리고 있었다(1.36.116 에서 발견) — 가드가 밟지 않은 경로였다.
|
|
11208
|
+
const da = mk(['--minimal']);
|
|
11209
|
+
const ra = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', da], { cwd: da, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11210
|
+
if (ra.status !== 0) bad.push(`adapter:실패(exit=${ra.status})`);
|
|
11211
|
+
if (!fs.existsSync(path.join(da, '.claude', 'skills', 'leerness', 'SKILL.md'))) bad.push('adapter:SKILL.md없음');
|
|
11212
|
+
if (fs.existsSync(path.join(da, '.claude', 'skills', 'leerness.md'))) bad.push('adapter:평면파일을만듦');
|
|
11213
|
+
// 1.36.116 (검수 #16-3): `init` 이 이미 .mcp.json 을 만들어 둔 뒤라, adapter 에서 병합 호출을 통째로 **삭제해도**
|
|
11214
|
+
// 이 단언은 통과한다(변이 생존). adapter 자신이 만드는지 보려면 먼저 지우고 재실행해야 한다.
|
|
11215
|
+
fs.rmSync(path.join(da, '.mcp.json'), { force: true });
|
|
11216
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', da], { cwd: da, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11217
|
+
if (!fs.existsSync(path.join(da, '.mcp.json'))) bad.push('adapter:.mcp.json없음(adapter가직접만들지않음)');
|
|
11218
|
+
// 1.36.116 (검수 #11): adapter 가 manifest 의 **설치 등급**을 읽지 않아 `--minimal` 로 줄여 둔 CLAUDE.md 를
|
|
11219
|
+
// standard 로 되돌리고 있었다. 사용자가 고른 침투성을 다른 명령이 조용히 뒤집으면 그 설정은 의미가 없다.
|
|
11220
|
+
const dm2 = mk(['--minimal']);
|
|
11221
|
+
const beforeLen = fs.readFileSync(path.join(dm2, 'CLAUDE.md'), 'utf8').length;
|
|
11222
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dm2], { cwd: dm2, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11223
|
+
const afterLen = fs.readFileSync(path.join(dm2, 'CLAUDE.md'), 'utf8').length;
|
|
11224
|
+
if (afterLen > beforeLen * 1.5) bad.push(`adapter:등급무시(${beforeLen}→${afterLen})`);
|
|
11225
|
+
// 판별력 — standard 프로젝트에서는 adapter 가 여전히 standard 를 유지해야 한다(등급 반영이 전부를 줄이면 안 된다)
|
|
11226
|
+
const ds2 = mk();
|
|
11227
|
+
const sBefore = fs.readFileSync(path.join(ds2, 'CLAUDE.md'), 'utf8').length;
|
|
11228
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', ds2], { cwd: ds2, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11229
|
+
const sAfter = fs.readFileSync(path.join(ds2, 'CLAUDE.md'), 'utf8').length;
|
|
11230
|
+
if (sAfter < sBefore * 0.5) bad.push(`adapter:standard를_축소(${sBefore}→${sAfter})`);
|
|
11231
|
+
}
|
|
11232
|
+
// ③ init 이 .mcp.json 을 만들고, 기존 서버를 보존하며, 손상 파일은 덮어쓰지 않는다
|
|
11233
|
+
{
|
|
11234
|
+
const d = mk();
|
|
11235
|
+
const mp = path.join(d, '.mcp.json');
|
|
11236
|
+
if (!fs.existsSync(mp)) bad.push('mcp:init이안만듦');
|
|
11237
|
+
else {
|
|
11238
|
+
let j = null; try { j = JSON.parse(fs.readFileSync(mp, 'utf8')); } catch {}
|
|
11239
|
+
if (!j || !j.mcpServers || !j.mcpServers.leerness) bad.push('mcp:leerness항목없음');
|
|
11240
|
+
}
|
|
11241
|
+
// 기존 서버 보존 — 재init 이 남의 MCP 등록을 날리면 안 된다
|
|
11242
|
+
const d2 = mk();
|
|
11243
|
+
fs.writeFileSync(path.join(d2, '.mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'x', args: [] } } }, null, 2));
|
|
11244
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes'], { cwd: d2, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11245
|
+
let j2 = null; try { j2 = JSON.parse(fs.readFileSync(path.join(d2, '.mcp.json'), 'utf8')); } catch {}
|
|
11246
|
+
if (!j2 || !j2.mcpServers || !j2.mcpServers.other) bad.push('mcp:기존서버유실');
|
|
11247
|
+
if (!j2 || !j2.mcpServers || !j2.mcpServers.leerness) bad.push('mcp:병합후leerness없음');
|
|
11248
|
+
// 손상 .mcp.json 은 보존한다(1.36.41 클래스 — init 이 그 위를 덮으면 사용자의 다른 등록이 날아간다)
|
|
11249
|
+
const d3 = mk();
|
|
11250
|
+
fs.writeFileSync(path.join(d3, '.mcp.json'), '{ "mcpServers": ');
|
|
11251
|
+
const broken = fs.readFileSync(path.join(d3, '.mcp.json'), 'utf8');
|
|
11252
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d3, '--yes'], { cwd: d3, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11253
|
+
if (fs.readFileSync(path.join(d3, '.mcp.json'), 'utf8') !== broken) bad.push('mcp:손상파일덮어씀');
|
|
11254
|
+
// 1.36.116 (검수 #2): 루트만 검사했더니 `{"mcpServers":[]}` — **유효 JSON·틀린 스키마** 에서 배열에
|
|
11255
|
+
// named property 를 붙이고 stringify 가 그걸 버려, 등록 0인데 성공 보고했다(조용한 무동작 + 성공 표시).
|
|
11256
|
+
const d4 = mk();
|
|
11257
|
+
const badSchema = JSON.stringify({ mcpServers: [] }, null, 2);
|
|
11258
|
+
fs.writeFileSync(path.join(d4, '.mcp.json'), badSchema);
|
|
11259
|
+
const r4 = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d4], { cwd: d4, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11260
|
+
if (fs.readFileSync(path.join(d4, '.mcp.json'), 'utf8') !== badSchema) bad.push('mcp:틀린스키마를_덮어씀');
|
|
11261
|
+
if (r4.status === 0) bad.push('mcp:틀린스키마에서_성공주장');
|
|
11262
|
+
// 1.36.116 (검수 #12): `--no-mcp` 가 _BOOL_FLAGS 에 없어 "알 수 없는 플래그" 경고를 냈다 — 문서화된 opt-out 이
|
|
11263
|
+
// 경고를 내면 사용자는 그게 듣는지 알 수 없다. 경고 0 + 실제로 안 만들기, 둘 다 본다.
|
|
11264
|
+
const d5 = path.join(sb, 'nomcp'); fs.mkdirSync(d5, { recursive: true });
|
|
11265
|
+
fs.writeFileSync(path.join(d5, 'package.json'), '{"name":"n","version":"0.1.0"}');
|
|
11266
|
+
const r5 = cp.spawnSync(process.execPath, [CLI, 'init', d5, '--yes', '--no-mcp'], { cwd: d5, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11267
|
+
if (/알 수 없는 (플래그|옵션)|unknown flag/i.test(String(r5.stdout || '') + String(r5.stderr || ''))) bad.push('mcp:no-mcp가_미등록경고');
|
|
11268
|
+
if (fs.existsSync(path.join(d5, '.mcp.json'))) bad.push('mcp:no-mcp인데_생성됨');
|
|
11269
|
+
// 1.36.116 (검수 P1): 사용자가 직접 만든 `leerness` 항목(래퍼·버전고정·env)을 무조건 덮어쓰고 있었다.
|
|
11270
|
+
// 이 저장소의 계약은 false-DROP 이 버그다 — 남의 설정은 보존한다.
|
|
11271
|
+
const d7 = mk();
|
|
11272
|
+
const custom = { mcpServers: { leerness: { command: 'node', args: ['./local-mcp.js'], env: { KEEP: '1' } } } };
|
|
11273
|
+
fs.writeFileSync(path.join(d7, '.mcp.json'), JSON.stringify(custom, null, 2));
|
|
11274
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d7], { cwd: d7, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11275
|
+
let j7 = null; try { j7 = JSON.parse(fs.readFileSync(path.join(d7, '.mcp.json'), 'utf8')); } catch {}
|
|
11276
|
+
if (!j7 || j7.mcpServers.leerness.command !== 'node' || !(j7.mcpServers.leerness.env || {}).KEEP) bad.push('mcp:사용자정의항목을덮어씀');
|
|
11277
|
+
// 판별력 — **우리가 만든** 항목은 갱신돼야 한다(보존이 과하면 -y 같은 마이그레이션이 영원히 안 된다)
|
|
11278
|
+
const d8 = mk();
|
|
11279
|
+
fs.writeFileSync(path.join(d8, '.mcp.json'), JSON.stringify({ mcpServers: { leerness: { command: 'npx', args: ['leerness', 'mcp', 'serve'] } } }, null, 2));
|
|
11280
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d8], { cwd: d8, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11281
|
+
let j8 = null; try { j8 = JSON.parse(fs.readFileSync(path.join(d8, '.mcp.json'), 'utf8')); } catch {}
|
|
11282
|
+
if (!j8 || (j8.mcpServers.leerness.args || [])[0] !== '-y') bad.push('mcp:우리항목이_갱신안됨');
|
|
11283
|
+
// 1.36.116 (검수 P2-A): 잘못된 스키마에서 `init` 은 warning 만 내고 성공으로 끝났다 — 등록 0인데 사용자는 됐다고 믿는다.
|
|
11284
|
+
const d9 = path.join(sb, 'badschema'); fs.mkdirSync(d9, { recursive: true });
|
|
11285
|
+
fs.writeFileSync(path.join(d9, 'package.json'), '{"name":"b","version":"0.1.0"}');
|
|
11286
|
+
const bs = JSON.stringify({ mcpServers: [] }, null, 2);
|
|
11287
|
+
fs.writeFileSync(path.join(d9, '.mcp.json'), bs);
|
|
11288
|
+
const r9 = cp.spawnSync(process.execPath, [CLI, 'init', d9, '--yes'], { cwd: d9, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11289
|
+
const out9 = String(r9.stdout || '') + String(r9.stderr || '');
|
|
11290
|
+
if (fs.readFileSync(path.join(d9, '.mcp.json'), 'utf8') !== bs) bad.push('mcp:init이_틀린스키마를덮어씀');
|
|
11291
|
+
if (!/등록하지 못했|MCP/.test(out9) || !/✗|⚠/.test(out9)) bad.push('mcp:init이_미등록을_알리지않음');
|
|
11292
|
+
}
|
|
11293
|
+
// ⑥ (검수 P1-D) Cursor 의 프로젝트별 MCP 설정 위치는 `.cursor/mcp.json` 이다 — 루트 `.mcp.json` 만 만들고
|
|
11294
|
+
// "Cursor 도 인식합니다" 라고 말하던 것은 거짓 안내였다.
|
|
11295
|
+
{
|
|
11296
|
+
const dc = mk();
|
|
11297
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc], { cwd: dc, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11298
|
+
const cf = path.join(dc, '.cursor', 'mcp.json');
|
|
11299
|
+
if (!fs.existsSync(cf)) bad.push('cursor:.cursor/mcp.json없음');
|
|
11300
|
+
else { let jc = null; try { jc = JSON.parse(fs.readFileSync(cf, 'utf8')); } catch {} if (!jc || !(jc.mcpServers || {}).leerness) bad.push('cursor:항목없음'); }
|
|
11301
|
+
// 손상 파일은 여전히 보존해야 한다(병합기 계약이 새 경로에도 걸리는지)
|
|
11302
|
+
const dc2 = mk();
|
|
11303
|
+
fs.mkdirSync(path.join(dc2, '.cursor'), { recursive: true });
|
|
11304
|
+
const brokenC = '{ "mcpServers": ';
|
|
11305
|
+
fs.writeFileSync(path.join(dc2, '.cursor', 'mcp.json'), brokenC);
|
|
11306
|
+
cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc2], { cwd: dc2, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11307
|
+
if (fs.readFileSync(path.join(dc2, '.cursor', 'mcp.json'), 'utf8') !== brokenC) bad.push('cursor:손상파일덮어씀');
|
|
11308
|
+
}
|
|
11309
|
+
// ④ Desktop 안내 — 앱 설정을 **자동으로 쓰지 않고** 경로와 스니펫을 준다
|
|
11310
|
+
{
|
|
11311
|
+
const d = mk();
|
|
11312
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 120000, env: ENV });
|
|
11313
|
+
let j = null; try { j = JSON.parse(String(r.stdout || '').trim()); } catch {}
|
|
11314
|
+
if (r.status !== 0) bad.push(`desktop:exit=${r.status}`); // 1.36.116 (검수 #16-4): 정상 출력 뒤 exit 1 이어도 통과하던 자리
|
|
11315
|
+
if (!j) bad.push('desktop:json아님');
|
|
11316
|
+
else {
|
|
11317
|
+
if (!j.configPath || !/claude_desktop_config\.json$/.test(j.configPath)) bad.push('desktop:설정경로없음');
|
|
11318
|
+
// basename 만 보면 엉뚱한 디렉터리를 가리켜도 통과한다 — 플랫폼별 실제 위치를 단언한다.
|
|
11319
|
+
const _cp = String(j.configPath).replace(/\\/g, '/');
|
|
11320
|
+
const _expect = process.platform === 'win32' ? /\/Claude\/claude_desktop_config\.json$/
|
|
11321
|
+
: process.platform === 'darwin' ? /\/Library\/Application Support\/Claude\/claude_desktop_config\.json$/
|
|
11322
|
+
: /\/Claude\/claude_desktop_config\.json$/;
|
|
11323
|
+
if (!_expect.test(_cp)) bad.push(`desktop:플랫폼경로불일치(${_cp.slice(-60)})`);
|
|
11324
|
+
if (!j.snippet || !j.snippet.mcpServers || !j.snippet.mcpServers.leerness) bad.push('desktop:스니펫없음');
|
|
11325
|
+
// 1.36.116 (검수 #13): `-y` 가 없으면 npx 가 설치 여부를 되묻고 GUI 앱은 그 프롬프트에 답할 수 없다.
|
|
11326
|
+
const _args = ((j.snippet.mcpServers || {}).leerness || {}).args || [];
|
|
11327
|
+
if (_args[0] !== '-y') bad.push('desktop:npx무인설치아님');
|
|
11328
|
+
}
|
|
11329
|
+
// 사람용 출력도 경로를 보여줘야 한다(사용자가 복붙할 대상이다)
|
|
11330
|
+
const h = String(cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', d], { cwd: d, encoding: 'utf8', timeout: 120000, env: ENV }).stdout || '');
|
|
11331
|
+
if (!/claude_desktop_config\.json/.test(h)) bad.push('desktop:사람용출력에경로없음');
|
|
11332
|
+
// 1.36.116 (검수 #1, P1): 전체 객체를 보여주면서 "mcpServers 안에 넣으세요" 라고 하면 문구대로 따른 사용자가
|
|
11333
|
+
// `mcpServers.mcpServers.leerness` 를 만든다 → Desktop 이 서버를 못 찾아 **이 명령이 고치려던 증상**이 재발한다.
|
|
11334
|
+
// 그래서 사람용 출력을 실제로 파싱해, 붙여넣기용 조각 두 가지가 **각각 그대로 쓰면 맞는지** 본다.
|
|
11335
|
+
// ⚠ "두 형태가 어딘가 있다" 로 재면 공허하다(검수 P1): ⓐ 문구를 '전체를 mcpServers 안에 넣으세요' 로
|
|
11336
|
+
// 되돌려도 ⓑ 가 남아 있으면 통과한다. 그래서 **구간별 대응**을 잰다 — ⓐ 라벨 아래 블록은 파일 전체 형태,
|
|
11337
|
+
// ⓑ 라벨 아래 블록은 mcpServers 안에 넣을 단일 property 여야 하고, 라벨 문구도 각각 그 뜻이어야 한다.
|
|
11338
|
+
const grab = (sec, startRe) => {
|
|
11339
|
+
const ls = String(sec).split('\n');
|
|
11340
|
+
const i = ls.findIndex(l => startRe.test(l));
|
|
11341
|
+
if (i < 0) return null;
|
|
11342
|
+
for (let j = i + 1; j < ls.length; j++) if (/^\s{4}[}\]]\s*$/.test(ls[j])) return ls.slice(i, j + 1).join('\n');
|
|
11343
|
+
return null;
|
|
11344
|
+
};
|
|
11345
|
+
const secA = h.split('ⓐ')[1] || '', secB = h.split('ⓑ')[1] || '';
|
|
11346
|
+
if (!secA || !secB) bad.push('desktop:두형태구분없음');
|
|
11347
|
+
else {
|
|
11348
|
+
const labelA = secA.split('\n')[0], labelB = secB.split('\n')[0];
|
|
11349
|
+
const bA = grab(secA, /^\s{4}\{\s*$/), bB = grab(secB, /^\s{4}"leerness":\s*\{\s*$/);
|
|
11350
|
+
let wholeOk = false, fragOk = false;
|
|
11351
|
+
if (bA) { try { const o = JSON.parse(bA); wholeOk = !!(o.mcpServers && o.mcpServers.leerness && !o.mcpServers.mcpServers); } catch {} }
|
|
11352
|
+
if (bB) { try { const o = JSON.parse('{' + bB + '}'); fragOk = !!(o.leerness && o.leerness.command); } catch {} }
|
|
11353
|
+
if (!wholeOk) bad.push('desktop:ⓐ가파일전체형태아님');
|
|
11354
|
+
if (!fragOk) bad.push('desktop:ⓑ가단일항목형태아님');
|
|
11355
|
+
// 대응: ⓐ 는 '넣으라' 가 아니라 '저장하라' 여야 하고(안에 넣으면 mcpServers.mcpServers 가 된다), ⓑ 는 '안에 넣으라' 여야 한다
|
|
11356
|
+
if (/안에[^\n]*(넣|추가)/.test(labelA)) bad.push('desktop:ⓐ가중첩을지시(이중중첩유도)');
|
|
11357
|
+
if (!/mcpServers/.test(labelB) || !/안에[^\n]*(넣|추가)/.test(labelB)) bad.push('desktop:ⓑ가위치를안알려줌');
|
|
11358
|
+
// ⓐ 의 조건이 '파일이 없거나 비어 있을 때' 여야 한다 — 'mcpServers 가 없을 때' 로 걸면
|
|
11359
|
+
// 다른 top-level 설정만 있는 파일을 통째로 덮어쓰게 안내한다(검수 P1)
|
|
11360
|
+
if (/mcpServers[^\n]*없/.test(labelA)) bad.push('desktop:ⓐ조건이기존설정을덮어씀');
|
|
11361
|
+
}
|
|
11362
|
+
}
|
|
11363
|
+
// ⑤ (검수 #12) `mcp install` 이 그룹 도움말에 없으면 사용자는 이 명령의 존재 자체를 모른다 — 안내는 발견돼야 안내다
|
|
11364
|
+
{
|
|
11365
|
+
const rh = cp.spawnSync(process.execPath, [CLI, 'mcp'], { encoding: 'utf8', timeout: 120000, env: ENV });
|
|
11366
|
+
const ht = String(rh.stdout || '') + String(rh.stderr || '');
|
|
11367
|
+
if (!/mcp install/.test(ht)) bad.push('mcp:그룹도움말에install없음');
|
|
11368
|
+
}
|
|
11369
|
+
dbg.bad = bad.slice(0, 12);
|
|
11370
|
+
ok = bad.length === 0;
|
|
11371
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
11372
|
+
console.log(ok ? '✓ S(1.36.115) 설치하면 도구가 leerness 를 쓴다: CLAUDE.md 가 4조합 모두에서 직접 지시 · 스킬은 <name>/SKILL.md 규약 + frontmatter · init 이 .mcp.json 생성(기존 서버 보존 · 손상 파일 미덮어씀) · Desktop 은 자동수정 없이 경로+스니펫 안내'
|
|
11373
|
+
: '✗ 1.36.115 도구 배선 실패 ' + JSON.stringify(dbg));
|
|
11374
|
+
if (!ok) failed++;
|
|
11375
|
+
}
|
|
11376
|
+
|
|
11377
|
+
// ── 1.36.116 블록 T: "존재하지만 아무도 실행하지 않는 가드" 탐지.
|
|
11378
|
+
// 근거는 실제 프로젝트다 — 어떤 저장소의 CI 파일이 자기 사고를 이렇게 적어 놨다:
|
|
11379
|
+
// "가드가 여럿 있는데 CI 에 하나도 연결돼 있지 않았다. 배포 전 검수가 세 번 연속 실질 결함을 찾아냈고,
|
|
11380
|
+
// 그중 여러 건이 '가드는 있는데 아무도 안 돌렸다' 에서 왔다. 사람이 돌려야만 유효한 검사는 결국 안 돌아간다."
|
|
11381
|
+
// leerness 는 위생을 감사하면서 이 클래스를 보지 않았다.
|
|
11382
|
+
// ⚠ 이 기능의 성패는 **오탐**에 달렸다. 잘 만든 프로젝트는 CI 에 이름을 나열하지 않고 package.json 을
|
|
11383
|
+
// 동적으로 열거한다 — 그걸 못 알아보면 가장 모범적인 저장소에서 수십 건이 뜨고 사용자는 경고를 꺼 버린다.
|
|
11384
|
+
// 실제로 초안이 그 실측 프로젝트에서 **85건 오탐**을 냈고(근접 창 가정 때문), 그래서 아래 세 대조군을 모두 건다.
|
|
11385
|
+
{
|
|
11386
|
+
total++;
|
|
11387
|
+
let ok = false; const dbg = {};
|
|
11388
|
+
try {
|
|
11389
|
+
const PU = require(path.resolve(path.dirname(CLI), '..', 'lib', 'pure-utils'));
|
|
11390
|
+
const bad = [];
|
|
11391
|
+
// ⚠ 자기참조 회피(5회차): 이 파일 자신이 `scripts/e2e.js` 로 **러너로 수집된다**. 열거자 모양의 문자열을
|
|
11392
|
+
// 그대로 적으면 leerness 가 자기 테스트 픽스처를 진짜 열거자로 읽고, 그 결과 자기 자신의 진짜 고아
|
|
11393
|
+
// (`test:smoke`)가 '덮임' 으로 사라진다 — 실제로 이번 라운드에 두 번 났다. 리터럴을 쪼개서 만든다.
|
|
11394
|
+
const _ENUM_LIT = 'Object.' + 'keys(pkg.' + 'scripts)';
|
|
11395
|
+
const _RE_LIT = (p) => '/' + '^' + p + '/.' + 'test(n)';
|
|
11396
|
+
// ① 동적 열거자를 알아본다 — 읽기와 열거가 **멀리 떨어져 있어도**(실측 56줄) 인식해야 한다
|
|
11397
|
+
const farApart = {
|
|
11398
|
+
// `check:types` 는 열거자의 `test:` 접두 밖이라 실제로 안 돈다 — 그건 오탐이 아니라 참이다.
|
|
11399
|
+
// (초안은 여기에 bare `lint` 를 썼다가 실패했다. 제품이 그걸 **관례적 진입점**으로 제외하는 것이 옳고,
|
|
11400
|
+
// 틀린 쪽은 내 기대였다 — 진입점과 잎 가드를 섞어 시험한 것.)
|
|
11401
|
+
packageScripts: { 'test:a': 'x', 'test:b': 'y', 'check:types': 'z', 'test:ci': 'node scripts/run.mjs' },
|
|
11402
|
+
runners: [
|
|
11403
|
+
{ file: '.github/workflows/ci.yml', text: 'run: npm run test:ci' },
|
|
11404
|
+
// ⚠ 리터럴을 **쪼개서** 만든다. 이 픽스처를 그대로 적으면 e2e.js 자신이 '동적 열거자' 로 보이고,
|
|
11405
|
+
// audit 이 이 저장소를 스캔할 때 leerness 자신의 실제 고아(`test:smoke`)가 가려진다 —
|
|
11406
|
+
// 소스가드의 자기참조 함정과 같은 형태이고, 해법도 같다(분할 리터럴).
|
|
11407
|
+
{ file: 'scripts/run.mjs', text: 'const pkg = JSON.parse(readFileSync("../package.json"));\n' + '// 주석\n'.repeat(60) + 'const all = Object.' + 'entries(pkg.' + 'scripts ?? {}).filter(([name]) => name.start' + 'sWith("test:"));' },
|
|
11408
|
+
],
|
|
11409
|
+
scriptFiles: [],
|
|
11410
|
+
};
|
|
11411
|
+
const r1 = PU._detectOrphanGuards(farApart);
|
|
11412
|
+
if (!r1.dynamicRunners.length) bad.push('열거자_미인식');
|
|
11413
|
+
if (r1.orphanScripts.includes('test:a') || r1.orphanScripts.includes('test:b')) bad.push('열거대상을_고아로_오탐');
|
|
11414
|
+
if (!r1.enumPrefixes.includes('test:')) bad.push('열거접두_미추출');
|
|
11415
|
+
// 열거자가 test:* 만 태우면 `lint` 는 실제로 안 도는 게 사실이다 — 그건 오탐이 아니라 참이다
|
|
11416
|
+
if (!r1.orphanScripts.includes('check:types')) bad.push('열거범위밖을_덮인것으로_오판');
|
|
11417
|
+
|
|
11418
|
+
// ② 진짜 고아는 잡는다(무능 아님) — 배열 호출 형태(spawn)도 '부름' 으로 본다
|
|
11419
|
+
const r2 = PU._detectOrphanGuards({
|
|
11420
|
+
packageScripts: { 'test:ci': 'node scripts/run-ci.js', 'test:unit': 'node t.js', 'check:secrets': 'node s.js' },
|
|
11421
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm run test:ci' },
|
|
11422
|
+
{ file: 'scripts/run-ci.js', text: 'spawn("npm",["run","test:unit"])' }],
|
|
11423
|
+
scriptFiles: ['scripts/verify-orphan.js', 'scripts/run-ci.js'],
|
|
11424
|
+
});
|
|
11425
|
+
if (JSON.stringify(r2.orphanScripts) !== JSON.stringify(['check:secrets'])) bad.push(`고아검출오차(${JSON.stringify(r2.orphanScripts)})`);
|
|
11426
|
+
if (JSON.stringify(r2.orphanFiles) !== JSON.stringify(['scripts/verify-orphan.js'])) bad.push(`고아파일오차(${JSON.stringify(r2.orphanFiles)})`);
|
|
11427
|
+
|
|
11428
|
+
// ③ 관례적 진입점은 대상이 아니다 — `npm test` 하나뿐인 신규 프로젝트가 시끄러우면 이 경고는 죽는다
|
|
11429
|
+
const r3 = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', build: 'tsc' }, runners: [], scriptFiles: [] });
|
|
11430
|
+
if (r3.orphanScripts.length) bad.push(`진입점_오탐(${r3.orphanScripts.join(',')})`);
|
|
11431
|
+
// ③-b npm 생명주기 훅(pre*/post*)은 npm 자신이 러너다 — 고아가 아니다(작성 중 실제로 오탐이 났다).
|
|
11432
|
+
const r3b = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', pretest: 'node p.js', posttest: 'node q.js', 'test:x': 'node x.js' }, runners: [], scriptFiles: [] });
|
|
11433
|
+
if (r3b.orphanScripts.includes('pretest') || r3b.orphanScripts.includes('posttest')) bad.push(`생명주기훅_오탐(${r3b.orphanScripts.join(',')})`);
|
|
11434
|
+
if (!r3b.orphanScripts.includes('test:x')) bad.push('생명주기_제외가_잎가드까지_삼킴'); // 판별력: 과도하게 제외하지 않는다
|
|
11435
|
+
|
|
11436
|
+
// ④ 근거를 반드시 함께 준다 — 목록만 주면 사용자는 못 믿고 넘긴다
|
|
11437
|
+
if (!r1.reason || !r2.reason) bad.push('근거_없음');
|
|
11438
|
+
|
|
11439
|
+
// ⑤ **주석 속 언급은 실행이 아니다.** 이 저장소에서 실제로 겪었다 — 이 함정을 설명하는 주석에
|
|
11440
|
+
// 스크립트 이름을 적었더니 그 이름이 '덮임' 으로 처리돼 진짜 고아가 사라졌다(자기참조 3회차).
|
|
11441
|
+
const r5 = PU._detectOrphanGuards({
|
|
11442
|
+
packageScripts: { 'test:ghost': 'node g.js' },
|
|
11443
|
+
runners: [{ file: '.github/workflows/ci.yml', text: '# 언젠가 test:ghost 를 넣자\nrun: npm run build' }],
|
|
11444
|
+
scriptFiles: [],
|
|
11445
|
+
});
|
|
11446
|
+
if (!r5.orphanScripts.includes('test:ghost')) bad.push('주석언급을_실행으로_오인');
|
|
11447
|
+
// 판별력 — 주석이 아닌 **실제 호출**은 여전히 '덮임' 이어야 한다(주석 필터가 과하지 않다)
|
|
11448
|
+
const r5b = PU._detectOrphanGuards({
|
|
11449
|
+
packageScripts: { 'test:ghost': 'node g.js' },
|
|
11450
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm run test:ghost' }],
|
|
11451
|
+
scriptFiles: [],
|
|
11452
|
+
});
|
|
11453
|
+
if (r5b.orphanScripts.length) bad.push('주석필터가_실제호출까지_지움');
|
|
11454
|
+
|
|
11455
|
+
// ⑤ 실제 CLI 배선 — audit 이 이 발견을 **권고로** 낸다(차단 아님)
|
|
11456
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-orph116-'));
|
|
11457
|
+
const ENV2 = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_NO_PROMPT: '1' });
|
|
11458
|
+
try {
|
|
11459
|
+
const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
|
|
11460
|
+
fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'p', version: '0.1.0', scripts: { test: 'node t.js', 'test:orphan-guard': 'node g.js' } }));
|
|
11461
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV2 });
|
|
11462
|
+
const rj = cp.spawnSync(process.execPath, [CLI, 'audit', '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV2 });
|
|
11463
|
+
let j = null; try { j = JSON.parse(String(rj.stdout || '').trim()); } catch {}
|
|
11464
|
+
if (!j) bad.push('audit_json오염');
|
|
11465
|
+
else {
|
|
11466
|
+
const f = (j.findings || []).find(x => x.kind === 'orphan_guard');
|
|
11467
|
+
if (!f) bad.push('audit에_발견안실림');
|
|
11468
|
+
else {
|
|
11469
|
+
if (f.severity !== 'warn') bad.push(`권고아님(${f.severity})`); // 차단으로 승격되면 안 된다
|
|
11470
|
+
if (!(f.orphanScripts || []).includes('test:orphan-guard')) bad.push('audit_대상누락');
|
|
11471
|
+
if (!f.reason) bad.push('audit_근거없음');
|
|
11472
|
+
}
|
|
11473
|
+
}
|
|
11474
|
+
// 대조군 — 고아가 없으면 이 발견이 나오면 안 된다(오탐 0)
|
|
11475
|
+
const d2 = path.join(sb, 'q'); fs.mkdirSync(d2, { recursive: true });
|
|
11476
|
+
fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'q', version: '0.1.0', scripts: { test: 'node t.js' } }));
|
|
11477
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes'], { cwd: d2, encoding: 'utf8', timeout: 300000, env: ENV2 });
|
|
11478
|
+
const rj2 = cp.spawnSync(process.execPath, [CLI, 'audit', '--path', d2, '--json'], { cwd: d2, encoding: 'utf8', timeout: 300000, env: ENV2 });
|
|
11479
|
+
let j2 = null; try { j2 = JSON.parse(String(rj2.stdout || '').trim()); } catch {}
|
|
11480
|
+
if (!j2) bad.push('대조_audit_json오염');
|
|
11481
|
+
else if ((j2.findings || []).some(x => x.kind === 'orphan_guard')) bad.push('대조_오탐');
|
|
11482
|
+
} finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
11483
|
+
|
|
11484
|
+
// ⑥ (검수 #5) glob 러너는 이름을 안 적는다 — `npm-run-all test:*` 를 못 읽으면 **정상 배선이 통째로 고아**가 된다.
|
|
11485
|
+
const r6 = PU._detectOrphanGuards({
|
|
11486
|
+
packageScripts: { test: 'npm-run-all test:*', 'test:unit': 'vitest', 'test:e2e': 'playwright' },
|
|
11487
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test' }], scriptFiles: [],
|
|
11488
|
+
});
|
|
11489
|
+
if (r6.orphanScripts.length) bad.push('glob러너_오탐:' + r6.orphanScripts.join(','));
|
|
11490
|
+
// 판별력 — glob 밖의 잎은 여전히 보고돼야 한다(glob 처리가 전부를 덮어 버리면 안 된다)
|
|
11491
|
+
const r6b = PU._detectOrphanGuards({
|
|
11492
|
+
packageScripts: { test: 'npm-run-all test:*', 'test:unit': 'vitest', 'check:dead': 'node d.js' },
|
|
11493
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test' }], scriptFiles: [],
|
|
11494
|
+
});
|
|
11495
|
+
if (!r6b.orphanScripts.includes('check:dead')) bad.push('glob처리가_범위밖까지_덮음');
|
|
11496
|
+
|
|
11497
|
+
// ⑦ (검수 #7) **도달성**이지 문자열 존재가 아니다 — 아무도 안 부르는 wrapper 가 잎을 덮으면 안 되고,
|
|
11498
|
+
// 고아끼리의 순환도 덮임이 되면 안 된다. 둘 다 "이름이 어디든 나오면 덮임" 구현에서 통과하던 케이스다.
|
|
11499
|
+
const r7 = PU._detectOrphanGuards({
|
|
11500
|
+
packageScripts: { test: 'node t.js', 'dead-wrapper': 'npm run check:schema', 'check:schema': 'node s.js' },
|
|
11501
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test' }], scriptFiles: [],
|
|
11502
|
+
});
|
|
11503
|
+
if (!r7.orphanScripts.includes('check:schema')) bad.push('죽은wrapper가_잎을_덮음');
|
|
11504
|
+
const r7b = PU._detectOrphanGuards({
|
|
11505
|
+
packageScripts: { test: 'node t.js', 'check:a': 'npm run check:b', 'check:b': 'npm run check:a' },
|
|
11506
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test' }], scriptFiles: [],
|
|
11507
|
+
});
|
|
11508
|
+
if (!(r7b.orphanScripts.includes('check:a') && r7b.orphanScripts.includes('check:b'))) bad.push('고아순환이_서로를_덮음');
|
|
11509
|
+
// 판별력 — 실제로 도달하는 wrapper 는 잎을 덮어야 한다(도달성이 과하게 엄격하면 안 된다)
|
|
11510
|
+
const r7c = PU._detectOrphanGuards({
|
|
11511
|
+
packageScripts: { test: 'npm run live-wrapper', 'live-wrapper': 'npm run check:schema', 'check:schema': 'node s.js' },
|
|
11512
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test' }], scriptFiles: [],
|
|
11513
|
+
});
|
|
11514
|
+
if (r7c.orphanScripts.length) bad.push('산wrapper의_잎을_오탐:' + r7c.orphanScripts.join(','));
|
|
11515
|
+
|
|
11516
|
+
// ⑧ (검수 #3) `guard:*` 는 이 기능이 잡으려는 것의 **이름 그 자체**인데 후보 어휘에 없었다 — 후보 0개면 구조적 미탐이다.
|
|
11517
|
+
const r8 = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', 'guard:parity': 'node g.js' }, runners: [], scriptFiles: [] });
|
|
11518
|
+
if (!r8.orphanScripts.includes('guard:parity')) bad.push('guard접두_후보아님');
|
|
11519
|
+
|
|
11520
|
+
// ⑨ (검수 #6) `/^test:/.test(name)` 형태의 접두 필터를 못 뽑으면 "전부 덮는다" 가 돼 같은 파일의 다른 가드가 사라진다.
|
|
11521
|
+
const r9 = PU._detectOrphanGuards({
|
|
11522
|
+
packageScripts: { test: 'node r.js', 'test:unit': 'vitest', 'check:dead': 'node d.js' },
|
|
11523
|
+
runners: [{ file: 'scripts/run-ci.mjs', text: `const pkg = require("./package.json");\nfor (const n of ${_ENUM_LIT}) { if (${_RE_LIT('test:')}) run(n); }` }],
|
|
11524
|
+
scriptFiles: [],
|
|
11525
|
+
});
|
|
11526
|
+
if (!r9.enumPrefixes.includes('test:')) bad.push('정규식리터럴_접두_미추출');
|
|
11527
|
+
if (!r9.orphanScripts.includes('check:dead')) bad.push('접두밖_가드가_사라짐');
|
|
11528
|
+
|
|
11529
|
+
// ⑩ 접두 후보가 **이 패키지 스크립트와 하나도 안 맞으면** 그건 열거자의 필터가 아니다 —
|
|
11530
|
+
// 큰 소스 파일에서 `--`·`_`·`https:` 같은 쓰레기 접두가 수십 개 나와 전부를 '덮임' 으로 만든 적이 있다(실측).
|
|
11531
|
+
const r10 = PU._detectOrphanGuards({
|
|
11532
|
+
packageScripts: { test: 'node r.js', 'check:dead': 'node d.js' },
|
|
11533
|
+
runners: [{ file: 'scripts/big.js', text: `require("./package.json");\n${_ENUM_LIT};\nx.startsWith("--");\ny.startsWith("https:");\nz.startsWith("_");` }],
|
|
11534
|
+
scriptFiles: [],
|
|
11535
|
+
});
|
|
11536
|
+
if (!r10.orphanScripts.includes('check:dead')) bad.push('무관한접두가_전부를_덮음');
|
|
11537
|
+
|
|
11538
|
+
// ⑪ turbo/nx/lerna 는 배선이 자기 설정 파일에 있어 스크립트 그래프로 안 보인다 → **보고 보류**(오탐 방지).
|
|
11539
|
+
const r11 = PU._detectOrphanGuards({
|
|
11540
|
+
packageScripts: { test: 'turbo run test', 'check:x': 'node x.js' },
|
|
11541
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test' }], scriptFiles: ['scripts/check-x.js'],
|
|
11542
|
+
});
|
|
11543
|
+
if (r11.orphanScripts.length || r11.orphanFiles.length) bad.push('오케스트레이터_보류_미작동');
|
|
11544
|
+
if (!/turbo|nx|lerna/.test(r11.reason || '')) bad.push('보류_근거_미표기');
|
|
11545
|
+
// 판별력 — 단어가 산문에 스치기만 해도 보류되면 이 경고는 아무 데서도 안 뜬다(leerness 자신에서 실제로 그랬다)
|
|
11546
|
+
const r11b = PU._detectOrphanGuards({
|
|
11547
|
+
packageScripts: { test: 'node t.js', 'check:x': 'node x.js' },
|
|
11548
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test\n# 참고: nx 로 옮길지 검토중, turbo 도 후보' }], scriptFiles: [],
|
|
11549
|
+
});
|
|
11550
|
+
if (!r11b.orphanScripts.includes('check:x')) bad.push('산문언급으로_판정보류됨');
|
|
11551
|
+
|
|
11552
|
+
// ⑫ (검수 #17) **수집기→판정기 배선**을 실제로 태운다. 이전 블록은 순수 함수만 불러서
|
|
11553
|
+
// 수집기가 러너를 하나도 안 돌려줘도 전부 통과했다 — 가드가 자기가 밟지 않는 경로를 못 지킨 자리다.
|
|
11554
|
+
const AUD = require(path.join(__dirname, '..', 'lib', 'audit.js'));
|
|
11555
|
+
if (typeof AUD._collectGuardInputs !== 'function') bad.push('수집기_미노출');
|
|
11556
|
+
else {
|
|
11557
|
+
const sb2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-wire116-'));
|
|
11558
|
+
try {
|
|
11559
|
+
const d3 = path.join(sb2, 'w'); fs.mkdirSync(path.join(d3, '.github', 'workflows'), { recursive: true });
|
|
11560
|
+
fs.mkdirSync(path.join(d3, 'scripts'), { recursive: true });
|
|
11561
|
+
fs.writeFileSync(path.join(d3, 'package.json'), JSON.stringify({
|
|
11562
|
+
name: 'w', version: '0.1.0', bin: { w: 'bin/w.js' },
|
|
11563
|
+
scripts: { test: 'node t.js', 'check:wired': 'node c.js', 'check:lonely': 'node l.js' },
|
|
11564
|
+
}));
|
|
11565
|
+
fs.writeFileSync(path.join(d3, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm run check:wired\n');
|
|
11566
|
+
fs.mkdirSync(path.join(d3, 'bin'), { recursive: true });
|
|
11567
|
+
fs.writeFileSync(path.join(d3, 'bin', 'w.js'), 'console.log("check:lonely");\n'); // 자기 진입점은 러너가 아니다
|
|
11568
|
+
// (검수 P2) `scripts/` 순회를 통째로 지워도 이전 단언은 통과했다 — 파일 고아 탐지가 사라지는 변이가 생존한다.
|
|
11569
|
+
// 수집기가 **실제로 디스크에서** 가드 파일을 찾아오는지 잰다.
|
|
11570
|
+
fs.writeFileSync(path.join(d3, 'scripts', 'verify-nothing.js'), 'process.exit(0)\n');
|
|
11571
|
+
const inp = AUD._collectGuardInputs(d3);
|
|
11572
|
+
if (!inp.scriptFiles.some(f => /verify-nothing\.js$/.test(f))) bad.push('수집기가_scripts순회_미수행');
|
|
11573
|
+
if (!PU._detectOrphanGuards(inp).orphanFiles.some(f => /verify-nothing\.js$/.test(f))) bad.push('파일고아_미보고');
|
|
11574
|
+
if (!inp.runners.some(r => /ci\.yml$/.test(r.file))) bad.push('수집기가_워크플로_미수집');
|
|
11575
|
+
if (inp.runners.some(r => /bin\/w\.js$/.test(r.file))) bad.push('자기진입점을_러너로_수집');
|
|
11576
|
+
const w1 = PU._detectOrphanGuards(inp);
|
|
11577
|
+
if (w1.orphanScripts.includes('check:wired')) bad.push('배선된_가드를_오탐');
|
|
11578
|
+
if (!w1.orphanScripts.includes('check:lonely')) bad.push('자기진입점_언급이_덮어버림');
|
|
11579
|
+
// 변이 대조 — 수집기가 러너를 안 주면 판정이 **달라져야** 한다(같으면 이 단언은 배선을 안 보는 것이다)
|
|
11580
|
+
const w2 = PU._detectOrphanGuards({ packageScripts: inp.packageScripts, runners: [], scriptFiles: inp.scriptFiles });
|
|
11581
|
+
if (w2.orphanScripts.length === w1.orphanScripts.length) bad.push('러너제거가_결과를_안바꿈');
|
|
11582
|
+
// (검수 #9) 수집이 온전하면 불완전 표시가 없어야 하고, 불완전하면 근거에 그 사실이 실려야 한다 —
|
|
11583
|
+
// '깨끗함' 과 '못 읽었음' 이 같은 출력이면 목록을 믿을 근거가 없다.
|
|
11584
|
+
if (w1.scanIncomplete) bad.push(`정상수집인데_불완전표시(${w1.scanIncomplete})`);
|
|
11585
|
+
const wInc = PU._detectOrphanGuards(Object.assign({}, inp, { readErrors: 2 }));
|
|
11586
|
+
if (!wInc.scanIncomplete) bad.push('불완전_미전파');
|
|
11587
|
+
if (!/수집 불완전/.test(wInc.reason || '')) bad.push('불완전_근거미표기');
|
|
11588
|
+
} finally { try { fs.rmSync(sb2, { recursive: true, force: true }); } catch {} }
|
|
11589
|
+
}
|
|
11590
|
+
// ⑬ (검수 #10) 대용량 파일은 통째로 읽지 않는다 — 일반 예외와 달리 OOM/장시간 정지는 바깥 try/catch 로 회복되지 않는다.
|
|
11591
|
+
{
|
|
11592
|
+
const sb3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-big116-'));
|
|
11593
|
+
try {
|
|
11594
|
+
const d6 = path.join(sb3, 'b'); fs.mkdirSync(d6, { recursive: true });
|
|
11595
|
+
fs.writeFileSync(path.join(d6, 'package.json'), JSON.stringify({ name: 'b', version: '0.1.0', scripts: { test: 'node t.js', 'check:x': 'node x.js' } }));
|
|
11596
|
+
fs.writeFileSync(path.join(d6, 'Makefile'), 'x:\n\t@echo ' + 'A'.repeat(3 * 1024 * 1024) + '\n'); // 3MB > 상한
|
|
11597
|
+
const t0 = Date.now();
|
|
11598
|
+
const inp6 = AUD._collectGuardInputs(d6);
|
|
11599
|
+
const ms = Date.now() - t0;
|
|
11600
|
+
if (ms > 5000) bad.push(`대용량수집_지연(${ms}ms)`);
|
|
11601
|
+
if (inp6.runners.some(r => /Makefile$/.test(r.file))) bad.push('상한초과파일을_읽음');
|
|
11602
|
+
if (!inp6.skippedLarge) bad.push('대용량_제외표시없음');
|
|
11603
|
+
const r13 = PU._detectOrphanGuards(inp6);
|
|
11604
|
+
if (!/수집 불완전/.test(r13.reason || '')) bad.push('대용량제외_근거미표기');
|
|
11605
|
+
} finally { try { fs.rmSync(sb3, { recursive: true, force: true }); } catch {} }
|
|
11606
|
+
}
|
|
11607
|
+
// ⑯ (검수 P2) 죽은 wrapper 가 **외부 러너 파일**을 통해 잎을 살리던 우회로. BFS 재작성이 스크립트 사이는 막았지만
|
|
11608
|
+
// 한 단계 밖(스크립트 → 파일 → 이름 언급)이 그대로 열려 있었다. 수집기가 준 `viaScript` 로 닫는다.
|
|
11609
|
+
const r16 = PU._detectOrphanGuards({
|
|
11610
|
+
packageScripts: { test: 'node t.js', 'dead-wrapper': 'node tools/run-checks.js', 'check:secret': 'node s.js' },
|
|
11611
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null },
|
|
11612
|
+
{ file: 'tools/run-checks.js', text: 'sh("npm run check:secret")', viaScript: 'dead-wrapper' }],
|
|
11613
|
+
scriptFiles: [],
|
|
11614
|
+
});
|
|
11615
|
+
if (!r16.orphanScripts.includes('check:secret')) bad.push('죽은wrapper의_러너파일이_잎을_살림');
|
|
11616
|
+
// 판별력 — 살아 있는 스크립트가 부르는 러너 파일은 여전히 잎을 덮어야 한다
|
|
11617
|
+
const r16b = PU._detectOrphanGuards({
|
|
11618
|
+
packageScripts: { test: 'node tools/run-checks.js', 'check:secret': 'node s.js' },
|
|
11619
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null },
|
|
11620
|
+
{ file: 'tools/run-checks.js', text: 'sh("npm run check:secret")', viaScript: 'test' }],
|
|
11621
|
+
scriptFiles: [],
|
|
11622
|
+
});
|
|
11623
|
+
if (r16b.orphanScripts.length) bad.push('산_러너파일의_잎을_오탐:' + r16b.orphanScripts.join(','));
|
|
11624
|
+
// ⑰ (검수 P2) 와일드카드가 있다고 실행은 아니다 — `echo "test:*"` 는 아무것도 안 돌린다.
|
|
11625
|
+
const r17 = PU._detectOrphanGuards({
|
|
11626
|
+
packageScripts: { test: 'echo "test:*"', 'test:secrets': 'node s.js' },
|
|
11627
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null }], scriptFiles: [],
|
|
11628
|
+
});
|
|
11629
|
+
if (!r17.orphanScripts.includes('test:secrets')) bad.push('echo속_glob을_실행으로_오인');
|
|
11630
|
+
// ⑱ (검수 P2) 짧은 접두(`ci`) 열거자를 버리면 그 열거자가 실제로 돌리는 것들이 전부 고아가 된다.
|
|
11631
|
+
const r18 = PU._detectOrphanGuards({
|
|
11632
|
+
packageScripts: { 'test:ci': 'node scripts/run.mjs', 'ci-check': 'node a.js', 'ci-guard': 'node b.js', 'check:dead': 'node d.js' },
|
|
11633
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm run test:ci', viaScript: null },
|
|
11634
|
+
{ file: 'scripts/run.mjs', text: `const pkg = require("./package.json");\nfor (const n of ${_ENUM_LIT}) { if (n.startsWith("ci")) run(n); }`, viaScript: 'test:ci' }],
|
|
11635
|
+
scriptFiles: [],
|
|
11636
|
+
});
|
|
11637
|
+
if (r18.orphanScripts.includes('ci-check') || r18.orphanScripts.includes('ci-guard')) bad.push('짧은접두_열거자를_버림:' + r18.orphanScripts.join(','));
|
|
11638
|
+
if (!r18.enumPrefixes.includes('ci')) bad.push('짧은접두_미추출');
|
|
11639
|
+
// ⑲ (검수 P2) 죽은 스크립트에 turbo 한 줄을 넣어 경고 전체를 끌 수 있으면 그건 우회로다.
|
|
11640
|
+
const r19 = PU._detectOrphanGuards({
|
|
11641
|
+
packageScripts: { test: 'node t.js', 'check:secrets': 'node s.js', note: 'echo turbo run test' },
|
|
11642
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null }], scriptFiles: [],
|
|
11643
|
+
});
|
|
11644
|
+
if (!r19.orphanScripts.includes('check:secrets')) bad.push('죽은스크립트의_turbo로_판정보류_우회');
|
|
11645
|
+
// 판별력 — **실제로 도는** 스크립트가 turbo 를 부르면 여전히 보류여야 한다
|
|
11646
|
+
const r19b = PU._detectOrphanGuards({
|
|
11647
|
+
packageScripts: { test: 'turbo run build', 'check:secrets': 'node s.js' },
|
|
11648
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null }], scriptFiles: [],
|
|
11649
|
+
});
|
|
11650
|
+
if (r19b.orphanScripts.length) bad.push('산_turbo에서_보류_미작동');
|
|
11651
|
+
// ⑳ (검수 P2) npm 이 스스로 돌리는 생명주기 스크립트(postinstall 등)는 루트다 — 그 잎을 고아라 하면 오탐이다.
|
|
11652
|
+
const r20 = PU._detectOrphanGuards({
|
|
11653
|
+
packageScripts: { postinstall: 'npm run check:generated', 'check:generated': 'node g.js' },
|
|
11654
|
+
runners: [], scriptFiles: [],
|
|
11655
|
+
});
|
|
11656
|
+
if (r20.orphanScripts.length) bad.push('생명주기_잎_오탐:' + r20.orphanScripts.join(','));
|
|
11657
|
+
// ⑮ 자기참조 재발 가드. 이 저장소 **자신**을 수집해 보면 `scripts/e2e.js` 가 러너로 담긴다.
|
|
11658
|
+
// 그 안에 열거자 모양의 픽스처 문자열을 그대로 쓰면 leerness 가 자기 테스트를 진짜 열거자로 읽고
|
|
11659
|
+
// 자기 자신의 고아 판정을 통째로 무력화한다(이번 라운드에 두 번 발생). 리터럴 쪼개기가 유지되는지 실측으로 잰다.
|
|
11660
|
+
{
|
|
11661
|
+
const selfRoot = path.resolve(__dirname, '..');
|
|
11662
|
+
const selfInp = AUD._collectGuardInputs(selfRoot);
|
|
11663
|
+
const selfRes = PU._detectOrphanGuards(selfInp);
|
|
11664
|
+
if (!selfInp.runners.some(r => /e2e\.js$/.test(r.file))) bad.push('자기수집:e2e.js가러너에없음'); // 셋업 유효성
|
|
11665
|
+
if (selfRes.dynamicRunners.some(f => /e2e\.js$|e2e-core\.js$/.test(f))) bad.push(`자기참조:테스트픽스처가열거자로읽힘(${selfRes.dynamicRunners.join(',')})`);
|
|
11666
|
+
if (selfInp.runners.some(r => /bin[\\/]leerness\.js$/.test(r.file))) bad.push('자기참조:자기CLI가러너로수집됨');
|
|
11667
|
+
// 기준값을 **값으로** 고정한다(검수 P2): dynamicRunners 만 보면, 비주석 코드에 `test:smoke` 를 한 번 적는
|
|
11668
|
+
// 변이로 이 저장소의 실측 기준이 조용히 사라져도 통과한다. 이 둘은 실제로 어떤 러너도 부르지 않는다 —
|
|
11669
|
+
// ci.yml 은 `test:fast` 만 돌리고, `test:core` 는 자기가 실행하는 파일에서만 언급된다.
|
|
11670
|
+
// ⚠ 나중에 이것들을 CI 에 배선하면 이 단언을 **의도적으로** 갱신하라(자동으로 넘어가면 안 된다).
|
|
11671
|
+
for (const expect of ['test:smoke', 'test:core']) {
|
|
11672
|
+
if (!selfRes.orphanScripts.includes(expect)) bad.push(`자기기준:${expect}가고아목록에없음(${selfRes.orphanScripts.join(',') || '없음'})`);
|
|
11673
|
+
}
|
|
11674
|
+
if (selfRes.orphanScripts.includes('test:fast')) bad.push('자기기준:CI가도는test:fast를오탐');
|
|
11675
|
+
// 그리고 **불변식**으로 고정한다: 이 테스트 파일 자체가 판정을 바꾸면 안 된다.
|
|
11676
|
+
// 리터럴 쪼개기로 막으면 다음에 누가 이름을 적는 순간 또 뚫린다 — 값이 아니라 성질을 단언한다.
|
|
11677
|
+
const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfInp,
|
|
11678
|
+
{ runners: selfInp.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
|
|
11679
|
+
if (JSON.stringify(selfRes.orphanScripts.slice().sort()) !== JSON.stringify(selfNoE2e.orphanScripts.slice().sort())) {
|
|
11680
|
+
bad.push(`자기참조:테스트파일이_판정을_바꿈(${selfRes.orphanScripts.join(',')} vs ${selfNoE2e.orphanScripts.join(',')})`);
|
|
11681
|
+
}
|
|
11682
|
+
}
|
|
11683
|
+
// ⑭ (검수 #14) Linux 는 XDG_CONFIG_HOME 이 있으면 그쪽이 정답이다 — ~/.config 는 기본값일 뿐이다.
|
|
11684
|
+
{
|
|
11685
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'leerness.js'), 'utf8');
|
|
11686
|
+
if (!/XDG_CONFIG_HOME/.test(src)) bad.push('XDG_미반영');
|
|
11687
|
+
}
|
|
11688
|
+
|
|
11689
|
+
dbg.bad = bad.slice(0, 10);
|
|
11690
|
+
ok = bad.length === 0;
|
|
11691
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
11692
|
+
console.log(ok ? '✓ T(1.36.116) 고아 가드 탐지: 러너루트 도달성(죽은 wrapper·순환 잡음) · glob 러너 오탐 0 · guard:* 후보 · 정규식 접두 · 무관접두 무시 · turbo 보류 · **수집기→판정기 배선** 변이대조 · audit 권고 · 고아 없으면 침묵'
|
|
11693
|
+
: '✗ 1.36.116 고아 가드 탐지 실패 ' + JSON.stringify(dbg));
|
|
11694
|
+
if (!ok) failed++;
|
|
11695
|
+
}
|
|
11696
|
+
|
|
10969
11697
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
10970
11698
|
if (failed > 0) process.exit(1);
|
|
10971
11699
|
|