leerness 1.36.166 → 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 +8 -0
- package/README.md +15 -5
- package/bin/leerness.js +154 -23
- package/lib/claims-baseline.js +272 -0
- package/lib/diagnostics.js +196 -41
- package/lib/mcp-tools.js +1 -1
- package/package.json +5 -5
- package/scripts/claims-baseline-concurrency-probe.js +130 -0
- package/scripts/claims-baseline-probe.js +263 -0
- package/scripts/command-flags-probe.js +20 -0
- package/scripts/release-runtime-probe.js +5 -2
- package/scripts/which-shim-probe.js +208 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// lib/claims-baseline.js — explicit, fingerprint-bound legacy claim debt.
|
|
2
|
+
//
|
|
3
|
+
// A baseline never edits historical evidence and never makes a new failure pass.
|
|
4
|
+
// It can acknowledge only failures that existed before an explicit tracker-row
|
|
5
|
+
// boundary, and only while the complete row plus the verifier reasons remain
|
|
6
|
+
// byte-for-byte equivalent after canonical JSON normalization.
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { absRoot, exists, read, mkdirp, writeUtf8 } = require('./io');
|
|
13
|
+
|
|
14
|
+
const SCHEMA_VERSION = 1;
|
|
15
|
+
const KIND = 'leerness-claims-baseline';
|
|
16
|
+
const POLICY = 'exact-progress-row-and-reasons';
|
|
17
|
+
const FILE_NAME = 'claims-baseline.json';
|
|
18
|
+
|
|
19
|
+
function baselinePath(root) {
|
|
20
|
+
return path.join(absRoot(root), '.leerness', FILE_NAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function _canonical(value) {
|
|
24
|
+
if (Array.isArray(value)) return '[' + value.map(_canonical).join(',') + ']';
|
|
25
|
+
if (value && typeof value === 'object') {
|
|
26
|
+
return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + _canonical(value[k])).join(',') + '}';
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _sha256(value) {
|
|
32
|
+
return crypto.createHash('sha256').update(_canonical(value)).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _normalizedRow(row) {
|
|
36
|
+
return {
|
|
37
|
+
id: String(row && row.id || ''),
|
|
38
|
+
status: String(row && row.status || ''),
|
|
39
|
+
request: String(row && row.request || ''),
|
|
40
|
+
evidence: String(row && row.evidence || ''),
|
|
41
|
+
nextAction: String(row && row.nextAction || ''),
|
|
42
|
+
updated: String(row && row.updated || ''),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeReasons(reasons) {
|
|
47
|
+
return Array.from(new Set((Array.isArray(reasons) ? reasons : [])
|
|
48
|
+
.map(x => String(x || '').trim()).filter(Boolean))).sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function claimFingerprint(row, reasons) {
|
|
52
|
+
return _sha256({ row: _normalizedRow(row), reasons: normalizeReasons(reasons) });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _withoutIntegrity(doc) {
|
|
56
|
+
const out = { ...doc };
|
|
57
|
+
delete out.integrity;
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _integrity(doc) {
|
|
62
|
+
return 'sha256:' + _sha256(_withoutIntegrity(doc));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateBaseline(doc) {
|
|
66
|
+
const problems = [];
|
|
67
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, problems: ['root-not-object'] };
|
|
68
|
+
if (doc.schemaVersion !== SCHEMA_VERSION) problems.push('unsupported-schema');
|
|
69
|
+
if (doc.kind !== KIND) problems.push('wrong-kind');
|
|
70
|
+
if (doc.policy !== POLICY) problems.push('wrong-policy');
|
|
71
|
+
if (typeof doc.createdAt !== 'string' || Number.isNaN(Date.parse(doc.createdAt))) problems.push('invalid-created-at');
|
|
72
|
+
if (typeof doc.createdByVersion !== 'string' || !doc.createdByVersion.trim()) problems.push('invalid-created-by-version');
|
|
73
|
+
if (typeof doc.beforeTaskId !== 'string' || !/^[A-Z]+-\d{3,}$/.test(doc.beforeTaskId)) problems.push('invalid-before-task');
|
|
74
|
+
if (!Number.isInteger(doc.evaluatedBefore) || doc.evaluatedBefore < 0) problems.push('invalid-evaluated-before');
|
|
75
|
+
if (!Number.isInteger(doc.rawFailedBefore) || doc.rawFailedBefore < 0) problems.push('invalid-raw-failed-before');
|
|
76
|
+
if (!Array.isArray(doc.entries)) problems.push('entries-not-array');
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
for (const entry of Array.isArray(doc.entries) ? doc.entries : []) {
|
|
79
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { problems.push('invalid-entry'); continue; }
|
|
80
|
+
if (typeof entry.id !== 'string' || !/^[A-Z]+-\d{3,}$/.test(entry.id)) problems.push('invalid-entry-id');
|
|
81
|
+
else if (ids.has(entry.id)) problems.push('duplicate-entry-id');
|
|
82
|
+
else ids.add(entry.id);
|
|
83
|
+
if (!/^[a-f0-9]{64}$/.test(String(entry.fingerprint || ''))) problems.push('invalid-entry-fingerprint');
|
|
84
|
+
const reasons = normalizeReasons(entry.reasons);
|
|
85
|
+
const reasonsAreCanonical = Array.isArray(entry.reasons)
|
|
86
|
+
&& entry.reasons.every(reason => typeof reason === 'string' && reason.trim())
|
|
87
|
+
&& JSON.stringify(entry.reasons) === JSON.stringify(reasons);
|
|
88
|
+
if (!reasons.length || !reasonsAreCanonical) problems.push('invalid-entry-reasons');
|
|
89
|
+
}
|
|
90
|
+
if (doc.rawFailedBefore !== (Array.isArray(doc.entries) ? doc.entries.length : -1)) problems.push('failed-count-mismatch');
|
|
91
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(String(doc.integrity || ''))) problems.push('invalid-integrity-format');
|
|
92
|
+
else if (doc.integrity !== _integrity(doc)) problems.push('integrity-mismatch');
|
|
93
|
+
return { ok: problems.length === 0, problems: Array.from(new Set(problems)) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function loadBaseline(root) {
|
|
97
|
+
const file = baselinePath(root);
|
|
98
|
+
if (!exists(file)) return { state: 'absent', file, doc: null, problems: [] };
|
|
99
|
+
let doc;
|
|
100
|
+
try { doc = JSON.parse(read(file)); }
|
|
101
|
+
catch (error) { return { state: 'invalid', file, doc: null, problems: ['invalid-json'], error: error.message }; }
|
|
102
|
+
const checked = validateBaseline(doc);
|
|
103
|
+
if (!checked.ok) return { state: 'invalid', file, doc, problems: checked.problems };
|
|
104
|
+
return { state: 'valid', file, doc, problems: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildBaseline(rows, rawResult, options = {}) {
|
|
108
|
+
const beforeTaskId = String(options.beforeTaskId || '');
|
|
109
|
+
const boundaryIndex = rows.findIndex(row => row.id === beforeTaskId);
|
|
110
|
+
if (boundaryIndex < 0) {
|
|
111
|
+
const error = new Error(`progress-tracker boundary not found: ${beforeTaskId}`);
|
|
112
|
+
error.code = 'E_CLAIMS_BASELINE_BOUNDARY';
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
const eligibleRows = rows.slice(0, boundaryIndex).filter(row => /done|완료|completed/i.test(String(row.status || '')));
|
|
116
|
+
const eligible = new Map(eligibleRows.map(row => [row.id, row]));
|
|
117
|
+
const failures = (rawResult.results || []).filter(result => result && !result.ok && eligible.has(result.id));
|
|
118
|
+
const entries = failures.map(result => ({
|
|
119
|
+
id: result.id,
|
|
120
|
+
fingerprint: claimFingerprint(eligible.get(result.id), result.reasons),
|
|
121
|
+
reasons: normalizeReasons(result.reasons),
|
|
122
|
+
}));
|
|
123
|
+
const doc = {
|
|
124
|
+
schemaVersion: SCHEMA_VERSION,
|
|
125
|
+
kind: KIND,
|
|
126
|
+
createdAt: String(options.createdAt || new Date().toISOString()),
|
|
127
|
+
createdByVersion: String(options.version || 'unknown'),
|
|
128
|
+
beforeTaskId,
|
|
129
|
+
policy: POLICY,
|
|
130
|
+
evaluatedBefore: eligibleRows.length,
|
|
131
|
+
rawFailedBefore: entries.length,
|
|
132
|
+
entries,
|
|
133
|
+
};
|
|
134
|
+
doc.integrity = _integrity(doc);
|
|
135
|
+
return {
|
|
136
|
+
doc,
|
|
137
|
+
stats: {
|
|
138
|
+
evaluatedBefore: eligibleRows.length,
|
|
139
|
+
baselined: entries.length,
|
|
140
|
+
failuresAtOrAfterBoundary: (rawResult.results || []).filter(result => result && !result.ok && !eligible.has(result.id)).length,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function saveBaseline(root, doc, withLock) {
|
|
146
|
+
const checked = validateBaseline(doc);
|
|
147
|
+
if (!checked.ok) {
|
|
148
|
+
const error = new Error(`refusing to write invalid claims baseline: ${checked.problems.join(', ')}`);
|
|
149
|
+
error.code = 'E_CLAIMS_BASELINE_INVALID';
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
const file = baselinePath(root);
|
|
153
|
+
const write = () => {
|
|
154
|
+
mkdirp(path.dirname(file));
|
|
155
|
+
if (exists(file)) {
|
|
156
|
+
const error = new Error(`claims baseline already exists and will not be overwritten: ${file}`);
|
|
157
|
+
error.code = 'E_CLAIMS_BASELINE_EXISTS';
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
const temp = file + `.tmp-${process.pid}-${Date.now()}`;
|
|
161
|
+
writeUtf8(temp, JSON.stringify(doc, null, 2) + '\n');
|
|
162
|
+
try { fs.renameSync(temp, file); }
|
|
163
|
+
catch (error) {
|
|
164
|
+
try { fs.rmSync(temp, { force: true }); } catch {}
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
if (typeof withLock === 'function') withLock(file, write);
|
|
169
|
+
else write();
|
|
170
|
+
return file;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _rawResult(rawResult, state, extra = {}) {
|
|
174
|
+
return {
|
|
175
|
+
...rawResult,
|
|
176
|
+
rawFailed: rawResult.failed,
|
|
177
|
+
baselined: 0,
|
|
178
|
+
baseline: { state, ...extra },
|
|
179
|
+
errors: [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function applyBaseline(rows, rawResult, loaded, options = {}) {
|
|
184
|
+
if (options.raw) return _rawResult(rawResult, 'ignored');
|
|
185
|
+
if (!loaded || loaded.state === 'absent') return _rawResult(rawResult, 'absent', { file: loaded && loaded.file });
|
|
186
|
+
if (loaded.state !== 'valid') {
|
|
187
|
+
return {
|
|
188
|
+
..._rawResult(rawResult, 'invalid', { file: loaded.file, problems: loaded.problems || [] }),
|
|
189
|
+
ok: false,
|
|
190
|
+
errors: ['baseline-invalid'],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const doc = loaded.doc;
|
|
195
|
+
const duplicateIds = rows.map(row => row.id).filter((id, index, all) => all.indexOf(id) !== index);
|
|
196
|
+
const boundaryIndex = rows.findIndex(row => row.id === doc.beforeTaskId);
|
|
197
|
+
const rowIndex = new Map(rows.map((row, index) => [row.id, index]));
|
|
198
|
+
const outOfScope = doc.entries.filter(entry => !rowIndex.has(entry.id) || rowIndex.get(entry.id) >= boundaryIndex).map(entry => entry.id);
|
|
199
|
+
if (boundaryIndex < 0 || duplicateIds.length || outOfScope.length) {
|
|
200
|
+
const problems = [
|
|
201
|
+
...(boundaryIndex < 0 ? ['boundary-missing'] : []),
|
|
202
|
+
...(duplicateIds.length ? ['duplicate-progress-id'] : []),
|
|
203
|
+
...(outOfScope.length ? ['entry-outside-boundary'] : []),
|
|
204
|
+
];
|
|
205
|
+
return {
|
|
206
|
+
..._rawResult(rawResult, 'invalid-context', { file: loaded.file, problems, outOfScope: outOfScope.slice(0, 20) }),
|
|
207
|
+
ok: false,
|
|
208
|
+
errors: ['baseline-invalid-context'],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const rowsById = new Map(rows.map(row => [row.id, row]));
|
|
213
|
+
const entriesById = new Map(doc.entries.map(entry => [entry.id, entry]));
|
|
214
|
+
let accepted = 0;
|
|
215
|
+
const mismatched = [];
|
|
216
|
+
const newFailures = [];
|
|
217
|
+
const acceptedIds = new Set();
|
|
218
|
+
const results = (rawResult.results || []).map(result => {
|
|
219
|
+
if (!result || result.ok) return result;
|
|
220
|
+
const entry = entriesById.get(result.id);
|
|
221
|
+
if (!entry) { newFailures.push(result.id); return result; }
|
|
222
|
+
const fingerprint = claimFingerprint(rowsById.get(result.id), result.reasons);
|
|
223
|
+
if (fingerprint !== entry.fingerprint) {
|
|
224
|
+
mismatched.push(result.id);
|
|
225
|
+
return { ...result, baselineAccepted: false, baselineMismatch: true };
|
|
226
|
+
}
|
|
227
|
+
accepted++;
|
|
228
|
+
acceptedIds.add(result.id);
|
|
229
|
+
return {
|
|
230
|
+
...result,
|
|
231
|
+
ok: true,
|
|
232
|
+
rawOk: false,
|
|
233
|
+
reasons: [],
|
|
234
|
+
baselineAccepted: true,
|
|
235
|
+
baselineReasons: normalizeReasons(result.reasons),
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
const failed = results.filter(result => result && !result.ok).length;
|
|
239
|
+
const inactiveEntries = doc.entries.filter(entry => !acceptedIds.has(entry.id) && !mismatched.includes(entry.id)).map(entry => entry.id);
|
|
240
|
+
return {
|
|
241
|
+
ok: failed === 0,
|
|
242
|
+
total: rawResult.total,
|
|
243
|
+
failed,
|
|
244
|
+
rawFailed: rawResult.failed,
|
|
245
|
+
baselined: accepted,
|
|
246
|
+
results,
|
|
247
|
+
baseline: {
|
|
248
|
+
state: 'applied',
|
|
249
|
+
file: loaded.file,
|
|
250
|
+
beforeTaskId: doc.beforeTaskId,
|
|
251
|
+
entries: doc.entries.length,
|
|
252
|
+
accepted,
|
|
253
|
+
mismatched: mismatched.slice(0, 20),
|
|
254
|
+
newFailures: newFailures.slice(0, 20),
|
|
255
|
+
inactiveEntries: inactiveEntries.slice(0, 20),
|
|
256
|
+
},
|
|
257
|
+
errors: [],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
SCHEMA_VERSION,
|
|
263
|
+
FILE_NAME,
|
|
264
|
+
baselinePath,
|
|
265
|
+
normalizeReasons,
|
|
266
|
+
claimFingerprint,
|
|
267
|
+
validateBaseline,
|
|
268
|
+
loadBaseline,
|
|
269
|
+
buildBaseline,
|
|
270
|
+
saveBaseline,
|
|
271
|
+
applyBaseline,
|
|
272
|
+
};
|
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/mcp-tools.js
CHANGED
|
@@ -8,7 +8,7 @@ module.exports = [
|
|
|
8
8
|
{ name: 'leerness_drift_check', requiredTier: 'read-only', description: '1.9.136 — AI 에이전트 leerness 미사용 drift 자동 감지 JSON ({ root, score, level, signals[], healthy }). 5+ 신호 + 4단계 레벨 (🟢 healthy / 🟡 warning / 🟠 caution / 🔴 critical). 보안 신호 통합 (1.9.78)', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
9
9
|
{ name: 'leerness_audit', requiredTier: 'safe-write', description: '1.9.102 — 워크스페이스 일관성 감사 JSON (warnings/failures/fixed/healthy + findings[]. kind 11종: design_dup/design_system_default/reuse_map_empty/milestone_unlinked/handoff_not_generated/current_state_stale/readme_version_mismatch/npm_cve/gitignore_missing_secrets/env_keys_missing/strict_promoted)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, fix: { type: 'boolean' }, strict: { type: 'boolean' } } } },
|
|
10
10
|
{ name: 'leerness_verify_claim', requiredTier: 'read-only', description: 'AI 거짓 완료 자동 검증 (evidence 파일 + 실 테스트 실행). 1.9.309(UR-0048): done/완료 주장은 evidence(수정파일+테스트) 기본 강제 — 증거 없는 done 은 FAIL(exit 1). lenient=true 로 완화, runTests/strictClaims 추가 가능. 응답 verdict.evidenceComplete 포함.', inputSchema: { type: 'object', properties: { taskId: { type: 'string' }, path: { type: 'string' }, runTests: { type: 'boolean' }, strictClaims: { type: 'boolean' }, lenient: { type: 'boolean' }, refereeId: { type: 'string' } }, required: ['taskId'] } },
|
|
11
|
-
{ name: 'leerness_verify_claim_all', requiredTier: 'read-only', description: '1.33.3 — 모든 done/완료 주장을 한 번에 검증(CI·스케일).
|
|
11
|
+
{ name: 'leerness_verify_claim_all', requiredTier: 'read-only', description: '1.33.3/1.36.167 — 모든 done/완료 주장을 한 번에 검증(CI·스케일). 명시적 claims baseline이 있으면 exact 행+실패사유 지문이 같은 경계 이전 레거시 부채만 격리하고 새/변경 실패는 계속 차단한다. 응답 { ok, total, failed, rawFailed, baselined, baseline, results }. raw=true면 baseline을 무시하고 원판정을 감사한다.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, runTests: { type: 'boolean' }, strictClaims: { type: 'boolean' }, lenient: { type: 'boolean' }, raw: { type: 'boolean' } } } },
|
|
12
12
|
{ name: 'leerness_contract_verify', requiredTier: 'read-only', description: '명세 ↔ 구현 함수/필드 일치 자동 검사', inputSchema: { type: 'object', properties: { spec: { type: 'string' }, impl: { type: 'string' } }, required: ['spec', 'impl'] } },
|
|
13
13
|
{ name: 'leerness_agents_list', requiredTier: 'read-only', description: '외부 AI CLI 가용성 표 (claude/codex/agy/copilot 상태 + 환경변수 활성화 여부)', inputSchema: { type: 'object', properties: {} } },
|
|
14
14
|
{ name: 'leerness_reuse_map', requiredTier: 'read-only', description: '워크스페이스 중복 함수/capability 자동 감지 (--all-apps + fuzzy 매칭)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, allApps: { type: 'boolean' }, strictElements: { type: 'boolean' } } } },
|
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",
|
|
53
|
-
"test:commands": "node ./scripts/command-flags-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/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"
|