leerness 1.36.167 → 1.36.169
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 +10 -0
- package/README.md +4 -4
- package/bin/leerness.js +261 -58
- package/lib/diagnostics.js +196 -41
- package/lib/migrate.js +27 -5
- package/package.json +6 -5
- package/scripts/e2e-concurrency.js +73 -2
- package/scripts/e2e.js +18 -12
- package/scripts/next-action-suggestion-probe.js +144 -0
- package/scripts/release-runtime-probe.js +5 -2
- package/scripts/which-shim-probe.js +208 -0
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/lib/migrate.js
CHANGED
|
@@ -38,19 +38,41 @@ function migrateAuditCmd(root, opts = {}, deps = {}) {
|
|
|
38
38
|
// UR-0075 Phase C (1.9.357): migrate apply — audit가 찾은 '안전 항목'(canonical 백필)만 비파괴 적용.
|
|
39
39
|
// 기본 dry-run(변경 없음) · --yes 로 실제 적용. version-drift/missing-file 은 apply 범위 외(수동 안내).
|
|
40
40
|
function migrateApplyCmd(root, opts = {}, deps = {}) {
|
|
41
|
-
const { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons } = deps;
|
|
41
|
+
const { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons, _withLock } = deps;
|
|
42
42
|
root = absRoot(root);
|
|
43
43
|
const apply = !!opts.yes;
|
|
44
|
+
if (apply && typeof _withLock !== 'function') throw new Error('migrate apply requires the canonical store lock dependency');
|
|
45
|
+
const withLock = typeof _withLock === 'function' ? _withLock : (_target, fn) => fn();
|
|
44
46
|
const applied = []; // 안전하게 적용 가능(또는 dry-run 예정): canonical 백필
|
|
45
47
|
const skipped = []; // apply 범위 외: 수동 조치 필요
|
|
46
48
|
// canonical-pending: MD 항목 존재 + JSON 부재 → load(MD 백필)→save(canonical JSON + MD 정규화). idempotent(UR-0053).
|
|
47
49
|
if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
let changed = !apply;
|
|
51
|
+
if (apply) {
|
|
52
|
+
// 1.36.169 (T-0108): exists/load/save 전체가 decision add/drop/restore와 같은 락 안이어야 한다.
|
|
53
|
+
// 바깥에서 읽은 legacy snapshot을 락 획득 뒤 쓰면 그 사이 추가된 결정을 지울 수 있다.
|
|
54
|
+
changed = withLock(decisionsJsonPath(root), () => {
|
|
55
|
+
if (!exists(decisionsPath(root)) || exists(decisionsJsonPath(root))) return false;
|
|
56
|
+
const current = _loadDecisions(root);
|
|
57
|
+
if (!current.length) return false;
|
|
58
|
+
_saveDecisions(root, current);
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (changed) applied.push({ kind: 'canonical-backfill', detail: 'decisions.md → decisions.json' });
|
|
50
63
|
}
|
|
51
64
|
if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) {
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
let changed = !apply;
|
|
66
|
+
if (apply) {
|
|
67
|
+
changed = withLock(lessonsJsonPath(root), () => {
|
|
68
|
+
if (!exists(lessonsPath(root)) || exists(lessonsJsonPath(root))) return false;
|
|
69
|
+
const current = _loadLessons(root);
|
|
70
|
+
if (!current.length) return false;
|
|
71
|
+
_saveLessons(root, current);
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (changed) applied.push({ kind: 'canonical-backfill', detail: 'lessons.md → lessons.json' });
|
|
54
76
|
}
|
|
55
77
|
// version-drift / 누락 파일 — apply 가 안전하게 in-place 처리 불가(npm 재설치/재초기화 필요) → 수동 안내.
|
|
56
78
|
const hvPath = path.join(root, '.leerness', 'HARNESS_VERSION');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.169",
|
|
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,13 @@
|
|
|
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",
|
|
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",
|
|
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
|
+
"test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
|
|
54
|
+
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
|
|
54
55
|
"test:installed": "node ./scripts/installed-cleanroom-probe.js",
|
|
55
56
|
"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",
|
|
57
|
+
"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
58
|
"test:smoke": "node ./scripts/smoke.js",
|
|
58
59
|
"lint": "node ./scripts/lint.js",
|
|
59
60
|
"prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
|
|
@@ -44,8 +44,11 @@ function spawn(d, args, stallTarget) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function waitForStall(d, target) {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
47
|
+
const normalized = target.replace(/\\/g, '/');
|
|
48
|
+
const targetDir = path.dirname(normalized) === '.' ? '' : path.dirname(normalized);
|
|
49
|
+
const targetName = path.basename(normalized);
|
|
50
|
+
const re = new RegExp('^' + targetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
|
|
51
|
+
const hd = path.join(d, '.leerness', targetDir);
|
|
49
52
|
for (let i = 0; i < 800; i++) {
|
|
50
53
|
try { if (fs.readdirSync(hd).some(name => re.test(name))) return true; } catch {}
|
|
51
54
|
nap(10);
|
|
@@ -60,6 +63,7 @@ async function race(name, config) {
|
|
|
60
63
|
if (config.seed) config.seed(d);
|
|
61
64
|
const first = spawn(d, config.first, config.stallTarget);
|
|
62
65
|
const stalled = waitForStall(d, config.stallTarget);
|
|
66
|
+
if (config.beforeSecond) config.beforeSecond(d);
|
|
63
67
|
const second = spawn(d, config.second);
|
|
64
68
|
const exits = await Promise.all([wait(first), wait(second)]);
|
|
65
69
|
const inspected = config.inspect(d);
|
|
@@ -132,6 +136,73 @@ async function race(name, config) {
|
|
|
132
136
|
return { archived: plan.includes('archived'), survivor: plan.includes('survivor'), preserved: plan.includes('archived') && plan.includes('survivor') };
|
|
133
137
|
}
|
|
134
138
|
}));
|
|
139
|
+
results.push(await race('plan-init-vs-remove', {
|
|
140
|
+
stallTarget: 'plan.md',
|
|
141
|
+
seed: d => run(d, ['plan', 'add', 'victim']),
|
|
142
|
+
first: ['plan', 'remove', 'victim'],
|
|
143
|
+
second: ['plan', 'init', '--goal', 'survivor-goal'],
|
|
144
|
+
inspect: d => {
|
|
145
|
+
const plan = fs.readFileSync(path.join(d, '.leerness', 'plan.md'), 'utf8');
|
|
146
|
+
return {
|
|
147
|
+
victimRemoved: !plan.includes('victim'),
|
|
148
|
+
survivorPreserved: plan.includes('survivor-goal'),
|
|
149
|
+
preserved: !plan.includes('victim') && plan.includes('survivor-goal')
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
results.push(await race('anchors-plan-vs-plan-add', {
|
|
154
|
+
stallTarget: 'plan.md',
|
|
155
|
+
seed: d => fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', description: 'coordination probe' })),
|
|
156
|
+
first: ['anchors', 'draft', '--apply'],
|
|
157
|
+
second: ['plan', 'add', 'survivor-milestone'],
|
|
158
|
+
inspect: d => {
|
|
159
|
+
const plan = fs.readFileSync(path.join(d, '.leerness', 'plan.md'), 'utf8');
|
|
160
|
+
return { survivorPreserved: plan.includes('survivor-milestone'), preserved: plan.includes('survivor-milestone') };
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
results.push(await race('migrate-decisions-vs-add', {
|
|
164
|
+
stallTarget: 'decisions.json',
|
|
165
|
+
seed: d => {
|
|
166
|
+
run(d, ['decision', 'add', 'legacy-decision']);
|
|
167
|
+
fs.unlinkSync(path.join(d, '.leerness', 'decisions.json'));
|
|
168
|
+
},
|
|
169
|
+
first: ['migrate', 'apply', '--yes'],
|
|
170
|
+
second: ['decision', 'add', 'survivor-decision'],
|
|
171
|
+
inspect: d => {
|
|
172
|
+
const titles = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'decisions.json'), 'utf8')).map(item => item.title);
|
|
173
|
+
return { titles, preserved: titles.includes('legacy-decision') && titles.includes('survivor-decision') };
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
results.push(await race('migrate-lessons-vs-save', {
|
|
177
|
+
stallTarget: 'lessons.json',
|
|
178
|
+
seed: d => {
|
|
179
|
+
run(d, ['lesson', 'save', 'legacy-lesson']);
|
|
180
|
+
fs.unlinkSync(path.join(d, '.leerness', 'lessons.json'));
|
|
181
|
+
},
|
|
182
|
+
first: ['migrate', 'apply', '--yes'],
|
|
183
|
+
second: ['lesson', 'save', 'survivor-lesson'],
|
|
184
|
+
inspect: d => {
|
|
185
|
+
const texts = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'lessons.json'), 'utf8')).map(item => item.text);
|
|
186
|
+
return { texts, preserved: texts.includes('legacy-lesson') && texts.includes('survivor-lesson') };
|
|
187
|
+
}
|
|
188
|
+
}));
|
|
189
|
+
results.push(await race('rule-cache-verify-vs-verify', {
|
|
190
|
+
stallTarget: 'cache/rule-state.json',
|
|
191
|
+
seed: d => {
|
|
192
|
+
const pkg = version => fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'probe', version }));
|
|
193
|
+
pkg('1.0.0');
|
|
194
|
+
run(d, ['rule', 'add', 'version bump required', '--trigger', 'every-update']);
|
|
195
|
+
run(d, ['rule', 'verify']);
|
|
196
|
+
pkg('1.0.1');
|
|
197
|
+
},
|
|
198
|
+
beforeSecond: d => fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.2' })),
|
|
199
|
+
first: ['rule', 'verify'],
|
|
200
|
+
second: ['rule', 'verify'],
|
|
201
|
+
inspect: d => {
|
|
202
|
+
const cached = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'cache', 'rule-state.json'), 'utf8'));
|
|
203
|
+
return { cachedVersion: cached.packageVersion, preserved: cached.packageVersion === '1.0.2' };
|
|
204
|
+
}
|
|
205
|
+
}));
|
|
135
206
|
const ok = results.every(result => result.ok);
|
|
136
207
|
console.log(JSON.stringify({ ok, results }));
|
|
137
208
|
if (!ok) process.exitCode = 1;
|
package/scripts/e2e.js
CHANGED
|
@@ -10688,7 +10688,10 @@ total++;
|
|
|
10688
10688
|
if (syncUrl) CASES.push(['provider sync', ['provider', 'sync', syncUrl]]);
|
|
10689
10689
|
dbg.syncCovered = !!syncUrl;
|
|
10690
10690
|
const offenders = [], failedCmds = [];
|
|
10691
|
-
|
|
10691
|
+
// 파일 → 모든 쓰기에서 공통으로 쥔 락 키의 교집합. 보조 락이 더해져도 canonical 락이
|
|
10692
|
+
// 공통이면 상호배제는 유지된다(예: progress → plan 순서의 2파일 transaction).
|
|
10693
|
+
const commonKeysByFile = {};
|
|
10694
|
+
const keySetsByFile = {};
|
|
10692
10695
|
let lockedSeen = 0, rows = 0;
|
|
10693
10696
|
for (const [label, argv, seed] of CASES) {
|
|
10694
10697
|
const d = path.join(sb, label.replace(/[^a-z0-9]+/gi, '_'));
|
|
@@ -10713,8 +10716,10 @@ total++;
|
|
|
10713
10716
|
byFile[l.file] = (l.file in byFile ? byFile[l.file] : true) && l.locked;
|
|
10714
10717
|
// 어느 락 키로 지켰는지 모은다 — 파일 자신의 락이 아니면 다른 명령과 서로 배제되지 않는다.
|
|
10715
10718
|
if (l.locked && !DERIVED.test(l.file) && !EXEMPT[l.file]) {
|
|
10716
|
-
|
|
10717
|
-
|
|
10719
|
+
const held = new Set(l.heldOn || []);
|
|
10720
|
+
(keySetsByFile[l.file] = keySetsByFile[l.file] || new Set()).add([...held].sort().join(' + ') || '(none)');
|
|
10721
|
+
if (!commonKeysByFile[l.file]) commonKeysByFile[l.file] = held;
|
|
10722
|
+
else commonKeysByFile[l.file] = new Set([...commonKeysByFile[l.file]].filter(k => held.has(k)));
|
|
10718
10723
|
}
|
|
10719
10724
|
}
|
|
10720
10725
|
for (const [f, lockedOk] of Object.entries(byFile)) {
|
|
@@ -10728,11 +10733,12 @@ total++;
|
|
|
10728
10733
|
// codex 검수 P2(재현됨): "락을 하나라도 쥐었나" 는 너무 약하다. 상호배제는 **같은 키**를 쥘 때만 성립한다.
|
|
10729
10734
|
// session close 는 session-handoff.md.lock 을 쥔 채 current-state.md 를 썼고 audit --fix 는
|
|
10730
10735
|
// current-state.md.lock 을 쥐었다 — 둘 다 '락 보유' 인 채로 audit 의 갱신이 덮였다.
|
|
10731
|
-
// 올바른 불변식은 "자기 이름의 락" 이 아니라 **"그 파일의 모든 쓰기가
|
|
10732
|
-
// (decisions.md 를 decisions.json.lock 아래에서 쓰는 것은
|
|
10736
|
+
// 올바른 불변식은 "자기 이름의 락" 이 아니라 **"그 파일의 모든 쓰기가 적어도 한 canonical 키를 공유한다"** 이다.
|
|
10737
|
+
// (decisions.md 를 decisions.json.lock 아래에서 쓰는 것은 정상이고, progress→plan transaction처럼
|
|
10738
|
+
// 보조 락이 더해진 쓰기도 공통 plan.md.lock/progress-tracker.md.lock가 남으면 정상이다.)
|
|
10733
10739
|
const wrongKey = [];
|
|
10734
|
-
for (const [f,
|
|
10735
|
-
if (
|
|
10740
|
+
for (const [f, common] of Object.entries(commonKeysByFile)) {
|
|
10741
|
+
if (common.size === 0) wrongKey.push(f + ' ← 공통 락 없음 (' + [...keySetsByFile[f]].sort().join(' / ') + ')');
|
|
10736
10742
|
}
|
|
10737
10743
|
dbg.wrongKey = wrongKey;
|
|
10738
10744
|
dbg.offenders = offenders;
|
|
@@ -10746,7 +10752,7 @@ total++;
|
|
|
10746
10752
|
ok = dbg.probeAlive && dbg.coverage && dbg.syncCovered && offenders.length === 0 && wrongKey.length === 0;
|
|
10747
10753
|
try { srv.kill(); } catch {}
|
|
10748
10754
|
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
10749
|
-
console.log(ok ? `✓ L(1.36.108/T-0097) 사용자 상태 쓰기 ${dbg.rows}건 전부 락 안 +
|
|
10755
|
+
console.log(ok ? `✓ L(1.36.108/T-0097, 1.36.169/T-0108) 사용자 상태 쓰기 ${dbg.rows}건 전부 락 안 + 파일별 canonical 락 교집합 존재 (락 관측 ${dbg.lockedSeen}건 = 계측 살아있음 · 명령 실패 0)`
|
|
10750
10756
|
: '✗ 1.36.108 락 불변식 위반 ' + JSON.stringify(dbg));
|
|
10751
10757
|
if (!ok) failed++;
|
|
10752
10758
|
}
|
|
@@ -15527,8 +15533,8 @@ total++;
|
|
|
15527
15533
|
}
|
|
15528
15534
|
|
|
15529
15535
|
total++;
|
|
15530
|
-
// AO(1.36.152/T-0115) — write 시점의 락 관측만으로는 "읽은 사본도 락 안인가"를 증명할 수 없다.
|
|
15531
|
-
// 첫 프로세스의 최종 rename을 강제로 멈추고 두 번째 쓰기를 붙여, 서로 다른 RMW
|
|
15536
|
+
// AO(1.36.152/T-0115, 1.36.169/T-0108 확장) — write 시점의 락 관측만으로는 "읽은 사본도 락 안인가"를 증명할 수 없다.
|
|
15537
|
+
// 첫 프로세스의 최종 rename을 강제로 멈추고 두 번째 쓰기를 붙여, 서로 다른 RMW 11쌍이 모두 생존하는지 확인한다.
|
|
15532
15538
|
{
|
|
15533
15539
|
let ok = false; const dbg = {};
|
|
15534
15540
|
try {
|
|
@@ -15543,9 +15549,9 @@ total++;
|
|
|
15543
15549
|
dbg.exit = race.status; dbg.report = report;
|
|
15544
15550
|
if (!report) dbg.stderr = String(race.stderr || '').slice(0, 500);
|
|
15545
15551
|
ok = race.status === 0 && !!report && report.ok === true
|
|
15546
|
-
&& Array.isArray(report.results) && report.results.length ===
|
|
15552
|
+
&& Array.isArray(report.results) && report.results.length === 11 && report.results.every(r => r.ok === true);
|
|
15547
15553
|
} catch (e) { dbg.err = String(e && e.message || e).slice(0, 200); }
|
|
15548
|
-
console.log(ok ? '✓ AO(1.36.152/T-0115) 지연 경쟁
|
|
15554
|
+
console.log(ok ? '✓ AO(1.36.152/T-0115, 1.36.169/T-0108) 지연 경쟁 11쌍 모두 exit 0 + 양쪽 기록 보존'
|
|
15549
15555
|
: '✗ 1.36.152 동시 RMW 회귀 ' + JSON.stringify(dbg));
|
|
15550
15556
|
if (!ok) failed++;
|
|
15551
15557
|
}
|