leerness 1.36.117 → 1.36.119
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 +32 -8
- package/lib/audit.js +35 -7
- package/lib/pure-utils.js +39 -3
- package/package.json +1 -1
- package/scripts/e2e.js +120 -4
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.119 하네스를 사용합니다. 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.119는 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.117는 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.119 릴리스 태그 이력** (수백 라운드) · _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.119: 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.119';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -4781,7 +4781,9 @@ function _selfTestCases() {
|
|
|
4781
4781
|
const wired = src.includes("if (cmd === 'export' || cmd === 'prompt') {")
|
|
4782
4782
|
&& src.includes("arg('--target', null)")
|
|
4783
4783
|
&& src.includes("'agents-md': 'codex'")
|
|
4784
|
-
|
|
4784
|
+
// 1.36.119: 별칭도 `adapter` 와 **같은 손상 가드**를 타야 한다 — 전엔 일반 error 로 떨어져
|
|
4785
|
+
// 자동화가 '손상' 과 '그 밖의 실패' 를 구분할 수 없었다(검수 P2).
|
|
4786
|
+
&& src.includes('_guardStore(has(\'--json\'), () => adapterCmd(arg(\'--path\', process.cwd()), _xTool))');
|
|
4785
4787
|
return wired;
|
|
4786
4788
|
} },
|
|
4787
4789
|
{ name: '12th 외부평가 Sonnet P3 (UR-0143): plan drop 은 plan.md(D-)만 기록, progress-tracker T- 행 미생성 (1.9.449)', run: () => {
|
|
@@ -23879,7 +23881,15 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
23879
23881
|
const _mf = path.join(root, '.harness', 'manifest.json');
|
|
23880
23882
|
if (exists(_mf)) {
|
|
23881
23883
|
_assertStoreParsable(_mf, '.harness/manifest.json');
|
|
23882
|
-
|
|
23884
|
+
// 1.36.118 (검수 P1): `_assertStoreParsable` 은 **문법**만 본다. `null`·`[]`·`false`·`"x"` 는 유효 JSON 이라
|
|
23885
|
+
// 통과한 뒤 `.mode` 가 없어 조용히 standard 로 떨어졌다 — minimal 설치가 다시 뒤집힌다.
|
|
23886
|
+
// "손상이면 중단" 계약은 형상까지 봐야 지켜진다.
|
|
23887
|
+
let _mo = null;
|
|
23888
|
+
try { _mo = JSON.parse(read(_mf)); } catch { _mo = undefined; }
|
|
23889
|
+
if (!_mo || typeof _mo !== 'object' || Array.isArray(_mo)) {
|
|
23890
|
+
throw Object.assign(new Error(`.harness/manifest.json 이 객체가 아님(${Array.isArray(_mo) ? 'array' : typeof _mo}) — 설치 등급을 읽을 수 없어 중단합니다: ${_mf}`), { code: 'E_STORE_CORRUPT', file: _mf });
|
|
23891
|
+
}
|
|
23892
|
+
_instMode = _mo.mode || null;
|
|
23883
23893
|
}
|
|
23884
23894
|
}
|
|
23885
23895
|
// ⚠ 옵션이 둘로 갈린다: `minimal:true` 는 **어떤 키를 만들지**, `mode:'minimal'` 은 **템플릿을 줄일지** 다.
|
|
@@ -23905,18 +23915,30 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
23905
23915
|
if (files[k] == null) continue;
|
|
23906
23916
|
results.push(writeIfSafe(root, k, files[k], { mergeManaged: managedOverwrite.has(k), lang: lang === 'en' ? 'en' : 'ko' })); // 1.36.60 (검수 2차 #2): 래퍼 언어 전달 (altTemplate 은 전환-감지 없는 이 경로에선 미적용 — High 회귀 방지)
|
|
23907
23917
|
}
|
|
23908
|
-
|
|
23918
|
+
// 1.36.119 (검수): 아래 안내 문구의 판정 대상을 **MCP 병합 결과로 한정**한다. 전에는 `results` 전체를 훑었고,
|
|
23919
|
+
// 지금 맞는 이유는 `writeIfSafe` 가 'merged'/'preserved' 만 돌려주기 때문이다 — 그 이름이 바뀌면
|
|
23920
|
+
// 지침 파일을 썼다는 이유로 "MCP 호출 가능" 이라고 말하게 된다(우연한 정합은 계약이 아니다).
|
|
23921
|
+
const mcpResults = [];
|
|
23922
|
+
if (a.mcp) { const _m = _mergeMcpJson(root); results.push(_m); mcpResults.push(_m); }
|
|
23909
23923
|
// 1.36.116 (검수 P1-D): Cursor 의 프로젝트별 MCP 설정은 `.cursor/mcp.json` 이다. 루트 `.mcp.json` 만 만들어 놓고
|
|
23910
23924
|
// "Cursor 도 인식한다" 고 말하던 것은 거짓 안내였다 — 이 어댑터의 목적 자체가 그 도구를 배선하는 것이다.
|
|
23911
23925
|
// 루트 파일도 그대로 둔다(다른 도구가 쓴다). 같은 병합기라 손상 파일은 여전히 보존/중단된다.
|
|
23912
|
-
if (tool === 'cursor') { _assertStoreParsable(path.join(root, '.cursor', 'mcp.json'), '.cursor/mcp.json');
|
|
23926
|
+
if (tool === 'cursor') { _assertStoreParsable(path.join(root, '.cursor', 'mcp.json'), '.cursor/mcp.json'); const _m2 = _mergeMcpJson(root, '.cursor/mcp.json'); results.push(_m2); mcpResults.push(_m2); }
|
|
23913
23927
|
if (json) { log(JSON.stringify({ adapter: tool, files: results }, null, 2)); return; }
|
|
23914
23928
|
ok(`adapter ${tool} (${a.label}) 적용 — ${results.length}개 파일`);
|
|
23915
23929
|
for (const r of results) log(` ${r.action}: ${r.file}`);
|
|
23916
23930
|
// 1.36.117 (검수 P1): 깨진 항목을 보존했을 때도 "직접 호출 가능" 이라고 말하고 있었다 — 그건 거짓 성공이다.
|
|
23917
|
-
const _brk =
|
|
23931
|
+
const _brk = mcpResults.filter(r => r.broken);
|
|
23918
23932
|
if (_brk.length) _brk.forEach(r => warn(`${r.file} 의 leerness 항목에 command 가 없어 서버가 뜨지 않습니다 — 사용자 설정이라 보존했습니다`));
|
|
23919
|
-
|
|
23933
|
+
// 1.36.118 (자체 헌트): **보존**했을 때도 "leerness MCP verb 를 직접 호출 가능" 이라고 말하고 있었다.
|
|
23934
|
+
// 보존한 항목이 leerness 를 띄우는지는 우리가 확인하지 않았다(사용자가 다른 서버를 적어 뒀을 수도 있다) —
|
|
23935
|
+
// 우리가 쓴 항목이 하나라도 있을 때만 그렇게 말한다. 그 밖에는 무엇을 보존했는지만 알린다.
|
|
23936
|
+
const _wrote = mcpResults.filter(r => r.action === 'created' || r.action === 'updated');
|
|
23937
|
+
const _kept = mcpResults.filter(r => r.preserved && !r.broken);
|
|
23938
|
+
if (a.mcp && _wrote.length) log(` ℹ MCP 등록 — 이 도구가 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`);
|
|
23939
|
+
_kept.forEach(r => log(` ℹ ${r.file} 의 기존 leerness 항목을 그대로 두었습니다 — 그 항목이 leerness MCP 를 띄우는지는 확인하지 않았습니다`));
|
|
23940
|
+
// 보존은 안전하지만 **막다른 길이면 안 된다** — 우리 것이 망가졌을 때 되돌릴 방법을 알려 준다(자체 헌트).
|
|
23941
|
+
if (_kept.length || _brk.length) log(` → 표준 설정으로 되돌리려면 그 파일의 "leerness" 항목을 지우고 이 명령을 다시 실행하세요`);
|
|
23920
23942
|
return;
|
|
23921
23943
|
}
|
|
23922
23944
|
|
|
@@ -27006,7 +27028,9 @@ async function main() {
|
|
|
27006
27028
|
const _ADAPTER_ALIAS = { 'agents-md': 'codex', 'agents': 'codex', 'agent': 'codex', 'agents.md': 'codex' };
|
|
27007
27029
|
let _xTool = arg('--target', null) || (args[1] && !args[1].startsWith('-') ? args[1] : 'list');
|
|
27008
27030
|
_xTool = _ADAPTER_ALIAS[String(_xTool).toLowerCase()] || _xTool;
|
|
27009
|
-
|
|
27031
|
+
// 1.36.119 (검수 P2): 같은 공개 명령의 **별칭**인데 `adapter` 만 손상을 구조화해 내고 여기는 일반 error 였다 —
|
|
27032
|
+
// 자동화가 '손상' 과 '그 밖의 실패' 를 구분할 수 없다. 같은 가드를 태운다.
|
|
27033
|
+
return _guardStore(has('--json'), () => adapterCmd(arg('--path', process.cwd()), _xTool));
|
|
27010
27034
|
}
|
|
27011
27035
|
// 1.9.245: API skill cache — 공식 문서·관련링크 자동 정리 (사용자 명시 UR-0015)
|
|
27012
27036
|
if (cmd === 'api-skill') return apiSkillCmd(arg('--path', process.cwd()), args[1] || 'help');
|
package/lib/audit.js
CHANGED
|
@@ -36,7 +36,10 @@ function _collectGuardInputs(root) {
|
|
|
36
36
|
let ownEntries = new Set();
|
|
37
37
|
let workspaces = false;
|
|
38
38
|
try {
|
|
39
|
-
|
|
39
|
+
// 0바이트 `package.json` 은 유효 JSON 이 아닌데 `|| '{}'` 가 그것을 '스크립트 없음' 으로 삼켰다(검수 P2).
|
|
40
|
+
const _pkgRaw = rd(path.join(root, 'package.json'));
|
|
41
|
+
if (_pkgRaw != null && !String(_pkgRaw).trim()) throw new Error('empty package.json');
|
|
42
|
+
const pkg = JSON.parse(_pkgRaw || '{}');
|
|
40
43
|
packageScripts = pkg.scripts || {};
|
|
41
44
|
workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces.length > 0 : !!(pkg.workspaces && pkg.workspaces.packages);
|
|
42
45
|
// 1.36.116 (실측): 패키지 **자신의 진입점**(bin/main)은 러너가 아니라 제품이다. leerness 에서 실제로 물렸다 —
|
|
@@ -56,11 +59,18 @@ function _collectGuardInputs(root) {
|
|
|
56
59
|
// 죽은 wrapper 가 `test` 보다 위에 있으면 둘이 같은 파일을 불러도 '죽은 참조' 로 고정돼
|
|
57
60
|
// 그 파일이 부르는 정상 가드가 고아로 나왔다(실측 재현). 참조한 스크립트를 전부 모은다.
|
|
58
61
|
const runners = [];
|
|
62
|
+
// ⚠ 출처가 **무조건**(CI 설정이 직접 부름)인 사실을 따로 기록한다. `viaScripts` 배열만 두면,
|
|
63
|
+
// CI 가 부르는 파일을 죽은 스크립트도 참조할 때 그 스크립트가 배열에 붙으면서 **무조건 출처가 사라지고**
|
|
64
|
+
// 러너 전체가 그 죽은 스크립트에 종속된다 → 정상 배선이 고아로 뒤집힌다(검수 지적, 실측 재현).
|
|
59
65
|
const push = (file, viaScript) => {
|
|
60
66
|
const found = runners.find(r => r.file === file);
|
|
61
|
-
if (found) {
|
|
67
|
+
if (found) {
|
|
68
|
+
if (!viaScript) found.unconditional = true;
|
|
69
|
+
else if (!found.viaScripts.includes(viaScript)) found.viaScripts.push(viaScript);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
62
72
|
const t = rd(path.join(root, file));
|
|
63
|
-
if (t != null) runners.push({ file, text: t, viaScript: viaScript || null, viaScripts: viaScript ? [viaScript] : [] });
|
|
73
|
+
if (t != null) runners.push({ file, text: t, viaScript: viaScript || null, viaScripts: viaScript ? [viaScript] : [], unconditional: !viaScript });
|
|
64
74
|
};
|
|
65
75
|
try { for (const f of fs.readdirSync(path.join(root, '.github', 'workflows'))) if (/\.ya?ml$/.test(f)) push(`.github/workflows/${f}`); } catch {}
|
|
66
76
|
// 1.36.116 (검수 #5): 정확히 `Makefile` 과 훅 2개만 읽었다 — `GNUmakefile`·소문자 `makefile`·`commit-msg` 를
|
|
@@ -72,15 +82,31 @@ function _collectGuardInputs(root) {
|
|
|
72
82
|
// 러너가 부르는 스크립트 파일도 러너다 — 동적 열거자가 대개 거기 있다(그걸 못 보면 오탐이 폭발한다).
|
|
73
83
|
// 1.36.116 (검수 #5): 경로에 `scripts/` 가 있어야만 수집했다 — `tools/run-ci.mjs`·`ci/run.mjs`·`bin/run-checks.js`
|
|
74
84
|
// 같은 흔한 배치를 놓치고, 그 안의 동적 열거자를 못 보면 다시 오탐이 폭발한다. 디렉터리 이름을 넓힌다.
|
|
75
|
-
|
|
85
|
+
// 경로 구분자는 `/`·`\` 둘 다 받는다 — Windows CI 는 `.\bin\cli.js` 로 적는다(검수 P2 미탐).
|
|
86
|
+
const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\][\w.-]+\.(?:m?js|cjs|ts|sh)/g;
|
|
87
|
+
const _norm = (x) => String(x).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
76
88
|
// CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
|
|
77
89
|
// ⚠ 자기 진입점 제외는 **CI 설정이 직접 부르는 경우까지** 막으면 안 된다(검수 P2) — 그 파일이 실제로
|
|
78
90
|
// 가드를 배선하고 있으면 그걸 못 봐서 정상 가드가 고아로 뒤집힌다. CI 가 부르면 그건 러너가 맞다.
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
// ⚠ 단 **주석 줄에서 수집하면 안 된다**: `# debug: node bin/cli.js` 한 줄로 제품 진입점이 러너가 되고
|
|
92
|
+
// 그 안의 문자열이 진짜 고아를 덮는다(검수 P2 — 자기참조가 주석 경로로 되살아난다).
|
|
93
|
+
// CMD 스텝의 `REM`·`::` 도 주석이다(검수 재현: `REM debug: node .\bin\phantom.js` 한 줄로 제품 진입점이 러너가 됐다).
|
|
94
|
+
const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|--|;|::|REM\s)/i.test(l)).join('\n');
|
|
95
|
+
// 설명 필드(`- name: archive index.js`)는 실행 증거가 아니다 — bare 진입점 매처에도 같은 규칙을 건다.
|
|
96
|
+
const _noDescriptive = (s) => String(s).split('\n').filter(l => !/^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:/i.test(l)).join('\n');
|
|
97
|
+
for (const r of runners.slice()) {
|
|
98
|
+
const txt = _noComment(r.text);
|
|
99
|
+
for (const m of txt.matchAll(_FILE_RE)) push(_norm(m[0]), null);
|
|
100
|
+
// 디렉터리 접두가 없는 진입점(`"main": "index.js"`)은 위 정규식이 못 잡는다 — 선언된 bin/main 만 이름으로 확인한다.
|
|
101
|
+
const bareTxt = _noDescriptive(txt);
|
|
102
|
+
for (const own of ownEntries) {
|
|
103
|
+
if (/[/\\]/.test(own)) continue;
|
|
104
|
+
const esc = own.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
105
|
+
if (new RegExp(`(^|[^\\w.\\-/\\\\])\\.?[/\\\\]?${esc}([^\\w.-]|$)`, 'm').test(bareTxt)) push(own, null);
|
|
106
|
+
}
|
|
81
107
|
}
|
|
82
108
|
for (const [name, body] of Object.entries(packageScripts)) for (const m of String(body || '').matchAll(_FILE_RE)) {
|
|
83
|
-
const rel = m[0]
|
|
109
|
+
const rel = _norm(m[0]);
|
|
84
110
|
if (!ownEntries.has(rel)) push(rel, name); // 자기 진입점은 러너가 아니다 (위 주석)
|
|
85
111
|
}
|
|
86
112
|
// scripts/ 아래 가드처럼 보이는 파일(2단계까지)
|
|
@@ -203,6 +229,8 @@ function audit(root, opts = {}, deps = {}) {
|
|
|
203
229
|
log(` → CI 워크플로나 집계 스크립트(예: test:ci)에 배선하거나, 더 이상 안 쓰면 지우세요`);
|
|
204
230
|
_finding('orphan_guard', 'warn', 'checks exist but no runner invokes them', {
|
|
205
231
|
orphanScripts: g.orphanScripts, orphanFiles: g.orphanFiles, scanIncomplete: g.scanIncomplete,
|
|
232
|
+
// 1.36.118 (검수 P3): 고아가 **있을 때**도 범위 정보가 필요하다 — 0건 분기에만 넣어 계약이 갈렸다.
|
|
233
|
+
monorepoOutOfScope: g.monorepoOutOfScope, deferred: g.deferred,
|
|
206
234
|
guardCount: g.guardCount, dynamicRunners: g.dynamicRunners, enumPrefixes: g.enumPrefixes, reason: g.reason,
|
|
207
235
|
});
|
|
208
236
|
} else if (g.scanIncomplete || g.deferred || g.monorepoOutOfScope) {
|
package/lib/pure-utils.js
CHANGED
|
@@ -1187,6 +1187,7 @@ function _detectOrphanGuards(input) {
|
|
|
1187
1187
|
};
|
|
1188
1188
|
// 출처가 여럿이면 **하나라도 살아 있으면** 그 러너는 실제로 돈다. 하나만 보면 선언 순서가 판정을 바꾼다(실측).
|
|
1189
1189
|
const _liveRunner = (r) => {
|
|
1190
|
+
if (r.unconditional) return true; // CI 설정이 직접 부르는 파일 — 어떤 스크립트에도 종속되지 않는다
|
|
1190
1191
|
const via = Array.isArray(r.viaScripts) && r.viaScripts.length ? r.viaScripts : (r.viaScript ? [r.viaScript] : []);
|
|
1191
1192
|
return via.length === 0 || via.some(n => reachable.has(n) || coveredByEnum(n));
|
|
1192
1193
|
};
|
|
@@ -1206,8 +1207,20 @@ function _detectOrphanGuards(input) {
|
|
|
1206
1207
|
// ⚠ 줄 필터를 **CI 설정 파일에까지** 걸면 matrix/여러 줄 배선을 놓친다(검수 P2):
|
|
1207
1208
|
// `script: [check:a, check:b]` 은 `npm` 이 다른 줄에 있다. CI 설정은 그 자체가 배선이므로 전문을 본다.
|
|
1208
1209
|
// 줄 필터는 **코드 파일**에만 건다 — 거기서만 이름이 데이터로 등장한다(우리 e2e.js 가 그랬다).
|
|
1210
|
+
// ⚠ CI 설정을 전문으로 보되 **설명용 필드는 뺀다**: `- name: TODO check:leaf 를 붙이자`,
|
|
1211
|
+
// `env: { FOO: check:x }`, artifact 이름 같은 것이 그것만으로 '호출됨' 이 됐다(자체 헌트 + 검수 재현).
|
|
1212
|
+
// ⚠ 한때 **허용 목록**(실행 키만 본다)으로 뒤집었다가 되돌렸다. 그건 정상 배선을 버렸다(검수 재현):
|
|
1213
|
+
// `matrix: { target: [check:leaf] }` + `run: npm run ${{ matrix.target }}` 에서 `target` 은 실행 키가
|
|
1214
|
+
// 아니므로 잘려 나가고, 실행 줄에는 이름이 없어 **정상 가드가 고아로 보고**된다. Azure 의 `customCommand:` 도 같다.
|
|
1215
|
+
// 이 저장소의 편향은 명시적이다 — **오탐이 이 경고를 죽인다**. 그래서 방향은 과소 보고다:
|
|
1216
|
+
// 사람이 읽는 설명 필드만 빼고 나머지는 그대로 본다.
|
|
1217
|
+
// 그 대가(측정됨): `env: { TARGET: check:x }`·`key: check:x-hash` 처럼 **데이터 값**에 이름이 있으면
|
|
1218
|
+
// 그것을 호출로 보고 진짜 고아를 가린다. 그건 미탐이라 감수한다.
|
|
1219
|
+
const _DESCRIPTIVE = /^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:/i;
|
|
1220
|
+
const _configLineOk = (l) => !_DESCRIPTIVE.test(l);
|
|
1209
1221
|
const _raw = _dropCommentLines(String(r.text || ''));
|
|
1210
|
-
const t = _isConfigRunner(r.file)
|
|
1222
|
+
const t = _isConfigRunner(r.file)
|
|
1223
|
+
? _raw.split('\n').filter(_configLineOk).join('\n')
|
|
1211
1224
|
: _raw.split('\n').filter(l => /\b(?:npm|npx|yarn|pnpm|make|run|exec)\b/.test(l)).join('\n');
|
|
1212
1225
|
for (const n of names) if (!roots.has(n) && _has(t, n)) { roots.add(n); grew = true; }
|
|
1213
1226
|
grew = true;
|
|
@@ -1235,11 +1248,34 @@ function _detectOrphanGuards(input) {
|
|
|
1235
1248
|
// `b/check.js` 까지 덮어 버린다. basename 은 그 이름이 유일할 때만 근거로 쓴다.
|
|
1236
1249
|
const _baseCount = new Map();
|
|
1237
1250
|
for (const f of scriptFiles) { const b = String(f).split(/[\\/]/).pop(); _baseCount.set(b, (_baseCount.get(b) || 0) + 1); }
|
|
1251
|
+
// ⚠ 그런데 유일성만으로 끄면 **해석 가능한 상대 호출**까지 잃는다(검수 P2): `scripts/a/run.js` 안의
|
|
1252
|
+
// `require('./check.js')` 는 `scripts/a/check.js` 를 가리키는데, 동명 파일이 있다는 이유로 그것까지 고아가 됐다.
|
|
1253
|
+
// 참조한 러너의 디렉터리를 기준으로 상대 경로를 **해석해서** 덮인 집합에 넣는다.
|
|
1254
|
+
// ⚠ 원문을 그대로 훑으면 **주석 속 참조**도 실행으로 계상되고(검수 재현: `// require("./a/check.js")`),
|
|
1255
|
+
// `..` 가 루트 밖으로 나가면 남은 조각이 엉뚱한 루트 내부 파일로 해석된다. 둘 다 막는다.
|
|
1256
|
+
// 구분자는 `\` 도 받는다(`require(".\\a\\check.js")`).
|
|
1257
|
+
const _resolved = new Set();
|
|
1258
|
+
for (const r of runners.filter(_liveRunner)) {
|
|
1259
|
+
const dir = String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
|
|
1260
|
+
const body = _dropCommentLines(String(r.text || ''));
|
|
1261
|
+
for (const m of body.matchAll(/(\.{1,2}(?:[/\\][\w.-]+)+\.(?:m?js|cjs|ts|sh))/g)) {
|
|
1262
|
+
const segs = dir.slice();
|
|
1263
|
+
let escaped = false;
|
|
1264
|
+
for (const s of m[1].replace(/\\/g, '/').split('/')) {
|
|
1265
|
+
if (s === '.' || s === '') continue;
|
|
1266
|
+
if (s === '..') { if (!segs.length) { escaped = true; break; } segs.pop(); } else segs.push(s);
|
|
1267
|
+
}
|
|
1268
|
+
if (!escaped) _resolved.add(segs.join('/'));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1238
1271
|
const orphanFiles = unknownOrchestrator ? [] : scriptFiles.filter(f => {
|
|
1239
1272
|
const full = String(f).replace(/\\/g, '/');
|
|
1240
|
-
if (allText.includes(full)) return false;
|
|
1273
|
+
if (allText.includes(full) || _resolved.has(full)) return false;
|
|
1241
1274
|
const base = full.split('/').pop();
|
|
1242
|
-
|
|
1275
|
+
// basename 폴백은 **경로의 일부**를 잡으면 안 된다: `../../outside/check.js` 가 우리 `scripts/check.js` 를
|
|
1276
|
+
// 덮어 버렸다(자체 검증에서 실측). 앞에 경로 구분자가 없는 **단독 언급**일 때만 근거로 쓴다.
|
|
1277
|
+
const _bnRe = new RegExp(`(^|[^\\w./\\\\-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w.-]|$)`);
|
|
1278
|
+
if (_baseCount.get(base) === 1 && _bnRe.test(allText)) return false;
|
|
1243
1279
|
return true;
|
|
1244
1280
|
});
|
|
1245
1281
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.119",
|
|
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
|
@@ -11222,6 +11222,26 @@ total++;
|
|
|
11222
11222
|
cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dm2], { cwd: dm2, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11223
11223
|
const afterLen = fs.readFileSync(path.join(dm2, 'CLAUDE.md'), 'utf8').length;
|
|
11224
11224
|
if (afterLen > beforeLen * 1.5) bad.push(`adapter:등급무시(${beforeLen}→${afterLen})`);
|
|
11225
|
+
// (검수 P3) manifest **형상** 5종을 고정한다 — `_assertStoreParsable` 은 문법만 보므로
|
|
11226
|
+
// `null`·`[]`·`false`·`0`·문자열이 통과한 뒤 조용히 standard 로 되돌아갔다.
|
|
11227
|
+
for (const shape of ['null', '[]', 'false', '0', '"minimal"']) {
|
|
11228
|
+
const dsh = mk(['--mode', 'minimal']);
|
|
11229
|
+
const shLen = fs.readFileSync(path.join(dsh, 'CLAUDE.md'), 'utf8').length;
|
|
11230
|
+
fs.writeFileSync(path.join(dsh, '.harness', 'manifest.json'), shape);
|
|
11231
|
+
const rsh = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dsh], { cwd: dsh, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11232
|
+
if (rsh.status === 0) bad.push(`manifest형상:${shape}에서_중단안함`);
|
|
11233
|
+
if (fs.readFileSync(path.join(dsh, 'CLAUDE.md'), 'utf8').length !== shLen) bad.push(`manifest형상:${shape}에서_등급뒤집힘`);
|
|
11234
|
+
}
|
|
11235
|
+
// 판별력 — 정상 객체(구형: mode 없음)는 통과해야 한다(형상 검사가 과하면 기존 설치가 깨진다)
|
|
11236
|
+
const dok = mk(['--mode', 'minimal']);
|
|
11237
|
+
fs.writeFileSync(path.join(dok, '.harness', 'manifest.json'), JSON.stringify({ installedAt: '2026-01-01' }, null, 2));
|
|
11238
|
+
if (cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dok], { cwd: dok, encoding: 'utf8', timeout: 300000, env: ENV }).status !== 0) bad.push('manifest형상:정상객체를_거부');
|
|
11239
|
+
// (검수 P2) `export`/`prompt` 는 같은 명령의 별칭이다 — 손상을 구조화해 내야 자동화가 구분한다
|
|
11240
|
+
const dex = mk(['--mode', 'minimal']);
|
|
11241
|
+
fs.writeFileSync(path.join(dex, '.harness', 'manifest.json'), 'false');
|
|
11242
|
+
const rex = cp.spawnSync(process.execPath, [CLI, 'export', 'claude', '--path', dex, '--json'], { cwd: dex, encoding: 'utf8', timeout: 300000, env: ENV });
|
|
11243
|
+
let jex = null; try { jex = JSON.parse(String(rex.stdout || '').trim()); } catch {}
|
|
11244
|
+
if (!jex || jex.code !== 'store_corrupt' || rex.status === 0) bad.push(`export별칭:손상코드아님(${jex && jex.code}/${rex.status})`);
|
|
11225
11245
|
// 판별력 — standard 프로젝트에서는 adapter 가 여전히 standard 를 유지해야 한다(등급 반영이 전부를 줄이면 안 된다)
|
|
11226
11246
|
const ds2 = mk();
|
|
11227
11247
|
const sBefore = fs.readFileSync(path.join(ds2, 'CLAUDE.md'), 'utf8').length;
|
|
@@ -11337,6 +11357,11 @@ total++;
|
|
|
11337
11357
|
const runnable = !!(val && typeof val === 'object' && val.command);
|
|
11338
11358
|
if (!runnable && expect === 'preserve' && !/command 가 없어/.test(out)) bad.push(`_ours:${label}에_경고없음`);
|
|
11339
11359
|
if (!runnable && expect === 'preserve' && /직접 호출 가능/.test(out)) bad.push(`_ours:${label}에_거짓성공`);
|
|
11360
|
+
// (자체 헌트) **보존**했을 때도 "leerness MCP verb 를 직접 호출 가능" 이라고 말하고 있었다 —
|
|
11361
|
+
// 보존한 항목이 leerness 를 띄우는지 우리는 확인하지 않았다. 우리가 쓴 항목이 있을 때만 그렇게 말한다.
|
|
11362
|
+
if (expect === 'preserve' && /직접 호출 가능/.test(out)) bad.push(`_ours:${label}_보존인데_호출가능주장`);
|
|
11363
|
+
if (expect === 'preserve' && !/되돌리려면/.test(out)) bad.push(`_ours:${label}_복구경로_안내없음`);
|
|
11364
|
+
if (expect === 'update' && !/직접 호출 가능/.test(out)) bad.push(`_ours:${label}_갱신인데_안내없음`);
|
|
11340
11365
|
}
|
|
11341
11366
|
}
|
|
11342
11367
|
// ⑧ (검수 P3) Desktop 상태·영문 표면을 **실제로** 검증한다 — 지금까지는 필드 존재만 봤다.
|
|
@@ -11347,19 +11372,31 @@ total++;
|
|
|
11347
11372
|
const hKo = String(cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko], { cwd: ko, encoding: 'utf8', timeout: 120000, env: ENV }).stdout || '');
|
|
11348
11373
|
if (/[가-힣]/.test(hEn)) bad.push(`desktop:영문표면에한국어(${(hEn.match(/[가-힣]+/g) || [])[0]})`);
|
|
11349
11374
|
if (!/[가-힣]/.test(hKo)) bad.push('desktop:한국어표면이비어있음');
|
|
11375
|
+
// 한국어 없음만 보면 **영문 안내를 비워도** 통과한다(검수 P3) — 영문 문구가 실제로 있는지 본다.
|
|
11376
|
+
if (!/Config file/.test(hEn) || !/mcpServers/.test(hEn) || !/save the whole file|add just this one entry/i.test(hEn)) bad.push('desktop:영문안내_내용없음');
|
|
11350
11377
|
// `mcpServers` 키가 없는 파일을 위한 안내가 있어야 한다(ⓐ 를 따르면 기존 설정이 날아간다)
|
|
11351
11378
|
if (!/mcpServers[^\n]*(키가 없|no .*key)/i.test(hKo)) bad.push('desktop:mcpServers키없는경우_안내없음');
|
|
11352
11379
|
// `registeredState` **네 상태**를 격리해서 실제로 잰다(검수 P3: 지금까지는 필드 존재만 봤다 —
|
|
11353
11380
|
// `_regState` 를 상수 'none' 으로 바꾸는 변이가 생존했다). 사용자의 진짜 설정은 건드리지 않는다:
|
|
11354
11381
|
// Windows 는 APPDATA, Linux 는 XDG_CONFIG_HOME 으로 앱 설정 경로를 임시 디렉터리에 묶는다.
|
|
11355
11382
|
const _cfgVar = process.platform === 'win32' ? 'APPDATA' : (process.platform === 'linux' ? 'XDG_CONFIG_HOME' : null);
|
|
11383
|
+
// macOS 는 경로가 homedir 고정이라 격리할 수 없다 → 그 플랫폼에선 이 깊은 검사가 통째로 건너뛰어진다(검수 P3).
|
|
11384
|
+
// 건너뛸 때도 **공허하게 통과하지는 않도록** 최소 계약(4상태 중 하나)은 잰다.
|
|
11385
|
+
if (!_cfgVar) {
|
|
11386
|
+
const rr = cp.spawnSync(process.execPath, [CLI, 'mcp', 'install', '--path', ko, '--json'], { cwd: ko, encoding: 'utf8', timeout: 120000, env: ENV });
|
|
11387
|
+
let jj = null; try { jj = JSON.parse(String(rr.stdout || '').trim()); } catch {}
|
|
11388
|
+
if (!jj || !['ok', 'broken', 'none', 'unreadable'].includes(jj.registeredState)) bad.push('desktop:상태값이_계약밖');
|
|
11389
|
+
}
|
|
11356
11390
|
if (_cfgVar) {
|
|
11357
11391
|
const states = [
|
|
11358
11392
|
[{ mcpServers: { leerness: { command: 'npx', args: ['-y', 'leerness', 'mcp', 'serve'] } } }, 'ok'],
|
|
11359
11393
|
[{ mcpServers: { leerness: {} } }, 'broken'],
|
|
11360
11394
|
[{ mcpServers: { leerness: [] } }, 'broken'],
|
|
11361
11395
|
[{ mcpServers: { leerness: { command: ' ' } } }, 'broken'],
|
|
11396
|
+
[{ mcpServers: [] }, 'broken'], // 컨테이너가 배열 — 'none' 으로 새면 안 된다(검수 P3)
|
|
11397
|
+
[{ mcpServers: false }, 'broken'],
|
|
11362
11398
|
[{ mcpServers: { other: { command: 'x' } } }, 'none'],
|
|
11399
|
+
[{}, 'none'],
|
|
11363
11400
|
['{ broken', 'unreadable'],
|
|
11364
11401
|
];
|
|
11365
11402
|
for (const [content, expect] of states) {
|
|
@@ -11539,6 +11576,8 @@ total++;
|
|
|
11539
11576
|
if (f.severity !== 'warn') bad.push(`권고아님(${f.severity})`); // 차단으로 승격되면 안 된다
|
|
11540
11577
|
if (!(f.orphanScripts || []).includes('test:orphan-guard')) bad.push('audit_대상누락');
|
|
11541
11578
|
if (!f.reason) bad.push('audit_근거없음');
|
|
11579
|
+
// (검수 P3) 범위 메타데이터는 고아가 **있을 때도** 실려야 한다 — 0건 분기에만 넣으면 계약이 갈린다.
|
|
11580
|
+
if (!('monorepoOutOfScope' in f) || !('deferred' in f)) bad.push('audit_범위메타누락');
|
|
11542
11581
|
}
|
|
11543
11582
|
}
|
|
11544
11583
|
// 대조군 — 고아가 없으면 이 발견이 나오면 안 된다(오탐 0)
|
|
@@ -11734,6 +11773,49 @@ total++;
|
|
|
11734
11773
|
const rr = (vi.runners.find(r => /tools[\\/]x\.js$/.test(r.file)) || {});
|
|
11735
11774
|
if (!(rr.viaScripts || []).includes('test')) bad.push('수집기가_출처를_하나만_담음');
|
|
11736
11775
|
if (PU._detectOrphanGuards(vi).orphanScripts.length) bad.push('실측_선언순서_오탐');
|
|
11776
|
+
// (검수) CI 설정이 **직접 부르는** 파일은 어떤 스크립트에도 종속되지 않아야 한다.
|
|
11777
|
+
// `viaScripts` 배열만 두면 죽은 스크립트가 그 배열에 붙으면서 무조건 출처가 사라져
|
|
11778
|
+
// 정상 배선이 고아로 뒤집혔다(오탐).
|
|
11779
|
+
const d5 = path.join(sb4, 'ci');
|
|
11780
|
+
fs.mkdirSync(path.join(d5, 'tools'), { recursive: true });
|
|
11781
|
+
fs.mkdirSync(path.join(d5, '.github', 'workflows'), { recursive: true });
|
|
11782
|
+
fs.writeFileSync(path.join(d5, 'package.json'), JSON.stringify({ name: 'c', version: '0.1.0',
|
|
11783
|
+
scripts: { 'dead-wrapper': 'node tools/ci.js', test: 'node t.js', 'check:leaf': 'node l.js' } }));
|
|
11784
|
+
fs.writeFileSync(path.join(d5, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: node tools/ci.js\n');
|
|
11785
|
+
fs.writeFileSync(path.join(d5, 'tools', 'ci.js'), 'sh("npm run check:leaf")\n');
|
|
11786
|
+
const ci = AUD._collectGuardInputs(d5);
|
|
11787
|
+
const cr = ci.runners.find(r => /tools[\\/]ci\.js$/.test(r.file)) || {};
|
|
11788
|
+
if (!cr.unconditional) bad.push('CI직접호출_출처_유실');
|
|
11789
|
+
if (PU._detectOrphanGuards(ci).orphanScripts.length) bad.push('CI직접호출인데_고아로_오탐');
|
|
11790
|
+
// (검수 P2) CMD 스텝의 `REM` 주석에서 제품 진입점을 러너로 승격시키면 자기참조가 되살아난다
|
|
11791
|
+
const d6c = path.join(sb4, 'rem');
|
|
11792
|
+
fs.mkdirSync(path.join(d6c, 'bin'), { recursive: true });
|
|
11793
|
+
fs.mkdirSync(path.join(d6c, '.github', 'workflows'), { recursive: true });
|
|
11794
|
+
fs.writeFileSync(path.join(d6c, 'package.json'), JSON.stringify({ name: 'r', version: '0.1.0', bin: { r: 'bin/phantom.js' },
|
|
11795
|
+
scripts: { test: 'node t.js', 'check:leaf': 'node l.js' } }));
|
|
11796
|
+
fs.writeFileSync(path.join(d6c, '.github', 'workflows', 'ci.yml'),
|
|
11797
|
+
'jobs:\n a:\n steps:\n - shell: cmd\n run: |\n REM debug only: node .\\bin\\phantom.js\n echo harmless\n - run: npm test\n');
|
|
11798
|
+
fs.writeFileSync(path.join(d6c, 'bin', 'phantom.js'), 'const x = "npm run check:leaf";\n');
|
|
11799
|
+
const remInp = AUD._collectGuardInputs(d6c);
|
|
11800
|
+
if (remInp.runners.some(r => /phantom\.js$/.test(r.file))) bad.push('CMD주석에서_진입점_수집');
|
|
11801
|
+
if (!PU._detectOrphanGuards(remInp).orphanScripts.includes('check:leaf')) bad.push('CMD주석이_고아를_덮음');
|
|
11802
|
+
// (검수 P2) 상대 경로 해석: 주석 속 참조는 무시, 루트 밖은 내부 파일로 오인 금지, 역슬래시도 해석
|
|
11803
|
+
const rel1 = PU._detectOrphanGuards({
|
|
11804
|
+
packageScripts: { test: 'node scripts/run-a.js && node scripts/run-b.js' },
|
|
11805
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
|
|
11806
|
+
{ file: 'scripts/run-a.js', text: '// require("./a/check.js")\n', viaScript: 'test', viaScripts: ['test'] },
|
|
11807
|
+
{ file: 'scripts/run-b.js', text: 'require("./b/check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
|
|
11808
|
+
scriptFiles: ['scripts/a/check.js', 'scripts/b/check.js'],
|
|
11809
|
+
});
|
|
11810
|
+
if (!rel1.orphanFiles.includes('scripts/a/check.js')) bad.push('주석속_상대참조를_실행으로');
|
|
11811
|
+
if (rel1.orphanFiles.includes('scripts/b/check.js')) bad.push('실제_상대참조를_고아로');
|
|
11812
|
+
const rel2 = PU._detectOrphanGuards({
|
|
11813
|
+
packageScripts: { test: 'node scripts/run.js' },
|
|
11814
|
+
runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
|
|
11815
|
+
{ file: 'scripts/run.js', text: 'require("../../outside/check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
|
|
11816
|
+
scriptFiles: ['scripts/check.js'],
|
|
11817
|
+
});
|
|
11818
|
+
if (!rel2.orphanFiles.includes('scripts/check.js')) bad.push('루트밖_참조가_내부파일을_덮음');
|
|
11737
11819
|
} finally { try { fs.rmSync(sb4, { recursive: true, force: true }); } catch {} }
|
|
11738
11820
|
}
|
|
11739
11821
|
// ⑰ (검수 P2) 와일드카드가 있다고 실행은 아니다 — `echo "test:*"` 는 아무것도 안 돌린다.
|
|
@@ -11780,12 +11862,42 @@ total++;
|
|
|
11780
11862
|
scriptFiles: [],
|
|
11781
11863
|
});
|
|
11782
11864
|
if (r19d.orphanScripts.length) bad.push('산_열거자를_무시:' + r19d.orphanScripts.join(','));
|
|
11865
|
+
// 고정점의 **다단 전파**: 열거자 A 가 붙여 준 접두 덕에 열거자 B 가 나중에 live 가 되는 순서.
|
|
11866
|
+
// 한 번만 훑는 구현으로 바꾸는 변이가 여기서 죽는다(검수 P3: 지금까지는 1단계만 봤다).
|
|
11867
|
+
const r19e = PU._detectOrphanGuards({
|
|
11868
|
+
packageScripts: { test: 'node scripts/enum-a.mjs', 'check:enum-b': 'node scripts/enum-b.mjs', 'verify:leaf': 'node v.js', 'scan:dead': 'node d.js' },
|
|
11869
|
+
runners: [
|
|
11870
|
+
{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
|
|
11871
|
+
{ file: 'scripts/enum-a.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) { if (n.startsWith("check:")) run(n); }`, viaScript: 'test', viaScripts: ['test'] },
|
|
11872
|
+
{ file: 'scripts/enum-b.mjs', text: `const pkg=require("./package.json");\nfor (const n of ${_ENUM_LIT}) { if (n.startsWith("verify:")) run(n); }`, viaScript: 'check:enum-b', viaScripts: ['check:enum-b'] },
|
|
11873
|
+
],
|
|
11874
|
+
scriptFiles: [],
|
|
11875
|
+
});
|
|
11876
|
+
if (r19e.orphanScripts.includes('verify:leaf')) bad.push('다단_열거자_전파_안됨');
|
|
11877
|
+
if (!r19e.enumPrefixes.includes('verify:')) bad.push('2단계_열거자_접두_미수집');
|
|
11878
|
+
// 판별력 — 어느 열거자도 안 태우는 것은 여전히 고아여야 한다(전파가 전부를 덮으면 안 된다)
|
|
11879
|
+
if (!r19e.orphanScripts.includes('scan:dead')) bad.push('다단전파가_범위밖까지_덮음');
|
|
11783
11880
|
// ⑳ (검수 P2) npm 이 스스로 돌리는 생명주기 스크립트(postinstall 등)는 루트다 — 그 잎을 고아라 하면 오탐이다.
|
|
11784
11881
|
const r20 = PU._detectOrphanGuards({
|
|
11785
11882
|
packageScripts: { postinstall: 'npm run check:generated', 'check:generated': 'node g.js' },
|
|
11786
11883
|
runners: [], scriptFiles: [],
|
|
11787
11884
|
});
|
|
11788
11885
|
if (r20.orphanScripts.length) bad.push('생명주기_잎_오탐:' + r20.orphanScripts.join(','));
|
|
11886
|
+
// ㉑ (자체 헌트) CI 설정을 **전문**으로 보게 바꾼 대가 — 사람이 읽는 필드(`name:`)에 이름이 있으면
|
|
11887
|
+
// 그것만으로 '호출됨' 이 됐다. 설명용 키는 빼되, 진짜 배선(`run:`·matrix)은 여전히 잡혀야 한다.
|
|
11888
|
+
for (const [label, text, expectOrphan] of [
|
|
11889
|
+
['산문만', 'jobs:\n a:\n steps:\n - name: TODO check:leaf 붙이기\n run: echo skip\n', true],
|
|
11890
|
+
['name 뒤 실제 run', 'jobs:\n a:\n steps:\n - name: Run check:leaf\n run: npm run check:leaf\n', false],
|
|
11891
|
+
['matrix', 'jobs:\n a:\n strategy:\n matrix:\n script: [check:leaf]\n steps:\n - run: npm run ${{ matrix.script }}\n', false],
|
|
11892
|
+
// ⚠ 한때 **허용 목록**(실행 키만 본다)으로 뒤집었다가 되돌렸다 — 아래 두 케이스가 그때 고아로 뒤집혔다(오탐).
|
|
11893
|
+
// 이 저장소의 편향은 "오탐이 경고를 죽인다" 이므로 방향은 과소 보고다. 이 두 줄이 그 결정을 고정한다.
|
|
11894
|
+
['matrix 변수명(실행키 아님)', 'jobs:\n a:\n strategy:\n matrix:\n target: [check:leaf]\n steps:\n - run: npm run ${{ matrix.target }}\n', false],
|
|
11895
|
+
['Azure customCommand', 'steps:\n- task: Npm@1\n inputs:\n command: custom\n customCommand: run check:leaf\n', false],
|
|
11896
|
+
]) {
|
|
11897
|
+
const rr = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
|
|
11898
|
+
runners: [{ file: '.github/workflows/ci.yml', text, viaScript: null, viaScripts: [] }], scriptFiles: [] });
|
|
11899
|
+
if (rr.orphanScripts.includes('check:leaf') !== expectOrphan) bad.push(`CI설명키:${label}(고아=${!expectOrphan})`);
|
|
11900
|
+
}
|
|
11789
11901
|
// ⑮ 자기참조 재발 가드. 이 저장소 **자신**을 수집해 보면 `scripts/e2e.js` 가 러너로 담긴다.
|
|
11790
11902
|
// 그 안에 열거자 모양의 픽스처 문자열을 그대로 쓰면 leerness 가 자기 테스트를 진짜 열거자로 읽고
|
|
11791
11903
|
// 자기 자신의 고아 판정을 통째로 무력화한다(이번 라운드에 두 번 발생). 리터럴 쪼개기가 유지되는지 실측으로 잰다.
|
|
@@ -11812,10 +11924,10 @@ total++;
|
|
|
11812
11924
|
const selfNoE2e = PU._detectOrphanGuards(Object.assign({}, selfNoBin,
|
|
11813
11925
|
{ runners: selfNoBin.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
|
|
11814
11926
|
// ⚠ 비교 대상을 `orphanScripts` 하나로 두면 약한 불변식이다(검수 P3) — 판정 전체를 비교한다.
|
|
11815
|
-
// `orphanFiles`
|
|
11816
|
-
//
|
|
11817
|
-
//
|
|
11818
|
-
|
|
11927
|
+
// `orphanFiles` 를 통째로 빼면 자기참조 회귀를 못 잡는다(검수 P3) — **측정된 차이만 허용**한다.
|
|
11928
|
+
// 허용치: `scripts/e2e-core.js`. 근거는 실측이다 — e2e.js 가 그 경로를 언급하고, 그 파일은
|
|
11929
|
+
// 고아인 `test:core` 만 실행하므로 e2e.js 를 빼면 고아로 뜬다. 그 밖의 차이는 자기참조 회귀다.
|
|
11930
|
+
const _FILE_ALLOW = new Set(['scripts/e2e-core.js']);
|
|
11819
11931
|
const _shape = (x) => JSON.stringify({
|
|
11820
11932
|
s: x.orphanScripts.slice().sort(),
|
|
11821
11933
|
d: x.dynamicRunners.slice().sort(), p: x.enumPrefixes.slice().sort(),
|
|
@@ -11824,6 +11936,10 @@ total++;
|
|
|
11824
11936
|
if (_shape(selfBase) !== _shape(selfNoE2e)) {
|
|
11825
11937
|
bad.push(`자기참조:테스트파일이_판정을_바꿈(${selfBase.orphanScripts.join(',')} vs ${selfNoE2e.orphanScripts.join(',')})`);
|
|
11826
11938
|
}
|
|
11939
|
+
const _fdiff = [...new Set([...selfBase.orphanFiles, ...selfNoE2e.orphanFiles])]
|
|
11940
|
+
.filter(f => selfBase.orphanFiles.includes(f) !== selfNoE2e.orphanFiles.includes(f))
|
|
11941
|
+
.filter(f => !_FILE_ALLOW.has(String(f).replace(/\\/g, '/')));
|
|
11942
|
+
if (_fdiff.length) bad.push(`자기참조:테스트파일이_파일판정을_바꿈(${_fdiff.join(',')})`);
|
|
11827
11943
|
// 셋업 유효성 — e2e.js 가 실제로 live 러너여야 이 불변식이 의미가 있다(비어 있으면 공허하게 통과한다)
|
|
11828
11944
|
if (!selfNoBin.runners.some(r => /e2e\.js$/.test(r.file))) bad.push('자기참조:불변식이_공허(e2e.js가입력에없음)');
|
|
11829
11945
|
}
|