leerness 1.36.167 → 1.36.168
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -4
- package/bin/leerness.js +1 -1
- package/lib/diagnostics.js +196 -41
- package/package.json +4 -4
- package/scripts/release-runtime-probe.js +5 -2
- package/scripts/which-shim-probe.js +208 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.168 — 2026-08-26
|
|
4
|
+
|
|
5
|
+
- Windows which가 동일 npm 설치의 extensionless/.cmd/.ps1 shim을 실제 package bin target과 npm 실행문까지 검증한 뒤 한 설치로 그룹화하고, pathCandidates 원문은 유지한 채 pathInstallations를 추가해 PATH 충돌 오탐을 제거. 독립 검토에서 잡힌 주석·더미 인자 우회까지 막아 변조·미증명·다른 디렉터리·비표준 .bat·POSIX 후보를 보수적으로 분리하며 15개 회귀 프로브와 제품 배선 가드 추가.
|
|
6
|
+
|
|
3
7
|
## 1.36.167 — 2026-08-26
|
|
4
8
|
|
|
5
9
|
- T-0145: 장기 운영 프로젝트의 레거시 완료 증거를 소급 수정하지 않고 명시적 tracker 경계 이전의 현재 실패만 exact 행+사유 SHA-256 지문으로 격리하는 claims baseline을 추가했습니다. verify-claim --all과 gate --claims는 원판정 실패·baseline 부채·현재 차단 및 진단 상세를 분리해 보고하고, --raw/개별 검증은 원판정을 유지합니다. 행 변경·신규 실패·경계 밖 항목·손상 baseline은 fail-closed하며, 생성 명령은 기존 baseline을 덮어쓰지 않아 변경 행의 재승인을 차단합니다. CLI·MCP·클린룸 프로브로 검증합니다.
|
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ MIT
|
|
|
138
138
|
<!-- leerness:project-readme:start -->
|
|
139
139
|
## Leerness Project Harness
|
|
140
140
|
|
|
141
|
-
이 프로젝트는 Leerness v1.36.
|
|
141
|
+
이 프로젝트는 Leerness v1.36.168 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
142
142
|
|
|
143
143
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
144
144
|
|
|
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
|
|
|
192
192
|
|
|
193
193
|
### MCP server (외부 AI 통합)
|
|
194
194
|
|
|
195
|
-
Leerness v1.36.
|
|
195
|
+
Leerness v1.36.168는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
196
196
|
|
|
197
197
|
```jsonc
|
|
198
198
|
// 카테고리별
|
|
@@ -213,7 +213,7 @@ Leerness v1.36.167는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
213
213
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
214
214
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
215
215
|
|
|
216
|
-
현재 누적: **v1.9.x → 1.36.
|
|
216
|
+
현재 누적: **v1.9.x → 1.36.168 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
217
217
|
|
|
218
218
|
### 성능 가이드
|
|
219
219
|
|
|
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
|
|
|
251
251
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
252
252
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
253
253
|
|
|
254
|
-
Last synced by Leerness v1.36.
|
|
254
|
+
Last synced by Leerness v1.36.168: 2026-08-26
|
|
255
255
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -48,7 +48,7 @@ const {
|
|
|
48
48
|
migrateLegacyWorkspace,
|
|
49
49
|
} = require('../lib/workspace-dir');
|
|
50
50
|
|
|
51
|
-
const VERSION = '1.36.
|
|
51
|
+
const VERSION = '1.36.168';
|
|
52
52
|
|
|
53
53
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
54
54
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
package/lib/diagnostics.js
CHANGED
|
@@ -3,13 +3,151 @@
|
|
|
3
3
|
// - I/O: ./io(log). node: child_process.
|
|
4
4
|
// - harness 고유 의존(VERSION · _selfTestCases · _detectShellCtx · _mcpToolCount · argv 파서 has · harnessPath)은 deps 주입(DI).
|
|
5
5
|
// _selfTestCases 의 각 case.run 클로저는 harness 스코프를 유지하므로 함수 참조 주입만으로 정상 동작.
|
|
6
|
-
'use strict';
|
|
7
|
-
const cp = require('child_process');
|
|
8
|
-
const
|
|
6
|
+
'use strict';
|
|
7
|
+
const cp = require('child_process');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
9
10
|
const { log, fail, absRoot, exists, read } = require('./io');
|
|
10
11
|
const { parseHarnessVersion, _parseChangelogBetween, compareVer } = require('./pure-utils');
|
|
11
12
|
const { spawnNpmSync } = require('./npm-process');
|
|
12
13
|
const { windowsPathCandidates } = require('./portable-process');
|
|
14
|
+
|
|
15
|
+
const _WINDOWS_NPM_SHIM_EXTS = new Set(['', '.cmd', '.ps1']);
|
|
16
|
+
|
|
17
|
+
function _pathKey(value, platform) {
|
|
18
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
19
|
+
let normalized = pathApi.normalize(String(value || ''));
|
|
20
|
+
if (platform === process.platform) {
|
|
21
|
+
try { normalized = fs.realpathSync.native(normalized); } catch {}
|
|
22
|
+
}
|
|
23
|
+
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _escapeRegExp(value) {
|
|
27
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _readSmallText(filePath) {
|
|
31
|
+
const stat = fs.statSync(filePath);
|
|
32
|
+
if (!stat.isFile() || stat.size > 64 * 1024) return null;
|
|
33
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _npmShimTargetsEntry(candidate, expectedEntry, platform) {
|
|
37
|
+
if (platform !== 'win32' || platform !== process.platform) return false;
|
|
38
|
+
const pathApi = path.win32;
|
|
39
|
+
const ext = pathApi.extname(candidate).toLowerCase();
|
|
40
|
+
if (!_WINDOWS_NPM_SHIM_EXTS.has(ext)) return false;
|
|
41
|
+
if (!ext) {
|
|
42
|
+
try {
|
|
43
|
+
if (_pathKey(fs.realpathSync.native(candidate), platform) === _pathKey(expectedEntry, platform)) return true;
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let source;
|
|
48
|
+
try { source = _readSmallText(candidate); } catch { return false; }
|
|
49
|
+
if (source == null) return false;
|
|
50
|
+
const relative = pathApi.relative(pathApi.dirname(candidate), expectedEntry);
|
|
51
|
+
if (!relative || pathApi.isAbsolute(relative)) return false;
|
|
52
|
+
const lines = source.split(/\r?\n/);
|
|
53
|
+
|
|
54
|
+
if (!ext) {
|
|
55
|
+
const target = _escapeRegExp(`"$basedir/${relative.replace(/\\/g, '/')}"`);
|
|
56
|
+
const invocation = new RegExp(`^\\s*exec\\s+(?:"\\$basedir[\\\\/]node(?:\\.exe)?"|node(?:\\.exe)?)\\s+${target}\\s+"\\$@"\\s*$`, 'i');
|
|
57
|
+
const forwarded = lines.filter(line => !/^\s*#/.test(line) && /"\$@"\s*$/.test(line));
|
|
58
|
+
return forwarded.length > 0 && forwarded.every(line => invocation.test(line));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (ext === '.cmd') {
|
|
62
|
+
const target = _escapeRegExp(`"%dp0%\\${relative.replace(/\//g, '\\')}"`);
|
|
63
|
+
const invocation = new RegExp(`^\\s*endLocal\\s+&\\s+goto\\s+#_undefined_#\\s+2>NUL\\s+\\|\\|\\s+title\\s+%COMSPEC%\\s+&\\s+"%_prog%"\\s+${target}\\s+%\\*\\s*$`, 'i');
|
|
64
|
+
const forwarded = lines.filter(line => !/^\s*(?:@?rem\b|::)/i.test(line) && /%\*\s*$/.test(line));
|
|
65
|
+
return forwarded.length === 1 && invocation.test(forwarded[0]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const target = _escapeRegExp(`"$basedir/${relative.replace(/\\/g, '/')}"`);
|
|
69
|
+
const invocation = new RegExp(`^\\s*(?:\\$input\\s*\\|\\s*)?&\\s+(?:"\\$basedir[\\\\/]node\\$exe"|"node\\$exe")\\s+${target}\\s+\\$args\\s*$`, 'i');
|
|
70
|
+
const forwarded = lines.filter(line => !/^\s*#/.test(line) && /&/.test(line) && /\$args\s*$/.test(line));
|
|
71
|
+
return forwarded.length > 0 && forwarded.every(line => invocation.test(line));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _npmPackageIdentity(candidate, options = {}) {
|
|
75
|
+
const platform = options.platform || process.platform;
|
|
76
|
+
if (platform !== process.platform) return null;
|
|
77
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
78
|
+
const command = String(options.command || 'leerness');
|
|
79
|
+
const packageName = String(options.packageName || command);
|
|
80
|
+
const dir = pathApi.dirname(candidate);
|
|
81
|
+
const manifests = [pathApi.join(dir, 'node_modules', packageName, 'package.json')];
|
|
82
|
+
if (pathApi.basename(dir).toLowerCase() === '.bin') {
|
|
83
|
+
manifests.push(pathApi.join(dir, '..', packageName, 'package.json'));
|
|
84
|
+
}
|
|
85
|
+
for (const manifest of manifests) {
|
|
86
|
+
try {
|
|
87
|
+
const stat = fs.statSync(manifest);
|
|
88
|
+
if (!stat.isFile() || stat.size > 64 * 1024) continue;
|
|
89
|
+
const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
90
|
+
const declaredBin = typeof pkg.bin === 'string' ? pkg.bin
|
|
91
|
+
: (pkg.bin && typeof pkg.bin === 'object'
|
|
92
|
+
&& Object.prototype.hasOwnProperty.call(pkg.bin, command) ? pkg.bin[command] : null);
|
|
93
|
+
if (pkg.name !== packageName || typeof declaredBin !== 'string'
|
|
94
|
+
|| !declaredBin.trim() || /\u0000/.test(declaredBin)) continue;
|
|
95
|
+
const expectedEntry = pathApi.resolve(pathApi.dirname(manifest), declaredBin);
|
|
96
|
+
if (!fs.statSync(expectedEntry).isFile()) continue;
|
|
97
|
+
if (!_npmShimTargetsEntry(candidate, expectedEntry, platform)) continue;
|
|
98
|
+
return _pathKey(expectedEntry, platform);
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `where leerness` returns each executable shim, not each PATH installation. A normal
|
|
105
|
+
// Windows npm install therefore yields both `leerness` and `leerness.cmd`. Only merge
|
|
106
|
+
// variants when the adjacent package manifest and the shim's resolved bin target
|
|
107
|
+
// prove they belong to the same package; unproven same-stem files remain separate.
|
|
108
|
+
function _groupPathInstallations(candidates, options = {}) {
|
|
109
|
+
const platform = options.platform || process.platform;
|
|
110
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
111
|
+
const command = String(options.command || 'leerness').toLowerCase();
|
|
112
|
+
const packageIdentity = typeof options.packageIdentity === 'function'
|
|
113
|
+
? options.packageIdentity
|
|
114
|
+
: candidate => _npmPackageIdentity(candidate, {
|
|
115
|
+
platform, command, packageName: options.packageName || command,
|
|
116
|
+
});
|
|
117
|
+
const groups = [];
|
|
118
|
+
const byKey = new Map();
|
|
119
|
+
|
|
120
|
+
for (const rawCandidate of Array.isArray(candidates) ? candidates : []) {
|
|
121
|
+
const candidate = String(rawCandidate || '').trim();
|
|
122
|
+
if (!candidate) continue;
|
|
123
|
+
const normalized = pathApi.normalize(candidate);
|
|
124
|
+
const base = pathApi.basename(normalized);
|
|
125
|
+
const ext = pathApi.extname(base).toLowerCase();
|
|
126
|
+
const isNpmShimVariant = platform === 'win32' && _WINDOWS_NPM_SHIM_EXTS.has(ext);
|
|
127
|
+
const stem = isNpmShimVariant && ext ? base.slice(0, -ext.length) : base;
|
|
128
|
+
const directory = pathApi.dirname(normalized);
|
|
129
|
+
let kind = 'candidate';
|
|
130
|
+
let key = `candidate:${_pathKey(normalized, platform)}`;
|
|
131
|
+
|
|
132
|
+
if (isNpmShimVariant && stem.toLowerCase() === command) {
|
|
133
|
+
let identity = null;
|
|
134
|
+
try { identity = packageIdentity(normalized); } catch { identity = null; }
|
|
135
|
+
if (identity) {
|
|
136
|
+
kind = 'npm-package';
|
|
137
|
+
key = `npm:${_pathKey(directory, platform)}:${String(identity).toLowerCase()}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let group = byKey.get(key);
|
|
142
|
+
if (!group) {
|
|
143
|
+
group = { directory, kind, candidates: [] };
|
|
144
|
+
byKey.set(key, group);
|
|
145
|
+
groups.push(group);
|
|
146
|
+
}
|
|
147
|
+
group.candidates.push(candidate);
|
|
148
|
+
}
|
|
149
|
+
return groups;
|
|
150
|
+
}
|
|
13
151
|
|
|
14
152
|
function doctorCmd(opts = {}, deps = {}) {
|
|
15
153
|
const { VERSION, uiLang, _selfTestCases, _selfTestReport, _controlSelftestEnv,
|
|
@@ -86,59 +224,70 @@ function doctorCmd(opts = {}, deps = {}) {
|
|
|
86
224
|
return report;
|
|
87
225
|
}
|
|
88
226
|
|
|
89
|
-
function whichCmd(deps = {}) {
|
|
90
|
-
const { VERSION, uiLang, has, harnessPath } = deps;
|
|
91
|
-
const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.28.2 (UR-0010 Phase 10c)
|
|
92
|
-
const
|
|
227
|
+
function whichCmd(deps = {}) {
|
|
228
|
+
const { VERSION, uiLang, has, harnessPath } = deps;
|
|
229
|
+
const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.28.2 (UR-0010 Phase 10c)
|
|
230
|
+
const platform = deps.platform || process.platform;
|
|
231
|
+
const out = {
|
|
93
232
|
version: VERSION,
|
|
94
233
|
runningFrom: harnessPath,
|
|
95
234
|
nodeVersion: process.version,
|
|
96
|
-
platform
|
|
235
|
+
platform,
|
|
97
236
|
arch: process.arch,
|
|
98
237
|
npm: {},
|
|
99
|
-
pathCandidates: []
|
|
238
|
+
pathCandidates: [],
|
|
239
|
+
pathInstallations: []
|
|
100
240
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
241
|
+
if (!deps.skipNpm) {
|
|
242
|
+
// npm root -g (글로벌 설치 경로)
|
|
243
|
+
try {
|
|
244
|
+
const r = spawnNpmSync(['root', '-g'], { encoding: 'utf8', timeout: 5000 });
|
|
245
|
+
if (r.status === 0) out.npm.globalRoot = (r.stdout || '').trim();
|
|
246
|
+
} catch {}
|
|
247
|
+
// npm cache (npx 캐시 경로)
|
|
248
|
+
try {
|
|
249
|
+
const r = spawnNpmSync(['config', 'get', 'cache'], { encoding: 'utf8', timeout: 5000 });
|
|
250
|
+
if (r.status === 0) out.npm.cacheDir = (r.stdout || '').trim();
|
|
251
|
+
} catch {}
|
|
252
|
+
// npm 글로벌 leerness 설치 정보
|
|
253
|
+
try {
|
|
254
|
+
const r = spawnNpmSync(['ls', '-g', 'leerness', '--depth=0', '--json'], { encoding: 'utf8', timeout: 8000 });
|
|
255
|
+
if (r.stdout) {
|
|
256
|
+
try {
|
|
257
|
+
const j = JSON.parse(r.stdout);
|
|
258
|
+
if (j.dependencies?.leerness) out.npm.globalInstalled = j.dependencies.leerness.version;
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
} catch {}
|
|
262
|
+
}
|
|
121
263
|
// PATH 후보 (Windows: absolute System32 where + PATH-only 필터 / Unix: which)
|
|
122
264
|
try {
|
|
123
|
-
const isWin =
|
|
124
|
-
if (
|
|
265
|
+
const isWin = platform === 'win32';
|
|
266
|
+
if (Array.isArray(deps.pathCandidates)) {
|
|
267
|
+
out.pathCandidates = deps.pathCandidates.slice();
|
|
268
|
+
} else if (isWin) {
|
|
125
269
|
out.pathCandidates = windowsPathCandidates('leerness', { env: process.env, cwd: process.cwd() });
|
|
126
270
|
} else {
|
|
127
271
|
const r = cp.spawnSync('which', ['-a', 'leerness'], { encoding: 'utf8', timeout: 5000, shell: false });
|
|
128
272
|
if (r.stdout) out.pathCandidates = (r.stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
129
273
|
}
|
|
130
274
|
} catch {}
|
|
275
|
+
out.pathInstallations = _groupPathInstallations(out.pathCandidates, {
|
|
276
|
+
platform, command: 'leerness', packageName: 'leerness',
|
|
277
|
+
...(typeof deps.packageIdentity === 'function' ? { packageIdentity: deps.packageIdentity } : {}),
|
|
278
|
+
});
|
|
131
279
|
// 진단: 글로벌 설치된 leerness 와 현재 실행 버전이 다르면 경고
|
|
132
280
|
out.diagnostics = [];
|
|
133
281
|
if (out.npm.globalInstalled && out.npm.globalInstalled !== VERSION) {
|
|
134
282
|
out.diagnostics.push(t(`⚠ 글로벌 설치 ${out.npm.globalInstalled} ≠ 현재 실행 ${VERSION} — npx 캐시 또는 PATH 충돌 의심`, `⚠ global install ${out.npm.globalInstalled} ≠ running ${VERSION} — suspect npx cache or PATH conflict`));
|
|
135
283
|
out.diagnostics.push(t(` → 강제 최신: npm i -g leerness@latest / 또는 npx --yes leerness@latest <command>`, ` → force latest: npm i -g leerness@latest / or npx --yes leerness@latest <command>`));
|
|
136
284
|
}
|
|
137
|
-
if (out.
|
|
138
|
-
out.diagnostics.push(t(`⚠ PATH 에 leerness
|
|
285
|
+
if (out.pathInstallations.length > 1) {
|
|
286
|
+
out.diagnostics.push(t(`⚠ PATH 에 leerness 설치가 ${out.pathInstallations.length}개 — 우선순위 충돌 가능`, `⚠ ${out.pathInstallations.length} leerness installations appear on PATH — possible precedence conflict`));
|
|
139
287
|
out.diagnostics.push(t(` → 명시적 경로 사용: ${out.runningFrom}`, ` → use the explicit path: ${out.runningFrom}`));
|
|
140
288
|
}
|
|
141
|
-
if (
|
|
289
|
+
if (deps.quiet) return out;
|
|
290
|
+
if (has('--json')) { log(JSON.stringify(out, null, 2)); return out; }
|
|
142
291
|
log(`# leerness which (1.9.164)`);
|
|
143
292
|
log(`${t('현재 실행', 'running from')}: ${out.runningFrom}`);
|
|
144
293
|
log(`${t('버전', 'version')}: v${out.version}`);
|
|
@@ -149,10 +298,15 @@ function whichCmd(deps = {}) {
|
|
|
149
298
|
if (out.npm.cacheDir) log(t(` npm cache: ${out.npm.cacheDir} (npx 옛 버전이 여기 캐싱 — 의심 시 \`npm cache clean --force\`)`, ` npm cache: ${out.npm.cacheDir} (npx caches old versions here — if suspect: \`npm cache clean --force\`)`));
|
|
150
299
|
if (out.npm.globalInstalled) log(t(` 글로벌 설치: leerness@${out.npm.globalInstalled}`, ` global install: leerness@${out.npm.globalInstalled}`));
|
|
151
300
|
else log(t(` 글로벌 설치: (없음 — npx 또는 로컬 경로만 사용 중)`, ` global install: (none — using npx or a local path only)`));
|
|
152
|
-
if (out.pathCandidates.length) {
|
|
153
|
-
log('');
|
|
154
|
-
log(t(`## PATH 후보 (${out.pathCandidates.length}개)`, `## PATH candidates (${out.pathCandidates.length})`));
|
|
155
|
-
for (const p of out.pathCandidates) log(` - ${p}`);
|
|
301
|
+
if (out.pathCandidates.length) {
|
|
302
|
+
log('');
|
|
303
|
+
log(t(`## PATH 후보 (${out.pathCandidates.length}개 · 설치 ${out.pathInstallations.length}개)`, `## PATH candidates (${out.pathCandidates.length} · ${out.pathInstallations.length} installation(s))`));
|
|
304
|
+
for (const p of out.pathCandidates) log(` - ${p}`);
|
|
305
|
+
const groupedShims = out.pathInstallations.filter(group => group.kind === 'npm-package' && group.candidates.length > 1);
|
|
306
|
+
if (groupedShims.length) {
|
|
307
|
+
const variants = groupedShims.reduce((sum, group) => sum + group.candidates.length, 0);
|
|
308
|
+
log(t(` ⓘ 동일 npm 설치의 shim 변형 ${variants}개를 설치 ${groupedShims.length}개로 그룹화`, ` ⓘ grouped ${variants} shim variants into ${groupedShims.length} npm installation(s)`));
|
|
309
|
+
}
|
|
156
310
|
}
|
|
157
311
|
if (out.diagnostics.length) {
|
|
158
312
|
log('');
|
|
@@ -166,8 +320,9 @@ function whichCmd(deps = {}) {
|
|
|
166
320
|
log(t(`💡 강제 최신 실행 방법:`, `💡 how to force the latest:`));
|
|
167
321
|
log(t(` 1) npx --yes leerness@latest <command> # npx 캐시 무시하고 최신 다운로드`, ` 1) npx --yes leerness@latest <command> # bypass npx cache, download latest`));
|
|
168
322
|
log(t(` 2) npm i -g leerness@latest # 글로벌 설치 갱신`, ` 2) npm i -g leerness@latest # update the global install`));
|
|
169
|
-
log(t(` 3) npm cache clean --force # npx 캐시 강제 비우기 (의심 시)`, ` 3) npm cache clean --force # force-clear the npx cache (if suspect)`));
|
|
170
|
-
|
|
323
|
+
log(t(` 3) npm cache clean --force # npx 캐시 강제 비우기 (의심 시)`, ` 3) npm cache clean --force # force-clear the npx cache (if suspect)`));
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
171
326
|
|
|
172
327
|
// 1.9.394 (UR-0025): whats-new — 현재 워크스페이스 버전 → 도구 버전 CHANGELOG 차분(신규 명령/플래그/파일 요약). introspection 핸들러.
|
|
173
328
|
// 순수 파서 _parseChangelogBetween(pure-utils) 사용. deps: VERSION/arg/has. CHANGELOG 경로는 root 우선, 없으면 pkg 자체(lib/../).
|
|
@@ -261,4 +416,4 @@ function whatsNewCmd(root, deps = {}) {
|
|
|
261
416
|
log(t(` 4. 상세: \`cat CHANGELOG.md\` 또는 \`leerness whats-new --json\``, ` 4. Details: \`cat CHANGELOG.md\` or \`leerness whats-new --json\``));
|
|
262
417
|
}
|
|
263
418
|
|
|
264
|
-
module.exports = { doctorCmd, whichCmd, whatsNewCmd };
|
|
419
|
+
module.exports = { doctorCmd, whichCmd, whatsNewCmd, _groupPathInstallations, _npmPackageIdentity };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.168",
|
|
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",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
|
|
53
53
|
"test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
|
|
54
54
|
"test:installed": "node ./scripts/installed-cleanroom-probe.js",
|
|
55
55
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
56
|
-
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/smoke.js",
|
|
56
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/smoke.js",
|
|
57
57
|
"test:smoke": "node ./scripts/smoke.js",
|
|
58
58
|
"lint": "node ./scripts/lint.js",
|
|
59
59
|
"prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
|
|
@@ -1124,8 +1124,11 @@ try {
|
|
|
1124
1124
|
|| /spawnSync\(['"]where(?:\.exe)?['"]/.test(npmSource)) {
|
|
1125
1125
|
failures.push('공용 npm resolver가 bare where 또는 동적 cmd wrapper로 후퇴함');
|
|
1126
1126
|
}
|
|
1127
|
-
if (!diagnosticsSource.includes("windowsPathCandidates('leerness'")
|
|
1128
|
-
|
|
1127
|
+
if (!diagnosticsSource.includes("windowsPathCandidates('leerness'")
|
|
1128
|
+
|| !diagnosticsSource.includes('out.pathInstallations = _groupPathInstallations')
|
|
1129
|
+
|| !diagnosticsSource.includes('if (out.pathInstallations.length > 1)')
|
|
1130
|
+
|| /spawnSync\(tool,/.test(diagnosticsSource)) {
|
|
1131
|
+
failures.push('which 진단이 안전한 Windows PATH 수집 또는 설치 단위 그룹화를 잃음');
|
|
1129
1132
|
}
|
|
1130
1133
|
if (cliSource.includes("cp.execSync('npm root -g'") || cliSource.includes("cp.spawnSync('chcp.com'")
|
|
1131
1134
|
|| cliSource.includes("cp.spawnSync('powershell'") || cliSource.includes("cp.spawnSync('powershell.exe'")) {
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const diagnostics = require('../lib/diagnostics');
|
|
7
|
+
|
|
8
|
+
const failures = [];
|
|
9
|
+
let checks = 0;
|
|
10
|
+
const groupPathInstallations = diagnostics._groupPathInstallations;
|
|
11
|
+
|
|
12
|
+
function check(condition, failure) {
|
|
13
|
+
checks++;
|
|
14
|
+
if (!condition) failures.push(failure);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof groupPathInstallations !== 'function') {
|
|
18
|
+
failures.push('diagnostics._groupPathInstallations is not implemented');
|
|
19
|
+
} else {
|
|
20
|
+
const npmShimPair = [
|
|
21
|
+
'C:\\Users\\tester\\AppData\\Roaming\\npm\\leerness',
|
|
22
|
+
'C:\\Users\\tester\\AppData\\Roaming\\npm\\leerness.cmd',
|
|
23
|
+
];
|
|
24
|
+
const samePackage = candidate => candidate.replace(/\//g, '\\').toLowerCase().includes('\\npm\\leerness')
|
|
25
|
+
? 'c:\\users\\tester\\appdata\\roaming\\npm\\node_modules\\leerness\\package.json'
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
const oneInstall = groupPathInstallations(npmShimPair, {
|
|
29
|
+
platform: 'win32',
|
|
30
|
+
command: 'leerness',
|
|
31
|
+
packageIdentity: samePackage,
|
|
32
|
+
});
|
|
33
|
+
check(oneInstall.length === 1 && oneInstall[0].candidates.length === 2,
|
|
34
|
+
`same npm shim pair should be one install, got ${JSON.stringify(oneInstall)}`);
|
|
35
|
+
|
|
36
|
+
const npmShimTrio = groupPathInstallations([...npmShimPair,
|
|
37
|
+
'C:\\Users\\tester\\AppData\\Roaming\\npm\\leerness.ps1'], {
|
|
38
|
+
platform: 'win32', command: 'leerness', packageIdentity: samePackage,
|
|
39
|
+
});
|
|
40
|
+
check(npmShimTrio.length === 1 && npmShimTrio[0].candidates.length === 3,
|
|
41
|
+
`PowerShell shim should join the same npm install, got ${JSON.stringify(npmShimTrio)}`);
|
|
42
|
+
|
|
43
|
+
const mixedCaseAndSeparators = groupPathInstallations([npmShimPair[0],
|
|
44
|
+
'c:/Users/tester/AppData/Roaming/npm/leerness.cmd'], {
|
|
45
|
+
platform: 'win32', command: 'leerness', packageIdentity: samePackage,
|
|
46
|
+
});
|
|
47
|
+
check(mixedCaseAndSeparators.length === 1,
|
|
48
|
+
`Windows case and separator variants should share one install, got ${JSON.stringify(mixedCaseAndSeparators)}`);
|
|
49
|
+
|
|
50
|
+
const nonStandardBat = groupPathInstallations([npmShimPair[0],
|
|
51
|
+
'C:\\Users\\tester\\AppData\\Roaming\\npm\\leerness.bat'], {
|
|
52
|
+
platform: 'win32', command: 'leerness', packageIdentity: samePackage,
|
|
53
|
+
});
|
|
54
|
+
check(nonStandardBat.length === 2,
|
|
55
|
+
`non-standard .bat must not be merged without content proof, got ${JSON.stringify(nonStandardBat)}`);
|
|
56
|
+
|
|
57
|
+
const trueConflict = groupPathInstallations([
|
|
58
|
+
...npmShimPair,
|
|
59
|
+
'D:\\tools\\leerness.cmd',
|
|
60
|
+
], {
|
|
61
|
+
platform: 'win32',
|
|
62
|
+
command: 'leerness',
|
|
63
|
+
packageIdentity: candidate => candidate.toLowerCase().startsWith('d:\\tools\\')
|
|
64
|
+
? 'd:\\tools\\node_modules\\leerness\\package.json'
|
|
65
|
+
: samePackage(candidate),
|
|
66
|
+
});
|
|
67
|
+
check(trueConflict.length === 2,
|
|
68
|
+
`different PATH directories should remain a conflict, got ${JSON.stringify(trueConflict)}`);
|
|
69
|
+
|
|
70
|
+
const unprovenPair = groupPathInstallations(npmShimPair, {
|
|
71
|
+
platform: 'win32',
|
|
72
|
+
command: 'leerness',
|
|
73
|
+
packageIdentity: () => null,
|
|
74
|
+
});
|
|
75
|
+
check(unprovenPair.length === 2,
|
|
76
|
+
`same-stem files without a shared package must stay separate, got ${JSON.stringify(unprovenPair)}`);
|
|
77
|
+
|
|
78
|
+
const posixCandidates = groupPathInstallations([
|
|
79
|
+
'/usr/local/bin/leerness',
|
|
80
|
+
'/opt/leerness/bin/leerness',
|
|
81
|
+
], {
|
|
82
|
+
platform: 'linux',
|
|
83
|
+
command: 'leerness',
|
|
84
|
+
packageIdentity: () => 'same-version-is-not-same-install',
|
|
85
|
+
});
|
|
86
|
+
check(posixCandidates.length === 2,
|
|
87
|
+
`POSIX candidates in separate PATH directories must not be grouped, got ${JSON.stringify(posixCandidates)}`);
|
|
88
|
+
|
|
89
|
+
const report = diagnostics.whichCmd({
|
|
90
|
+
VERSION: 'test', uiLang: 'en', has: () => false, harnessPath: 'fixture',
|
|
91
|
+
platform: 'win32', pathCandidates: npmShimPair, packageIdentity: samePackage,
|
|
92
|
+
skipNpm: true, quiet: true,
|
|
93
|
+
});
|
|
94
|
+
check(report.pathCandidates.length === 2 && report.pathInstallations.length === 1
|
|
95
|
+
&& !report.diagnostics.some(line => /precedence conflict/.test(line)),
|
|
96
|
+
`product-level which report should preserve 2 raw shims without a conflict, got ${JSON.stringify(report)}`);
|
|
97
|
+
|
|
98
|
+
const conflictReport = diagnostics.whichCmd({
|
|
99
|
+
VERSION: 'test', uiLang: 'en', has: () => false, harnessPath: 'fixture',
|
|
100
|
+
platform: 'win32', pathCandidates: [...npmShimPair, 'D:\\tools\\leerness.cmd'],
|
|
101
|
+
packageIdentity: candidate => candidate.toLowerCase().startsWith('d:\\tools\\')
|
|
102
|
+
? 'd:\\tools\\node_modules\\leerness\\package.json' : samePackage(candidate),
|
|
103
|
+
skipNpm: true, quiet: true,
|
|
104
|
+
});
|
|
105
|
+
check(conflictReport.pathInstallations.length === 2
|
|
106
|
+
&& conflictReport.diagnostics.some(line => /2 leerness installations/.test(line)),
|
|
107
|
+
`product-level which report should retain true PATH conflicts, got ${JSON.stringify(conflictReport)}`);
|
|
108
|
+
|
|
109
|
+
if (process.platform === 'win32') {
|
|
110
|
+
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-which-shim-'));
|
|
111
|
+
try {
|
|
112
|
+
const packageDir = path.join(fixture, 'node_modules', 'leerness');
|
|
113
|
+
const entryDir = path.join(packageDir, 'bin');
|
|
114
|
+
fs.mkdirSync(entryDir, { recursive: true });
|
|
115
|
+
fs.writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({
|
|
116
|
+
name: 'leerness', version: '9.9.9', bin: { leerness: 'bin/leerness.js' },
|
|
117
|
+
}));
|
|
118
|
+
fs.writeFileSync(path.join(entryDir, 'leerness.js'), '#!/usr/bin/env node\n');
|
|
119
|
+
const shShim = path.join(fixture, 'leerness');
|
|
120
|
+
const cmdShim = path.join(fixture, 'leerness.cmd');
|
|
121
|
+
const ps1Shim = path.join(fixture, 'leerness.ps1');
|
|
122
|
+
const validSh = '#!/bin/sh\nexec node "$basedir/node_modules/leerness/bin/leerness.js" "$@"\n';
|
|
123
|
+
const validCmd = '@ECHO off\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\leerness\\bin\\leerness.js" %*\n';
|
|
124
|
+
const validPs1 = '#!/usr/bin/env pwsh\n& "node$exe" "$basedir/node_modules/leerness/bin/leerness.js" $args\n';
|
|
125
|
+
fs.writeFileSync(shShim, validSh);
|
|
126
|
+
fs.writeFileSync(cmdShim, validCmd);
|
|
127
|
+
fs.writeFileSync(ps1Shim, validPs1);
|
|
128
|
+
const detected = groupPathInstallations([
|
|
129
|
+
shShim, cmdShim, ps1Shim,
|
|
130
|
+
], { platform: 'win32', command: 'leerness' });
|
|
131
|
+
check(detected.length === 1 && detected[0].kind === 'npm-package'
|
|
132
|
+
&& detected[0].candidates.length === 3,
|
|
133
|
+
`default package-manifest detection should group a real fixture, got ${JSON.stringify(detected)}`);
|
|
134
|
+
|
|
135
|
+
fs.writeFileSync(shShim,
|
|
136
|
+
'#!/bin/sh\nexec echo "$basedir/node_modules/leerness/bin/leerness.js" "$@"\n');
|
|
137
|
+
const dummySh = groupPathInstallations([shShim, cmdShim], {
|
|
138
|
+
platform: 'win32', command: 'leerness',
|
|
139
|
+
});
|
|
140
|
+
check(dummySh.length === 2,
|
|
141
|
+
`a dummy extensionless target argument must not prove execution, got ${JSON.stringify(dummySh)}`);
|
|
142
|
+
fs.writeFileSync(shShim, validSh);
|
|
143
|
+
|
|
144
|
+
fs.writeFileSync(cmdShim,
|
|
145
|
+
'@rem "%dp0%\\node_modules\\leerness\\bin\\leerness.js" %*\n"evil.exe" %*\n');
|
|
146
|
+
const commentedCmd = groupPathInstallations([shShim, cmdShim], {
|
|
147
|
+
platform: 'win32', command: 'leerness',
|
|
148
|
+
});
|
|
149
|
+
check(commentedCmd.length === 2,
|
|
150
|
+
`a commented cmd target plus divergent invocation must not be grouped, got ${JSON.stringify(commentedCmd)}`);
|
|
151
|
+
fs.writeFileSync(cmdShim, validCmd);
|
|
152
|
+
|
|
153
|
+
fs.writeFileSync(ps1Shim,
|
|
154
|
+
'# "$basedir/node_modules/leerness/bin/leerness.js" $args\n& evil $args\n');
|
|
155
|
+
const commentedPs1 = groupPathInstallations([shShim, ps1Shim], {
|
|
156
|
+
platform: 'win32', command: 'leerness',
|
|
157
|
+
});
|
|
158
|
+
check(commentedPs1.length === 2,
|
|
159
|
+
`a commented PowerShell target plus divergent invocation must not be grouped, got ${JSON.stringify(commentedPs1)}`);
|
|
160
|
+
fs.writeFileSync(ps1Shim, validPs1);
|
|
161
|
+
|
|
162
|
+
const otherEntry = path.join(fixture, 'node_modules', 'other', 'bin');
|
|
163
|
+
fs.mkdirSync(otherEntry, { recursive: true });
|
|
164
|
+
fs.writeFileSync(path.join(otherEntry, 'other.js'), '#!/usr/bin/env node\n');
|
|
165
|
+
fs.writeFileSync(cmdShim,
|
|
166
|
+
'@ECHO off\n"%_prog%" "%dp0%\\node_modules\\other\\bin\\other.js" %*\n');
|
|
167
|
+
const tampered = groupPathInstallations([
|
|
168
|
+
shShim, cmdShim,
|
|
169
|
+
], { platform: 'win32', command: 'leerness' });
|
|
170
|
+
check(tampered.length === 2,
|
|
171
|
+
`adjacent package metadata must not hide a divergent shim, got ${JSON.stringify(tampered)}`);
|
|
172
|
+
|
|
173
|
+
const localModules = path.join(fixture, 'local with space', 'node_modules');
|
|
174
|
+
const localBin = path.join(localModules, '.bin');
|
|
175
|
+
const localPackage = path.join(localModules, 'leerness');
|
|
176
|
+
fs.mkdirSync(path.join(localPackage, 'bin'), { recursive: true });
|
|
177
|
+
fs.mkdirSync(localBin, { recursive: true });
|
|
178
|
+
fs.writeFileSync(path.join(localPackage, 'package.json'), JSON.stringify({
|
|
179
|
+
name: 'leerness', version: '9.9.9', bin: { leerness: 'bin/leerness.js' },
|
|
180
|
+
}));
|
|
181
|
+
fs.writeFileSync(path.join(localPackage, 'bin', 'leerness.js'), '#!/usr/bin/env node\n');
|
|
182
|
+
const localSh = path.join(localBin, 'leerness');
|
|
183
|
+
const localCmd = path.join(localBin, 'leerness.cmd');
|
|
184
|
+
const localPs1 = path.join(localBin, 'leerness.ps1');
|
|
185
|
+
fs.writeFileSync(localSh,
|
|
186
|
+
'#!/bin/sh\nexec node "$basedir/../leerness/bin/leerness.js" "$@"\n');
|
|
187
|
+
fs.writeFileSync(localCmd,
|
|
188
|
+
'@ECHO off\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\..\\leerness\\bin\\leerness.js" %*\n');
|
|
189
|
+
fs.writeFileSync(localPs1,
|
|
190
|
+
'#!/usr/bin/env pwsh\n& "node$exe" "$basedir/../leerness/bin/leerness.js" $args\n');
|
|
191
|
+
const localInstall = groupPathInstallations([localSh, localCmd, localPs1], {
|
|
192
|
+
platform: 'win32', command: 'leerness',
|
|
193
|
+
});
|
|
194
|
+
check(localInstall.length === 1 && localInstall[0].candidates.length === 3,
|
|
195
|
+
`local node_modules/.bin shims should be one install, got ${JSON.stringify(localInstall)}`);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (failures.length) {
|
|
203
|
+
console.error(`which shim grouping failures (${failures.length}):`);
|
|
204
|
+
failures.forEach(failure => console.error(`- ${failure}`));
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(`which shim grouping probe: ${checks}/${checks} passed`);
|