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.
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0145 multi-session regression: two first-time creators may both observe
5
+ // "absent", but the create-only check inside the file lock must let exactly
6
+ // one policy win and must never overwrite that winner.
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+
13
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
14
+ const env = {
15
+ ...process.env,
16
+ LEERNESS_OFFLINE: '1',
17
+ LEERNESS_NO_PROMPT: '1',
18
+ LEERNESS_NO_AUTOCHCP: '1',
19
+ LEERNESS_NO_AUTO_ROADMAP: '1',
20
+ LEERNESS_NO_STALE_CHECK: '1',
21
+ LEERNESS_INTERNAL: '1',
22
+ };
23
+
24
+ function run(root, args, timeout = 90000) {
25
+ return cp.spawnSync(process.execPath, [CLI, ...args, '--path', root], {
26
+ cwd: root,
27
+ encoding: 'utf8',
28
+ timeout,
29
+ env,
30
+ });
31
+ }
32
+
33
+ function spawnRun(root, args) {
34
+ return new Promise(resolve => {
35
+ const child = cp.spawn(process.execPath, [CLI, ...args, '--path', root], {
36
+ cwd: root,
37
+ env,
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ });
40
+ let stdout = '';
41
+ let stderr = '';
42
+ child.stdout.setEncoding('utf8');
43
+ child.stderr.setEncoding('utf8');
44
+ child.stdout.on('data', chunk => { stdout += chunk; });
45
+ child.stderr.on('data', chunk => { stderr += chunk; });
46
+ child.on('error', error => resolve({ status: null, stdout, stderr, error }));
47
+ child.on('close', status => resolve({ status, stdout, stderr }));
48
+ });
49
+ }
50
+
51
+ function json(result) {
52
+ try { return JSON.parse(result.stdout || ''); } catch { return null; }
53
+ }
54
+
55
+ function digest(file) {
56
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
57
+ }
58
+
59
+ const failures = [];
60
+ function check(label, condition, result) {
61
+ if (condition) { console.log(`✓ ${label}`); return; }
62
+ failures.push(label);
63
+ const detail = result ? `\n ${JSON.stringify(result).slice(0, 1000)}` : '';
64
+ console.log(`✗ ${label}${detail}`);
65
+ }
66
+
67
+ async function main() {
68
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claims-baseline-race-'));
69
+ try {
70
+ let result = cp.spawnSync(process.execPath, [CLI, 'init', root, '--yes', '--no-enforce', '--no-stale-check'], {
71
+ cwd: root, encoding: 'utf8', timeout: 90000, env,
72
+ });
73
+ check('concurrency fixture init succeeds', result.status === 0, result);
74
+
75
+ result = run(root, ['task', 'add', 'legacy one']);
76
+ const legacyOne = (result.stdout.match(/T-\d{4,}/) || [])[0];
77
+ result = run(root, ['task', 'update', legacyOne, '--status', 'done', '--evidence', 'ghost-one.js implemented and tested', '--next', 'legacy']);
78
+ check('first legacy failure fixture is ready', result.status === 0 && !!legacyOne, result);
79
+
80
+ result = run(root, ['task', 'add', 'first boundary']);
81
+ const boundaryOne = (result.stdout.match(/T-\d{4,}/) || [])[0];
82
+ result = run(root, ['task', 'add', 'legacy two']);
83
+ const legacyTwo = (result.stdout.match(/T-\d{4,}/) || [])[0];
84
+ result = run(root, ['task', 'update', legacyTwo, '--status', 'done', '--evidence', 'ghost-two.js implemented and tested', '--next', 'legacy']);
85
+ check('second legacy failure fixture is ready', result.status === 0 && !!legacyTwo, result);
86
+
87
+ result = run(root, ['task', 'add', 'second boundary']);
88
+ const boundaryTwo = (result.stdout.match(/T-\d{4,}/) || [])[0];
89
+ check('two distinct policy boundaries are ready', result.status === 0 && !!boundaryOne && !!boundaryTwo, result);
90
+
91
+ const contenders = await Promise.all([
92
+ spawnRun(root, ['verify-claim', 'baseline', 'create', '--before', boundaryOne, '--yes', '--json']),
93
+ spawnRun(root, ['verify-claim', 'baseline', 'create', '--before', boundaryTwo, '--yes', '--json']),
94
+ ]);
95
+ const winners = contenders.filter(item => item.status === 0 && json(item)?.ok === true);
96
+ const losers = contenders.filter(item => item.status === 1 && json(item)?.code === 'baseline_exists');
97
+ check('two concurrent creators produce exactly one winner and one baseline_exists loser',
98
+ winners.length === 1 && losers.length === 1,
99
+ contenders);
100
+
101
+ const baselineFile = path.join(root, '.leerness', 'claims-baseline.json');
102
+ const stored = fs.existsSync(baselineFile) ? JSON.parse(fs.readFileSync(baselineFile, 'utf8')) : null;
103
+ const winner = json(winners[0] || {});
104
+ check('stored policy is exactly the reported winner, never a mixed or overwritten document',
105
+ stored && winner && stored.beforeTaskId === winner.beforeTaskId
106
+ && stored.integrity === winner.integrity && stored.entries.length === winner.baselined,
107
+ { stored, winner });
108
+
109
+ const winnerHash = fs.existsSync(baselineFile) ? digest(baselineFile) : null;
110
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryTwo, '--yes', '--json']);
111
+ check('later retries remain create-only and preserve the winning bytes',
112
+ result.status === 1 && json(result)?.code === 'baseline_exists'
113
+ && winnerHash && digest(baselineFile) === winnerHash,
114
+ result);
115
+ } finally {
116
+ fs.rmSync(root, { recursive: true, force: true });
117
+ }
118
+
119
+ if (failures.length) {
120
+ console.log(`CLAIMS_BASELINE_CONCURRENCY_PROBE_FAILED: ${failures.join('; ')}`);
121
+ process.exitCode = 1;
122
+ } else {
123
+ console.log('claims baseline concurrency probe passed');
124
+ }
125
+ }
126
+
127
+ main().catch(error => {
128
+ console.error(error && error.stack || error);
129
+ process.exitCode = 1;
130
+ });
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0145 regression probe: a reviewed legacy debt boundary unblocks only exact
5
+ // historical failures. Raw/per-task audits, changed rows, new failures, invalid
6
+ // input, and corrupt stores must all remain fail-closed.
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+ const claimsBaseline = require('../lib/claims-baseline');
13
+
14
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
15
+ const env = {
16
+ ...process.env,
17
+ LEERNESS_OFFLINE: '1',
18
+ LEERNESS_NO_PROMPT: '1',
19
+ LEERNESS_NO_AUTOCHCP: '1',
20
+ LEERNESS_NO_AUTO_ROADMAP: '1',
21
+ LEERNESS_NO_STALE_CHECK: '1',
22
+ LEERNESS_INTERNAL: '1',
23
+ };
24
+
25
+ function run(root, args, timeout = 60000) {
26
+ return cp.spawnSync(process.execPath, [CLI, ...args, '--path', root], {
27
+ cwd: root,
28
+ encoding: 'utf8',
29
+ timeout,
30
+ env,
31
+ });
32
+ }
33
+
34
+ function json(result) {
35
+ try { return JSON.parse(result.stdout || ''); } catch { return null; }
36
+ }
37
+
38
+ function digest(file) {
39
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
40
+ }
41
+
42
+ function canonical(value) {
43
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
44
+ if (value && typeof value === 'object') {
45
+ return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
46
+ }
47
+ return JSON.stringify(value);
48
+ }
49
+
50
+ function withIntegrity(doc) {
51
+ const unsigned = { ...doc };
52
+ delete unsigned.integrity;
53
+ return {
54
+ ...unsigned,
55
+ integrity: 'sha256:' + crypto.createHash('sha256').update(canonical(unsigned)).digest('hex'),
56
+ };
57
+ }
58
+
59
+ const failures = [];
60
+ function check(label, condition, result) {
61
+ if (condition) { console.log(`✓ ${label}`); return; }
62
+ failures.push(label);
63
+ const detail = result ? `\n exit=${result.status}\n stdout=${String(result.stdout || '').slice(0, 800)}\n stderr=${String(result.stderr || '').slice(0, 400)}` : '';
64
+ console.log(`✗ ${label}${detail}`);
65
+ }
66
+
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claims-baseline-'));
68
+ try {
69
+ let result = cp.spawnSync(process.execPath, [CLI, 'init', root, '--yes', '--no-enforce', '--no-stale-check'], {
70
+ cwd: root, encoding: 'utf8', timeout: 90000, env,
71
+ });
72
+ check('fixture init succeeds', result.status === 0, result);
73
+
74
+ fs.writeFileSync(path.join(root, 'calc.js'), 'function add(a,b){return a+b}\nmodule.exports={add}\n', 'utf8');
75
+ fs.mkdirSync(path.join(root, 'tests'), { recursive: true });
76
+ fs.writeFileSync(path.join(root, 'tests', 'calc.test.js'), 'const {add}=require("../calc.js");\ntest("a",()=>{if(add(1,2)!==3)throw 0});\n', 'utf8');
77
+
78
+ result = run(root, ['task', 'add', 'legacy calc claim']);
79
+ const legacyId = (result.stdout.match(/T-\d{4,}/) || [])[0];
80
+ result = run(root, ['task', 'update', legacyId, '--status', 'done', '--evidence', 'calc.js + tests/calc.test.js 테스트 50개 통과', '--next', 'legacy next']);
81
+ check('legacy false claim fixture is created', result.status === 0 && !!legacyId, result);
82
+ result = run(root, ['task', 'add', 'new work boundary']);
83
+ const boundaryId = (result.stdout.match(/T-\d{4,}/) || [])[0];
84
+ check('explicit non-done boundary fixture is created', result.status === 0 && !!boundaryId, result);
85
+
86
+ // Keep the five heuristic gate checks clean so --claims is the only failing dimension.
87
+ fs.writeFileSync(path.join(root, '.leerness', 'session-handoff.md'), '# Handoff\nLast generated: 2026-08-26T00:00:00Z\n\n## Completed\n- calc.js add() 구현 + 테스트\n\n## Next Exact Step\n- migration\n', 'utf8');
88
+ fs.writeFileSync(path.join(root, '.leerness', 'review-evidence.md'), '# Evidence\n## Test run\n- npm test: 1/1 passing\n', 'utf8');
89
+
90
+ const tracker = path.join(root, '.leerness', 'progress-tracker.md');
91
+ const trackerText = fs.readFileSync(tracker, 'utf8');
92
+ const trackerHash = digest(tracker);
93
+ const baselineFile = path.join(root, '.leerness', 'claims-baseline.json');
94
+
95
+ const beforeGate = run(root, ['gate', '.', '--claims', '--json'], 120000);
96
+ const beforeGateJson = json(beforeGate);
97
+ const beforeClaimCheck = beforeGateJson?.checks?.find(row => row.name === 'verify-claims');
98
+ check('gate --claims reproduces the legacy-only failure before migration',
99
+ beforeGate.status === 1 && beforeClaimCheck?.ok === false && beforeClaimCheck?.rawFailed === 1,
100
+ beforeGate);
101
+
102
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryId, '--json']);
103
+ check('baseline creation requires explicit --yes and writes nothing on refusal',
104
+ result.status === 1 && json(result)?.code === 'confirmation_required' && !fs.existsSync(baselineFile),
105
+ result);
106
+
107
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', 'T-9999', '--yes', '--json']);
108
+ check('unknown boundary fails before writing a baseline',
109
+ result.status === 1 && json(result)?.code === 'boundary_not_found' && !fs.existsSync(baselineFile),
110
+ result);
111
+
112
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryId, '--yes', '--json']);
113
+ const created = json(result);
114
+ check('baseline creation isolates only the failed row before the explicit boundary',
115
+ result.status === 0 && created?.ok === true && created?.baselined === 1
116
+ && created?.beforeTaskId === boundaryId && created?.historicalEvidenceModified === false
117
+ && fs.existsSync(baselineFile),
118
+ result);
119
+ check('baseline creation does not rewrite tracker rows or historical evidence',
120
+ digest(tracker) === trackerHash && fs.readFileSync(tracker, 'utf8') === trackerText);
121
+ const stored = JSON.parse(fs.readFileSync(baselineFile, 'utf8'));
122
+ check('baseline stores fingerprints/reasons, not replacement evidence',
123
+ stored.entries?.length === 1 && stored.entries[0].id === legacyId
124
+ && /^[a-f0-9]{64}$/.test(stored.entries[0].fingerprint)
125
+ && !JSON.stringify(stored).includes('테스트 50개'));
126
+
127
+ const casRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claims-baseline-cas-'));
128
+ try {
129
+ let winnerHash = null;
130
+ let raceCode = null;
131
+ try {
132
+ claimsBaseline.saveBaseline(casRoot, stored, (target, write) => {
133
+ fs.mkdirSync(path.dirname(target), { recursive: true });
134
+ fs.writeFileSync(target, 'concurrent-winner\n', 'utf8');
135
+ winnerHash = digest(target);
136
+ return write();
137
+ });
138
+ } catch (error) { raceCode = error && error.code; }
139
+ const target = claimsBaseline.baselinePath(casRoot);
140
+ check('create-only CAS rechecks destination absence inside the acquired lock',
141
+ raceCode === 'E_CLAIMS_BASELINE_EXISTS' && digest(target) === winnerHash);
142
+ } finally {
143
+ fs.rmSync(casRoot, { recursive: true, force: true });
144
+ }
145
+
146
+ result = run(root, ['verify-claim', '--all', '--json']);
147
+ const applied = json(result);
148
+ check('default aggregate verification applies exact legacy debt and exposes raw counts',
149
+ result.status === 0 && applied?.ok === true && applied?.rawFailed === 1
150
+ && applied?.baselined === 1 && applied?.failed === 0
151
+ && applied?.results?.find(row => row.id === legacyId)?.baselineAccepted === true,
152
+ result);
153
+
154
+ result = run(root, ['verify-claim', '--all', '--raw', '--json']);
155
+ const raw = json(result);
156
+ check('--raw preserves the original failing verdict',
157
+ result.status === 1 && raw?.ok === false && raw?.failed === 1
158
+ && raw?.baselined === 0 && raw?.baseline?.state === 'ignored',
159
+ result);
160
+
161
+ result = run(root, ['verify-claim', legacyId, '--json']);
162
+ check('per-task verification always remains raw',
163
+ result.status === 1 && json(result)?.ok === false && !json(result)?.baselineAccepted,
164
+ result);
165
+
166
+ const afterGate = run(root, ['gate', '.', '--claims', '--json'], 120000);
167
+ const afterGateJson = json(afterGate);
168
+ const afterClaimCheck = afterGateJson?.checks?.find(row => row.name === 'verify-claims');
169
+ check('gate --claims is unblocked only through visible legacy-debt accounting',
170
+ afterGate.status === 0 && afterGateJson?.ok === true && afterClaimCheck?.ok === true
171
+ && afterClaimCheck?.rawFailed === 1 && afterClaimCheck?.baselined === 1 && afterClaimCheck?.failed === 0,
172
+ afterGate);
173
+
174
+ result = run(root, ['verify-claim', 'baseline', 'show', '--json']);
175
+ check('baseline show returns the integrity-bound policy document',
176
+ result.status === 0 && json(result)?.state === 'valid'
177
+ && json(result)?.baseline?.beforeTaskId === boundaryId,
178
+ result);
179
+
180
+ const changedTracker = trackerText.replace('| legacy next |', '| changed metadata |');
181
+ fs.writeFileSync(tracker, changedTracker, 'utf8');
182
+ result = run(root, ['verify-claim', '--all', '--json']);
183
+ const changed = json(result);
184
+ check('any historical tracker-row change invalidates that exemption',
185
+ result.status === 1 && changed?.baselined === 0 && changed?.failed === 1
186
+ && changed?.baseline?.mismatched?.includes(legacyId),
187
+ result);
188
+ const baselineHashBeforeRetry = digest(baselineFile);
189
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryId, '--yes', '--json']);
190
+ check('baseline create cannot relaunder a changed historical row by overwriting policy',
191
+ result.status === 1 && json(result)?.code === 'baseline_exists'
192
+ && digest(baselineFile) === baselineHashBeforeRetry,
193
+ result);
194
+ result = run(root, ['verify-claim', '--all', '--json']);
195
+ check('a refused baseline recreation leaves the changed row blocked',
196
+ result.status === 1 && json(result)?.baseline?.mismatched?.includes(legacyId),
197
+ result);
198
+ result = run(root, ['gate', '.', '--claims', '--json'], 120000);
199
+ const changedGateCheck = json(result)?.checks?.find(row => row.name === 'verify-claims');
200
+ check('gate JSON preserves baseline mismatch diagnostics',
201
+ result.status === 1 && changedGateCheck?.baseline?.mismatched?.includes(legacyId),
202
+ result);
203
+ fs.writeFileSync(tracker, trackerText, 'utf8');
204
+
205
+ result = run(root, ['task', 'add', 'new false claim']);
206
+ const newId = (result.stdout.match(/T-\d{4,}/) || [])[0];
207
+ result = run(root, ['task', 'update', newId, '--status', 'done', '--evidence', 'ghost.js implemented and tested', '--next', 'none']);
208
+ check('post-boundary false claim fixture is created', result.status === 0 && !!newId, result);
209
+ result = run(root, ['verify-claim', '--all', '--json']);
210
+ const withNew = json(result);
211
+ check('new failures still block while the unchanged legacy row stays isolated',
212
+ result.status === 1 && withNew?.rawFailed === 2 && withNew?.baselined === 1
213
+ && withNew?.failed === 1 && withNew?.baseline?.newFailures?.includes(newId),
214
+ result);
215
+ result = run(root, ['gate', '.', '--claims', '--json'], 120000);
216
+ const newFailureGateCheck = json(result)?.checks?.find(row => row.name === 'verify-claims');
217
+ check('gate JSON preserves new-failure identifiers',
218
+ result.status === 1 && newFailureGateCheck?.baseline?.newFailures?.includes(newId),
219
+ result);
220
+
221
+ const corrupt = { ...stored, integrity: 'sha256:' + '0'.repeat(64) };
222
+ fs.writeFileSync(baselineFile, JSON.stringify(corrupt, null, 2) + '\n', 'utf8');
223
+ result = run(root, ['verify-claim', '--all', '--json']);
224
+ check('corrupt baseline fails closed with a distinct machine-readable error',
225
+ result.status === 1 && json(result)?.ok === false
226
+ && json(result)?.errors?.includes('baseline-invalid')
227
+ && json(result)?.baseline?.problems?.includes('integrity-mismatch'),
228
+ result);
229
+ result = run(root, ['gate', '.', '--claims', '--json'], 120000);
230
+ const corruptGateCheck = json(result)?.checks?.find(row => row.name === 'verify-claims');
231
+ check('gate JSON preserves corrupt-baseline problems',
232
+ result.status === 1 && corruptGateCheck?.errors?.includes('baseline-invalid')
233
+ && corruptGateCheck?.baseline?.problems?.includes('integrity-mismatch'),
234
+ result);
235
+ result = run(root, ['verify-claim', '--all', '--raw', '--json']);
236
+ check('raw audit remains available even when the baseline store is corrupt',
237
+ result.status === 1 && json(result)?.baseline?.state === 'ignored'
238
+ && json(result)?.errors?.length === 0 && json(result)?.rawFailed === 2,
239
+ result);
240
+ result = run(root, ['verify-claim', 'baseline', 'show', '--json']);
241
+ check('baseline show reports corruption instead of silently treating it as absent',
242
+ result.status === 1 && json(result)?.code === 'baseline_invalid',
243
+ result);
244
+
245
+ const nonCanonical = withIntegrity({
246
+ ...stored,
247
+ entries: stored.entries.map(entry => ({ ...entry, reasons: [...entry.reasons, entry.reasons[0]] })),
248
+ });
249
+ fs.writeFileSync(baselineFile, JSON.stringify(nonCanonical, null, 2) + '\n', 'utf8');
250
+ result = run(root, ['verify-claim', '--all', '--json']);
251
+ check('non-canonical reasons fail closed even with a recomputed document integrity hash',
252
+ result.status === 1 && json(result)?.baseline?.problems?.includes('invalid-entry-reasons'),
253
+ result);
254
+ } finally {
255
+ fs.rmSync(root, { recursive: true, force: true });
256
+ }
257
+
258
+ if (failures.length) {
259
+ console.log(`CLAIMS_BASELINE_PROBE_FAILED: ${failures.join('; ')}`);
260
+ process.exitCode = 1;
261
+ } else {
262
+ console.log('claims baseline probe passed');
263
+ }
@@ -270,6 +270,26 @@ try {
270
270
  check('verify-claim retains its supported --test-cmd contract',
271
271
  claimOwnsTestCommand.status === 1 && json(claimOwnsTestCommand)?.code !== 'unknown_flag',
272
272
  claimOwnsTestCommand);
273
+
274
+ const claimRaw = run(['verify-claim', '--all', '--raw', '--path', emptyProject, '--json']);
275
+ check('verify-claim --all retains its supported raw-audit contract',
276
+ claimRaw.status === 0 && json(claimRaw)?.baseline?.state === 'ignored',
277
+ claimRaw);
278
+
279
+ const claimForeignBoundary = run(['verify-claim', 'T-NOT-FOUND', '--before', 'T-0002', '--path', emptyProject, '--json']);
280
+ check('per-task verify-claim rejects the baseline-only --before flag',
281
+ claimForeignBoundary.status === 1 && json(claimForeignBoundary)?.code === 'unknown_flag',
282
+ claimForeignBoundary);
283
+
284
+ const baselineForeignRun = run(['verify-claim', 'baseline', 'show', '--run-tests', '--path', emptyProject, '--json']);
285
+ check('claims baseline show rejects verification-only mutation flags',
286
+ baselineForeignRun.status === 1 && json(baselineForeignRun)?.code === 'unknown_flag',
287
+ baselineForeignRun);
288
+
289
+ const gateForeignRaw = run(['gate', emptyProject, '--raw', '--json']);
290
+ check('gate rejects --raw because raw audit belongs to verify-claim --all',
291
+ gateForeignRaw.status === 1 && json(gateForeignRaw)?.code === 'unknown_flag',
292
+ gateForeignRaw);
273
293
  } finally {
274
294
  fs.rmSync(emptyProject, { recursive: true, force: true });
275
295
  }
@@ -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'") || /spawnSync\(tool,/.test(diagnosticsSource)) {
1128
- failures.push('which 진단이 Windows bare where를 다시 실행함');
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`);