leerness 1.36.148 → 1.36.161
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 +59 -0
- package/README.ko.md +4 -1
- package/README.md +9 -5
- package/bin/leerness.js +1663 -271
- package/lib/catalogs.js +1 -1
- package/lib/cursor-session-hook.js +76 -0
- package/lib/diagnostics.js +28 -14
- package/lib/git.js +5 -5
- package/lib/io.js +6 -5
- package/lib/mcp-tools.js +1 -1
- package/lib/npm-process.js +90 -0
- package/lib/pure-utils.js +2 -1
- package/lib/session-presence.js +16 -3
- package/lib/team.js +16 -5
- package/package.json +6 -4
- package/scripts/e2e-command-surface.js +304 -0
- package/scripts/e2e-concurrency.js +141 -0
- package/scripts/e2e-core.js +24 -0
- package/scripts/e2e.js +356 -65
- package/scripts/handoff-readonly-probe.js +403 -0
- package/scripts/lock-probe.js +108 -5
- package/scripts/platform-smoke.js +185 -0
- package/scripts/release-bump-probe.js +137 -0
- package/scripts/release-runtime-probe.js +210 -0
- package/scripts/smoke.js +5 -3
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// T-0136: default handoff is a tracked-workspace read boundary while ignored,
|
|
5
|
+
// per-session presence records retain concurrent agent identity and freshness.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const cp = require('child_process');
|
|
11
|
+
|
|
12
|
+
const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
|
|
13
|
+
const LOCK_PROBE = path.resolve(__dirname, 'lock-probe.js');
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-handoff-readonly-'));
|
|
15
|
+
const baseEnv = {
|
|
16
|
+
...process.env,
|
|
17
|
+
CI: '',
|
|
18
|
+
GITHUB_ACTIONS: '',
|
|
19
|
+
LEERNESS_HOOK: '',
|
|
20
|
+
LEERNESS_INTERNAL: '',
|
|
21
|
+
CLAUDE_CODE_CHILD_SESSION: '',
|
|
22
|
+
LEERNESS_OFFLINE: '1',
|
|
23
|
+
LEERNESS_NO_PROMPT: '1',
|
|
24
|
+
LEERNESS_NO_LOCK_WARN: '1',
|
|
25
|
+
LEERNESS_SESSION_ID: 'probe-session-0001'
|
|
26
|
+
};
|
|
27
|
+
const checks = [];
|
|
28
|
+
|
|
29
|
+
function run(bin, args, opts = {}) {
|
|
30
|
+
return cp.spawnSync(bin, args, {
|
|
31
|
+
cwd: root,
|
|
32
|
+
env: opts.env || baseEnv,
|
|
33
|
+
input: opts.input,
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
timeout: opts.timeout || 90000
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function must(label, result) {
|
|
40
|
+
if (result.status === 0) return result;
|
|
41
|
+
throw new Error(`${label} failed (exit ${result.status})\n${result.stdout || ''}\n${result.stderr || ''}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assert(label, condition, detail) {
|
|
45
|
+
checks.push({ label, ok: !!condition, detail: condition ? undefined : detail });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function git(args) {
|
|
49
|
+
const result = run('git', args);
|
|
50
|
+
must(`git ${args.join(' ')}`, result);
|
|
51
|
+
return result.stdout || '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handoffEnv(sessionId) {
|
|
55
|
+
return { ...baseEnv, LEERNESS_SESSION_ID: sessionId };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function addresslessEnv() {
|
|
59
|
+
const env = { ...baseEnv };
|
|
60
|
+
for (const key of ['LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID']) delete env[key];
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function spawnHandoff(sessionId) {
|
|
65
|
+
return new Promise(resolve => {
|
|
66
|
+
const child = cp.spawn(process.execPath, [CLI, 'handoff', root, '--quiet', '--no-drift-check'], {
|
|
67
|
+
cwd: root,
|
|
68
|
+
env: handoffEnv(sessionId),
|
|
69
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
70
|
+
});
|
|
71
|
+
let stdout = '', stderr = '';
|
|
72
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
73
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
74
|
+
child.on('close', status => resolve({ sessionId, status, stdout, stderr }));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function spawnCli(args, env, nodeArgs = []) {
|
|
79
|
+
return new Promise(resolve => {
|
|
80
|
+
const child = cp.spawn(process.execPath, [...nodeArgs, CLI, ...args], {
|
|
81
|
+
cwd: root,
|
|
82
|
+
env,
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
84
|
+
});
|
|
85
|
+
let stdout = '', stderr = '';
|
|
86
|
+
child.stdout.on('data', chunk => { stdout += chunk; });
|
|
87
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
88
|
+
child.on('close', status => resolve({ status, stdout, stderr }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function waitForJson(file, timeoutMs = 10000) {
|
|
93
|
+
const deadline = Date.now() + timeoutMs;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
|
|
96
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`timed out waiting for probe observation: ${file}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
(async () => {
|
|
102
|
+
must('leerness init', run(process.execPath, [CLI, 'init', root, '--yes', '--minimal', '--language', 'ko']));
|
|
103
|
+
git(['init']);
|
|
104
|
+
git(['config', 'user.email', 'handoff-probe@example.invalid']);
|
|
105
|
+
git(['config', 'user.name', 'leerness handoff probe']);
|
|
106
|
+
git(['add', '-A']);
|
|
107
|
+
git(['commit', '-m', 'fixture']);
|
|
108
|
+
|
|
109
|
+
must('default handoff', run(process.execPath, [CLI, 'handoff', root, '--no-drift-check']));
|
|
110
|
+
const initialStatus = git(['status', '--porcelain=v1', '--untracked-files=all']).trim();
|
|
111
|
+
assert('default handoff leaves tracked workspace clean', initialStatus === '', initialStatus);
|
|
112
|
+
for (const file of ['environment.json', 'last-handoff.json', 'tech-profile.json']) {
|
|
113
|
+
assert(`default handoff does not create .harness/${file}`,
|
|
114
|
+
!fs.existsSync(path.join(root, '.harness', file)), file);
|
|
115
|
+
}
|
|
116
|
+
const sessionDir = path.join(root, '.harness', 'cache', 'sessions');
|
|
117
|
+
const firstRecord = JSON.parse(fs.readFileSync(path.join(sessionDir, 'probe-session-0001.json'), 'utf8'));
|
|
118
|
+
assert('session freshness is stored in ignored per-session record',
|
|
119
|
+
firstRecord.sessionKey === 'probe-session-0001' && firstRecord.handoffCount === 1
|
|
120
|
+
&& Array.isArray(firstRecord.handoffHistory) && firstRecord.handoffHistory.length === 1,
|
|
121
|
+
firstRecord);
|
|
122
|
+
const addressedChildId = 'addressed-child-0001';
|
|
123
|
+
must('child handoff with inherited explicit id', run(process.execPath,
|
|
124
|
+
[CLI, 'handoff', root, '--quiet', '--no-drift-check'],
|
|
125
|
+
{ env: { ...handoffEnv(addressedChildId), CLAUDE_CODE_CHILD_SESSION: '1' } }));
|
|
126
|
+
assert('child does not merge inherited explicit id into parent presence',
|
|
127
|
+
!fs.existsSync(path.join(sessionDir, `${addressedChildId}.json`)), addressedChildId);
|
|
128
|
+
const freshnessDir = path.join(root, '.harness', 'cache', 'handoffs');
|
|
129
|
+
const proveAddressedEnforcement = (label, beforeEnv, expectedKey, handoffRunEnv = beforeEnv, afterEnv = beforeEnv) => {
|
|
130
|
+
const before = run(process.execPath,
|
|
131
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: beforeEnv });
|
|
132
|
+
assert(`${label}: enforce check blocks before this session handoff`, before.status !== 0,
|
|
133
|
+
{ status: before.status, stdout: before.stdout, stderr: before.stderr });
|
|
134
|
+
const fixtureName = `session-${label.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()}.txt`;
|
|
135
|
+
fs.writeFileSync(path.join(root, fixtureName), `${label}\n`, 'utf8');
|
|
136
|
+
must(`${label}: stage`, run('git', ['add', '-A'], { env: beforeEnv }));
|
|
137
|
+
const blocked = run('git', ['commit', '-m', `${label} waits for own handoff`], { env: beforeEnv });
|
|
138
|
+
assert(`${label}: git hook blocks before this session handoff`, blocked.status !== 0,
|
|
139
|
+
{ status: blocked.status, stdout: blocked.stdout, stderr: blocked.stderr });
|
|
140
|
+
must(`${label}: handoff`, run(process.execPath,
|
|
141
|
+
[CLI, 'handoff', root, '--quiet', '--no-drift-check'], { env: handoffRunEnv }));
|
|
142
|
+
assert(`${label}: handoff writes the expected normalized marker`,
|
|
143
|
+
fs.existsSync(path.join(freshnessDir, `${expectedKey}.json`)), expectedKey);
|
|
144
|
+
const after = must(`${label}: enforce check after handoff`, run(process.execPath,
|
|
145
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: afterEnv }));
|
|
146
|
+
assert(`${label}: enforce check accepts only after this session handoff`,
|
|
147
|
+
JSON.parse(after.stdout).ok === true, after.stdout);
|
|
148
|
+
const committed = run('git', ['commit', '-m', `${label} own handoff passes`], { env: afterEnv });
|
|
149
|
+
assert(`${label}: git hook accepts only after this session handoff`, committed.status === 0,
|
|
150
|
+
{ status: committed.status, stdout: committed.stdout, stderr: committed.stderr });
|
|
151
|
+
};
|
|
152
|
+
const firstFreshness = JSON.parse(fs.readFileSync(path.join(freshnessDir, 'probe-session-0001.json'), 'utf8'));
|
|
153
|
+
assert('handoff-only freshness marker is separate from presence lifecycle',
|
|
154
|
+
firstFreshness.sessionKey === 'probe-session-0001' && Number.isFinite(Date.parse(firstFreshness.last || '')),
|
|
155
|
+
firstFreshness);
|
|
156
|
+
|
|
157
|
+
const noAddress = addresslessEnv();
|
|
158
|
+
must('addressless default handoff', run(process.execPath,
|
|
159
|
+
[CLI, 'handoff', root, '--quiet', '--no-drift-check'], { env: noAddress }));
|
|
160
|
+
const anonymousMarker = path.join(freshnessDir, 'unaddressed.json');
|
|
161
|
+
assert('addressless handoff records ignored enforcement freshness', fs.existsSync(anonymousMarker), anonymousMarker);
|
|
162
|
+
const afterAddressless = git(['status', '--porcelain=v1', '--untracked-files=all']).trim();
|
|
163
|
+
assert('addressless handoff keeps tracked workspace clean', afterAddressless === '', afterAddressless);
|
|
164
|
+
|
|
165
|
+
// 설치 probe가 marker를 실제로 쓰고 lock을 쥔 바로 그 순간에 실제 handoff를 겹친다. 종료 뒤만 보면
|
|
166
|
+
// 고정 키 회귀나 lock 제거가 cleanup에 가려질 수 있으므로, preload 계측이 rename 직전 상태를 알려 준다.
|
|
167
|
+
const syntheticMarker = path.join(freshnessDir, 'leerness-enforce-probe.json');
|
|
168
|
+
const syntheticBefore = JSON.stringify({ schemaVersion: 1, sessionKey: 'leerness-enforce-probe', last: '2001-01-01T00:00:00.000Z', history: [] }, null, 2) + '\n';
|
|
169
|
+
fs.writeFileSync(syntheticMarker, syntheticBefore, 'utf8');
|
|
170
|
+
const syntheticTime = new Date('2001-01-01T00:00:00.000Z');
|
|
171
|
+
fs.utimesSync(syntheticMarker, syntheticTime, syntheticTime);
|
|
172
|
+
const readyFile = path.join(os.tmpdir(), `leerness-enforce-observe-${process.pid}-${Date.now()}.json`);
|
|
173
|
+
const observedEnv = {
|
|
174
|
+
...noAddress,
|
|
175
|
+
LOCKPROBE_STALL_PREFIX: 'cache/handoffs/',
|
|
176
|
+
LOCKPROBE_STALL_MS: '3000',
|
|
177
|
+
LOCKPROBE_READY_FILE: readyFile
|
|
178
|
+
};
|
|
179
|
+
const installPromise = spawnCli(['enforce', 'install', '--path', root, '--json'], observedEnv,
|
|
180
|
+
['--require', LOCK_PROBE]);
|
|
181
|
+
const during = await waitForJson(readyFile);
|
|
182
|
+
const duringSyntheticStat = fs.statSync(syntheticMarker);
|
|
183
|
+
assert('enforce verification uses a reserved per-invocation marker while installation is still running',
|
|
184
|
+
/^cache\/handoffs\/leerness-internal-probe-[a-f0-9]{24}\.json$/.test(during.target || ''), during);
|
|
185
|
+
assert('enforce verification holds the exact marker lock at the observed write',
|
|
186
|
+
during.locked === true && Array.isArray(during.heldOn) && during.heldOn.includes(`${during.target}.lock`), during);
|
|
187
|
+
assert('running enforce verification has not touched the user-addressable marker bytes',
|
|
188
|
+
fs.readFileSync(syntheticMarker, 'utf8') === syntheticBefore, syntheticMarker);
|
|
189
|
+
assert('running enforce verification has not touched the user-addressable marker mtime',
|
|
190
|
+
Math.abs(duringSyntheticStat.mtimeMs - syntheticTime.getTime()) < 1000,
|
|
191
|
+
{ expected: syntheticTime.toISOString(), actual: duringSyntheticStat.mtime.toISOString() });
|
|
192
|
+
const concurrentHandoffPromise = spawnHandoff('leerness-enforce-probe');
|
|
193
|
+
const [installRun, concurrentHandoff] = await Promise.all([installPromise, concurrentHandoffPromise]);
|
|
194
|
+
must('enforce install', installRun);
|
|
195
|
+
must('real handoff overlapping enforce verification', concurrentHandoff);
|
|
196
|
+
const install = JSON.parse(installRun.stdout);
|
|
197
|
+
assert('enforce hook installation is behaviorally verified', install.ok === true && install.verified === 'fired', install);
|
|
198
|
+
const syntheticAfterStat = fs.statSync(syntheticMarker);
|
|
199
|
+
const syntheticAfter = JSON.parse(fs.readFileSync(syntheticMarker, 'utf8'));
|
|
200
|
+
assert('overlapping real handoff survives enforce verification cleanup',
|
|
201
|
+
syntheticAfter.sessionKey === 'leerness-enforce-probe'
|
|
202
|
+
&& Date.now() - syntheticAfterStat.mtimeMs < 60000,
|
|
203
|
+
{ record: syntheticAfter, mtime: syntheticAfterStat.mtime.toISOString() });
|
|
204
|
+
assert('enforce installation cleans every internal probe marker',
|
|
205
|
+
fs.readdirSync(freshnessDir).every(name => !name.startsWith('leerness-internal-probe-')),
|
|
206
|
+
fs.readdirSync(freshnessDir));
|
|
207
|
+
try { fs.unlinkSync(readyFile); } catch {}
|
|
208
|
+
fs.unlinkSync(syntheticMarker);
|
|
209
|
+
const reinstallRun = must('enforce reinstall without prior synthetic marker', run(process.execPath,
|
|
210
|
+
[CLI, 'enforce', 'install', '--path', root, '--json'], { env: noAddress }));
|
|
211
|
+
assert('enforce reinstallation is behaviorally verified', JSON.parse(reinstallRun.stdout).verified === 'fired', reinstallRun.stdout);
|
|
212
|
+
assert('enforce reinstallation leaves no internal probe marker',
|
|
213
|
+
fs.readdirSync(freshnessDir).every(name => !name.startsWith('leerness-internal-probe-')),
|
|
214
|
+
fs.readdirSync(freshnessDir));
|
|
215
|
+
const anonymousCheck = must('addressless enforce check', run(process.execPath,
|
|
216
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: noAddress }));
|
|
217
|
+
const anonymousCheckJson = JSON.parse(anonymousCheck.stdout);
|
|
218
|
+
assert('enforce check accepts a real addressless handoff', anonymousCheckJson.ok === true, anonymousCheckJson);
|
|
219
|
+
fs.writeFileSync(path.join(root, 'addressless-commit.txt'), 'addressless handoff\n', 'utf8');
|
|
220
|
+
must('stage addressless commit', run('git', ['add', '-A'], { env: noAddress }));
|
|
221
|
+
const addresslessCommit = run('git', ['commit', '-m', 'addressless handoff passes hook'], { env: noAddress });
|
|
222
|
+
assert('git hook accepts a real addressless handoff', addresslessCommit.status === 0,
|
|
223
|
+
{ status: addresslessCommit.status, stdout: addresslessCommit.stdout, stderr: addresslessCommit.stderr });
|
|
224
|
+
|
|
225
|
+
// 구버전 global 파일이 방금 갱신됐어도 주소가 있는 세션은 자기 marker 없이는 통과하면 안 된다.
|
|
226
|
+
const legacyPath = path.join(root, '.harness', 'last-handoff.json');
|
|
227
|
+
const legacyNow = new Date().toISOString();
|
|
228
|
+
fs.writeFileSync(legacyPath, JSON.stringify({ last: legacyNow, history: [legacyNow] }, null, 2) + '\n', 'utf8');
|
|
229
|
+
proveAddressedEnforcement('legacy-isolation', handoffEnv('legacy-isolation-session'), 'legacy-isolation-session');
|
|
230
|
+
|
|
231
|
+
// 내부 anonymous 슬롯명은 사용자 주소로 쓸 수 없다. 대소문자 변형도 예약어로 거부하고
|
|
232
|
+
// 다음 유효 source를 선택해야 하며, 이미 신선한 anonymous marker를 빌려서는 안 된다.
|
|
233
|
+
const reservedFallbackId = 'reserved-fallback-0001';
|
|
234
|
+
const reservedFallbackEnv = {
|
|
235
|
+
...noAddress,
|
|
236
|
+
LEERNESS_SESSION_ID: 'UNADDRESSED',
|
|
237
|
+
CODEX_THREAD_ID: reservedFallbackId
|
|
238
|
+
};
|
|
239
|
+
proveAddressedEnforcement('reserved-anonymous-key', reservedFallbackEnv, reservedFallbackId);
|
|
240
|
+
|
|
241
|
+
const reservedProbeFallbackId = 'reserved-probe-fallback-0001';
|
|
242
|
+
const reservedProbeFallbackEnv = {
|
|
243
|
+
...noAddress,
|
|
244
|
+
LEERNESS_SESSION_ID: 'LEERNESS-INTERNAL-PROBE-0001',
|
|
245
|
+
CODEX_THREAD_ID: reservedProbeFallbackId
|
|
246
|
+
};
|
|
247
|
+
proveAddressedEnforcement('reserved-probe-prefix', reservedProbeFallbackEnv, reservedProbeFallbackId);
|
|
248
|
+
|
|
249
|
+
// 잘못된 명시 주소가 있어도 JS와 shell hook 모두 다음 유효 source(CODEX_THREAD_ID)를 선택해야 한다.
|
|
250
|
+
// fallback 세션에는 아직 marker가 없지만 다른 세션 marker는 여러 개 있으므로, wildcard 오수용도 함께 검증된다.
|
|
251
|
+
const fallbackId = 'fallback-session-0002';
|
|
252
|
+
const malformedFallbackEnv = {
|
|
253
|
+
...noAddress,
|
|
254
|
+
LEERNESS_SESSION_ID: 'bad!',
|
|
255
|
+
CODEX_THREAD_ID: fallbackId
|
|
256
|
+
};
|
|
257
|
+
const fallbackCheckBefore = run(process.execPath,
|
|
258
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: malformedFallbackEnv });
|
|
259
|
+
assert('invalid explicit key cannot borrow another session marker in enforce check', fallbackCheckBefore.status !== 0,
|
|
260
|
+
{ status: fallbackCheckBefore.status, stdout: fallbackCheckBefore.stdout, stderr: fallbackCheckBefore.stderr });
|
|
261
|
+
fs.writeFileSync(path.join(root, 'fallback-address.txt'), 'fallback session\n', 'utf8');
|
|
262
|
+
must('stage fallback-address commit', run('git', ['add', '-A'], { env: malformedFallbackEnv }));
|
|
263
|
+
const fallbackCommitBefore = run('git', ['commit', '-m', 'must wait for fallback handoff'], { env: malformedFallbackEnv });
|
|
264
|
+
assert('invalid explicit key cannot make git hook wildcard-match another session', fallbackCommitBefore.status !== 0,
|
|
265
|
+
{ status: fallbackCommitBefore.status, stdout: fallbackCommitBefore.stdout, stderr: fallbackCommitBefore.stderr });
|
|
266
|
+
must('fallback-address handoff', run(process.execPath,
|
|
267
|
+
[CLI, 'handoff', root, '--quiet', '--no-drift-check'], { env: malformedFallbackEnv }));
|
|
268
|
+
assert('handoff records the valid fallback session key',
|
|
269
|
+
fs.existsSync(path.join(freshnessDir, `${fallbackId}.json`)), fallbackId);
|
|
270
|
+
const fallbackCheckAfter = must('fallback-address enforce check', run(process.execPath,
|
|
271
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: malformedFallbackEnv }));
|
|
272
|
+
assert('enforce check accepts the same valid fallback after its own handoff',
|
|
273
|
+
JSON.parse(fallbackCheckAfter.stdout).ok === true, fallbackCheckAfter.stdout);
|
|
274
|
+
const fallbackCommitAfter = run('git', ['commit', '-m', 'fallback handoff passes hook'], { env: malformedFallbackEnv });
|
|
275
|
+
assert('git hook accepts the same valid fallback after its own handoff', fallbackCommitAfter.status === 0,
|
|
276
|
+
{ status: fallbackCommitAfter.status, stdout: fallbackCommitAfter.stdout, stderr: fallbackCommitAfter.stderr });
|
|
277
|
+
|
|
278
|
+
// JS와 shell이 같은 case-fold 규칙 및 KEY_RE 경계(8/64자)를 실제 check+commit 양쪽에서 사용한다.
|
|
279
|
+
const caseBeforeEnv = handoffEnv('CaseFoldSession01');
|
|
280
|
+
const caseAfterEnv = handoffEnv('CASEFOLDSESSION01');
|
|
281
|
+
proveAddressedEnforcement('case-fold', caseBeforeEnv, 'casefoldsession01', caseBeforeEnv, caseAfterEnv);
|
|
282
|
+
proveAddressedEnforcement('key-boundary-8', handoffEnv('edge0001'), 'edge0001');
|
|
283
|
+
const maxKey = 'B'.repeat(64);
|
|
284
|
+
proveAddressedEnforcement('key-boundary-64', handoffEnv(maxKey), maxKey.toLowerCase());
|
|
285
|
+
|
|
286
|
+
// 추론 주소는 child 세션에서 억제된다. 후보 세션 marker가 신선해도 anonymous marker/legacy가
|
|
287
|
+
// 없거나 오래됐으면 차단하고, child가 직접 handoff해 anonymous marker를 만든 뒤에만 통과한다.
|
|
288
|
+
const childCandidate = 'child-candidate-0001';
|
|
289
|
+
const childParentEnv = { ...noAddress, CODEX_THREAD_ID: childCandidate };
|
|
290
|
+
must('seed child candidate marker', run(process.execPath,
|
|
291
|
+
[CLI, 'handoff', root, '--quiet', '--no-drift-check'], { env: childParentEnv }));
|
|
292
|
+
if (fs.existsSync(anonymousMarker)) fs.unlinkSync(anonymousMarker);
|
|
293
|
+
const legacyOld = new Date(Date.now() - 72 * 3600000);
|
|
294
|
+
fs.utimesSync(legacyPath, legacyOld, legacyOld);
|
|
295
|
+
const childEnv = { ...childParentEnv, CLAUDE_CODE_CHILD_SESSION: '1' };
|
|
296
|
+
const childBefore = run(process.execPath,
|
|
297
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: childEnv });
|
|
298
|
+
assert('child suppression: enforce check does not borrow inferred parent marker or stale legacy', childBefore.status !== 0,
|
|
299
|
+
{ status: childBefore.status, stdout: childBefore.stdout, stderr: childBefore.stderr });
|
|
300
|
+
fs.writeFileSync(path.join(root, 'child-suppression.txt'), 'child suppression\n', 'utf8');
|
|
301
|
+
must('stage child suppression', run('git', ['add', '-A'], { env: childEnv }));
|
|
302
|
+
const childCommitBefore = run('git', ['commit', '-m', 'child waits for anonymous handoff'], { env: childEnv });
|
|
303
|
+
assert('child suppression: git hook does not borrow inferred parent marker or stale legacy', childCommitBefore.status !== 0,
|
|
304
|
+
{ status: childCommitBefore.status, stdout: childCommitBefore.stdout, stderr: childCommitBefore.stderr });
|
|
305
|
+
must('child addressless handoff', run(process.execPath,
|
|
306
|
+
[CLI, 'handoff', root, '--quiet', '--no-drift-check'], { env: childEnv }));
|
|
307
|
+
assert('child suppression: handoff uses anonymous marker', fs.existsSync(anonymousMarker), anonymousMarker);
|
|
308
|
+
const childAfter = must('child enforce check after anonymous handoff', run(process.execPath,
|
|
309
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: childEnv }));
|
|
310
|
+
assert('child suppression: enforce check accepts its anonymous handoff', JSON.parse(childAfter.stdout).ok === true, childAfter.stdout);
|
|
311
|
+
const childCommitAfter = run('git', ['commit', '-m', 'child anonymous handoff passes'], { env: childEnv });
|
|
312
|
+
assert('child suppression: git hook accepts its anonymous handoff', childCommitAfter.status === 0,
|
|
313
|
+
{ status: childCommitAfter.status, stdout: childCommitAfter.stdout, stderr: childCommitAfter.stderr });
|
|
314
|
+
|
|
315
|
+
const closeOnlyId = 'close-only-session';
|
|
316
|
+
const closeOnlyEnv = handoffEnv(closeOnlyId);
|
|
317
|
+
must('close-only session', run(process.execPath,
|
|
318
|
+
[CLI, 'session', 'close', root, '--json'], { env: closeOnlyEnv, timeout: 180000 }));
|
|
319
|
+
const closeOnlyRecord = JSON.parse(fs.readFileSync(path.join(sessionDir, `${closeOnlyId}.json`), 'utf8'));
|
|
320
|
+
assert('session close does not invent a handoff timestamp',
|
|
321
|
+
closeOnlyRecord.handoffCount === 0 && closeOnlyRecord.lastHandoffAt === null,
|
|
322
|
+
closeOnlyRecord);
|
|
323
|
+
assert('session close does not create a handoff-only marker',
|
|
324
|
+
!fs.existsSync(path.join(freshnessDir, `${closeOnlyId}.json`)), closeOnlyId);
|
|
325
|
+
const closeOnlyCheck = run(process.execPath,
|
|
326
|
+
[CLI, 'enforce', 'check', '--path', root, '--json'], { env: closeOnlyEnv });
|
|
327
|
+
assert('session close alone cannot satisfy enforce check', closeOnlyCheck.status !== 0,
|
|
328
|
+
{ status: closeOnlyCheck.status, stdout: closeOnlyCheck.stdout, stderr: closeOnlyCheck.stderr });
|
|
329
|
+
must('stage close-only changes', run('git', ['add', '-A'], { env: closeOnlyEnv }));
|
|
330
|
+
const closeOnlyCommit = run('git', ['commit', '-m', 'close alone must not pass hook'], { env: closeOnlyEnv });
|
|
331
|
+
assert('session close alone cannot satisfy git hook', closeOnlyCommit.status !== 0,
|
|
332
|
+
{ status: closeOnlyCommit.status, stdout: closeOnlyCommit.stdout, stderr: closeOnlyCommit.stderr });
|
|
333
|
+
must('commit close-only fixture with prior real handoff', run('git',
|
|
334
|
+
['commit', '-m', 'record close-only fixture'], { env: baseEnv }));
|
|
335
|
+
|
|
336
|
+
const parallelIds = Array.from({ length: 8 }, (_, index) => `parallel-session-${String(index + 1).padStart(2, '0')}`);
|
|
337
|
+
const parallel = await Promise.all(parallelIds.map(spawnHandoff));
|
|
338
|
+
assert('parallel handoff processes all exit successfully', parallel.every(item => item.status === 0), parallel);
|
|
339
|
+
const missing = parallelIds.filter(id => !fs.existsSync(path.join(sessionDir, `${id}.json`)));
|
|
340
|
+
assert('parallel handoffs retain every unique session record', missing.length === 0, missing);
|
|
341
|
+
const wrongCounts = parallelIds.filter(id => {
|
|
342
|
+
const record = JSON.parse(fs.readFileSync(path.join(sessionDir, `${id}.json`), 'utf8'));
|
|
343
|
+
return record.sessionKey !== id || record.handoffCount !== 1 || record.handoffHistory.length !== 1;
|
|
344
|
+
});
|
|
345
|
+
assert('parallel handoffs do not merge or overwrite another session', wrongCounts.length === 0, wrongCounts);
|
|
346
|
+
const afterParallel = git(['status', '--porcelain=v1', '--untracked-files=all']).trim();
|
|
347
|
+
assert('parallel handoffs keep git status clean', afterParallel === '', afterParallel);
|
|
348
|
+
|
|
349
|
+
fs.writeFileSync(path.join(root, '.harness', 'HARNESS_VERSION'), '9.9.9\n', 'utf8');
|
|
350
|
+
git(['add', '.harness/HARNESS_VERSION']);
|
|
351
|
+
git(['commit', '-m', 'version skew fixture']);
|
|
352
|
+
const skewText = must('version skew text handoff', run(process.execPath,
|
|
353
|
+
[CLI, 'handoff', root, '--compact', '--no-drift-check'], { env: handoffEnv('version-skew-text') }));
|
|
354
|
+
assert('human handoff warns when CLI is older than project harness',
|
|
355
|
+
/버전 불일치/.test(skewText.stdout) && /9\.9\.9/.test(skewText.stdout), skewText.stdout);
|
|
356
|
+
const skewJsonRun = must('version skew json handoff', run(process.execPath,
|
|
357
|
+
[CLI, 'handoff', root, '--json', '--no-drift-check'], { env: handoffEnv('version-skew-json') }));
|
|
358
|
+
const skewJson = JSON.parse(skewJsonRun.stdout);
|
|
359
|
+
assert('JSON handoff exposes machine-readable version skew',
|
|
360
|
+
skewJson.versionSkew && skewJson.versionSkew.kind === 'cli-older'
|
|
361
|
+
&& skewJson.versionSkew.harnessVersion === '9.9.9', skewJson.versionSkew);
|
|
362
|
+
const afterSkew = git(['status', '--porcelain=v1', '--untracked-files=all']).trim();
|
|
363
|
+
assert('version skew warning path remains tracked-workspace clean', afterSkew === '', afterSkew);
|
|
364
|
+
|
|
365
|
+
const cursorDir = path.join(root, '.cursor');
|
|
366
|
+
fs.mkdirSync(cursorDir, { recursive: true });
|
|
367
|
+
fs.writeFileSync(path.join(cursorDir, 'hooks.json'), JSON.stringify({
|
|
368
|
+
version: 1,
|
|
369
|
+
hooks: {
|
|
370
|
+
afterFileEdit: [{ command: 'node custom-after-edit.js' }],
|
|
371
|
+
sessionStart: [{ command: 'node custom-session-start.js' }]
|
|
372
|
+
}
|
|
373
|
+
}, null, 2) + '\n');
|
|
374
|
+
must('cursor adapter', run(process.execPath, [CLI, 'adapter', 'cursor', '--path', root]));
|
|
375
|
+
const hooksPath = path.join(cursorDir, 'hooks.json');
|
|
376
|
+
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
377
|
+
const starts = hooks.hooks.sessionStart.map(item => item.command);
|
|
378
|
+
assert('cursor adapter preserves existing hooks',
|
|
379
|
+
hooks.hooks.afterFileEdit[0].command === 'node custom-after-edit.js'
|
|
380
|
+
&& starts.includes('node custom-session-start.js'), hooks);
|
|
381
|
+
assert('cursor adapter adds exactly one leerness sessionStart hook',
|
|
382
|
+
starts.filter(command => command === 'node .cursor/hooks/leerness-session.cjs').length === 1, starts);
|
|
383
|
+
const hookScript = path.join(cursorDir, 'hooks', 'leerness-session.cjs');
|
|
384
|
+
const hookRun = must('cursor sessionStart hook', run(process.execPath, [hookScript], {
|
|
385
|
+
input: JSON.stringify({ hook_event_name: 'sessionStart', session_id: 'cursor-conversation-0001' })
|
|
386
|
+
}));
|
|
387
|
+
const hookOutput = JSON.parse(hookRun.stdout);
|
|
388
|
+
assert('cursor sessionStart maps conversation id to LEERNESS_SESSION_ID',
|
|
389
|
+
hookOutput.env && hookOutput.env.LEERNESS_SESSION_ID === 'cursor-conversation-0001', hookOutput);
|
|
390
|
+
const beforeSecondAdapter = fs.readFileSync(hooksPath, 'utf8');
|
|
391
|
+
must('cursor adapter idempotence', run(process.execPath, [CLI, 'adapter', 'cursor', '--path', root]));
|
|
392
|
+
const afterSecondAdapter = fs.readFileSync(hooksPath, 'utf8');
|
|
393
|
+
assert('cursor adapter hook merge is idempotent', beforeSecondAdapter === afterSecondAdapter, starts);
|
|
394
|
+
|
|
395
|
+
const failed = checks.filter(check => !check.ok);
|
|
396
|
+
process.stdout.write(JSON.stringify({ ok: failed.length === 0, checks: checks.length, failed, parallel: parallel.length }) + '\n');
|
|
397
|
+
if (failed.length) process.exitCode = 1;
|
|
398
|
+
})().catch(error => {
|
|
399
|
+
process.stderr.write(`${error && error.stack || error}\n`);
|
|
400
|
+
process.exitCode = 1;
|
|
401
|
+
}).finally(() => {
|
|
402
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
403
|
+
});
|
package/scripts/lock-probe.js
CHANGED
|
@@ -3,22 +3,58 @@
|
|
|
3
3
|
// 왜 소스 검사가 아니라 계측인가: 정적으로 "이 writeUtf8 이 _withLock 안인가" 를 판정하려 했더니
|
|
4
4
|
// 괄호 균형 스캐너가 주석/정규식 리터럴에서 폭주해 selftest 픽스처 수천 줄을 "락 안" 으로 분류했다.
|
|
5
5
|
// 락은 **파일별이 아니라 구간별**이기도 하다 — leerness 는 decisions.json 의 락을 쥔 채 decisions.md 를 쓴다.
|
|
6
|
-
// 그래서 _withLock 이 하는 것과 똑같이(
|
|
6
|
+
// 그래서 _withLock 이 하는 것과 똑같이(mkdir 로 <target>.lock 생성 → 끝나면 rmdir) 보유 집합을 미러링하고,
|
|
7
7
|
// 쓰기 시점에 "이 프로세스가 락을 하나라도 쥐고 있었나" 를 기록한다.
|
|
8
8
|
//
|
|
9
9
|
// 사용: node --require scripts/lock-probe.js bin/leerness.js <명령> ... (LOCKPROBE_OUT 에 jsonl 기록)
|
|
10
10
|
'use strict';
|
|
11
11
|
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
12
13
|
const path = require('path');
|
|
13
14
|
|
|
14
15
|
const OUT = process.env.LOCKPROBE_OUT;
|
|
15
16
|
const rec = [];
|
|
16
17
|
const held = new Set();
|
|
17
18
|
const norm = p => { try { return path.resolve(String(p)); } catch { return String(p); } };
|
|
18
|
-
// archive/ 는 백업 사본이라 경쟁 대상이 아니다. .lock
|
|
19
|
-
|
|
19
|
+
// archive/ 는 백업 사본이라 경쟁 대상이 아니다. 공개 .lock 과 해제 중인 token-unique
|
|
20
|
+
// .lock.release-* tombstone 자체/하위 파일, 원자쓰기 임시파일도 제외한다(최종 경로는 rename 이 알려준다).
|
|
21
|
+
// tombstone을 상태로 세면 해제용 rename/owner 삭제가 사용자 쓰기로 오인되고, 공개 락을 계속 held로
|
|
22
|
+
// 누적하면 이후의 정상 쓰기까지 "서로 다른 락" 아래에서 일어난 것처럼 보이는 거짓 실패가 난다.
|
|
23
|
+
const LOCK_INTERNAL = /\.lock(?:\.release-[0-9a-f]+)?(?:[\\/]|$)/i;
|
|
24
|
+
const isState = p => /[\\/]\.harness[\\/]/.test(p) && !/[\\/]archive[\\/]/.test(p)
|
|
25
|
+
&& !LOCK_INTERNAL.test(p) && !/\.tmp-\d+-\d+$/.test(p);
|
|
20
26
|
const relOf = p => (p.split(/[\\/]\.harness[\\/]/)[1] || p).replace(/\\/g, '/');
|
|
27
|
+
// 선택형 경쟁 창 확대. 제품에는 영향을 주지 않으며, 테스트가 명시적으로 이 모듈을 preload 하고
|
|
28
|
+
// LOCKPROBE_STALL_TARGET/LOCKPROBE_STALL_MS 를 함께 준 경우에만 최종 rename 직전에 멈춘다.
|
|
29
|
+
// 락이 있는 명령은 이 대기 동안에도 같은 락을 유지하고, 락이 없는 이전 RMW는 뒤늦은 쓰기가 새 데이터를 덮는다.
|
|
30
|
+
const STALL_TARGET = String(process.env.LOCKPROBE_STALL_TARGET || '').replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
31
|
+
const STALL_PREFIX = String(process.env.LOCKPROBE_STALL_PREFIX || '').replace(/\\/g, '/').replace(/^\.?\/+/, '');
|
|
32
|
+
const STALL_MS = Math.max(0, Math.min(10000, Number(process.env.LOCKPROBE_STALL_MS) || 0));
|
|
33
|
+
const READY_FILE = process.env.LOCKPROBE_READY_FILE ? path.resolve(process.env.LOCKPROBE_READY_FILE) : null;
|
|
34
|
+
let stalled = false;
|
|
35
|
+
const shouldStall = p => {
|
|
36
|
+
if (stalled || STALL_MS <= 0) return false;
|
|
37
|
+
const rel = relOf(norm(p));
|
|
38
|
+
return (STALL_TARGET && rel === STALL_TARGET) || (STALL_PREFIX && rel.startsWith(STALL_PREFIX));
|
|
39
|
+
};
|
|
40
|
+
const stall = ms => {
|
|
41
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
42
|
+
catch { const until = Date.now() + ms; while (Date.now() < until) {} }
|
|
43
|
+
};
|
|
21
44
|
|
|
45
|
+
const _mkdir = fs.mkdirSync.bind(fs);
|
|
46
|
+
fs.mkdirSync = function (p, ...a) {
|
|
47
|
+
const r = _mkdir(p, ...a);
|
|
48
|
+
try { if (/\.lock$/.test(String(p))) held.add(norm(p)); } catch {}
|
|
49
|
+
return r;
|
|
50
|
+
};
|
|
51
|
+
const _rmdir = fs.rmdirSync.bind(fs);
|
|
52
|
+
fs.rmdirSync = function (p, ...a) {
|
|
53
|
+
const r = _rmdir(p, ...a);
|
|
54
|
+
try { if (/\.lock$/.test(String(p))) held.delete(norm(p)); } catch {}
|
|
55
|
+
return r;
|
|
56
|
+
};
|
|
57
|
+
// 구버전 고정 파일 락도 계측 호환성을 유지한다.
|
|
22
58
|
const _open = fs.openSync.bind(fs);
|
|
23
59
|
fs.openSync = function (p, flags, ...a) {
|
|
24
60
|
const r = _open(p, flags, ...a);
|
|
@@ -27,8 +63,9 @@ fs.openSync = function (p, flags, ...a) {
|
|
|
27
63
|
};
|
|
28
64
|
const _unlink = fs.unlinkSync.bind(fs);
|
|
29
65
|
fs.unlinkSync = function (p, ...a) {
|
|
66
|
+
const r = _unlink(p, ...a);
|
|
30
67
|
try { if (/\.lock$/.test(String(p))) held.delete(norm(p)); } catch {}
|
|
31
|
-
return
|
|
68
|
+
return r;
|
|
32
69
|
};
|
|
33
70
|
|
|
34
71
|
// 1.36.108 (codex 검수 P2, 재현됨): "락을 하나라도 쥐었나" 는 **너무 약한 질문**이다.
|
|
@@ -43,7 +80,32 @@ const note = (target, how) => {
|
|
|
43
80
|
const _w = fs.writeFileSync.bind(fs);
|
|
44
81
|
fs.writeFileSync = function (p, ...a) { try { note(p, 'write'); } catch {} return _w(p, ...a); };
|
|
45
82
|
const _r = fs.renameSync.bind(fs);
|
|
46
|
-
fs.renameSync = function (from, to) {
|
|
83
|
+
fs.renameSync = function (from, to) {
|
|
84
|
+
try { note(to, 'rename'); } catch {}
|
|
85
|
+
try {
|
|
86
|
+
if (shouldStall(to)) {
|
|
87
|
+
stalled = true;
|
|
88
|
+
const target = norm(to);
|
|
89
|
+
if (READY_FILE) {
|
|
90
|
+
_w(READY_FILE, JSON.stringify({
|
|
91
|
+
target: relOf(target),
|
|
92
|
+
locked: held.has(target + '.lock'),
|
|
93
|
+
heldOn: [...held].map(relOf).sort()
|
|
94
|
+
}), 'utf8');
|
|
95
|
+
}
|
|
96
|
+
stall(STALL_MS);
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
const r = _r(from, to);
|
|
100
|
+
// 1.36.160: 공개 락 디렉터리를 tombstone으로 원자 이동하는 순간 target에 대한
|
|
101
|
+
// 상호배제는 끝난다. 실제 rename 성공 뒤에만 보유 집합에서 제거해 실패한 rename은
|
|
102
|
+
// 계속 held로 남긴다. tombstone은 정리 대상이지 새 공개 락이 아니므로 destination은 추가하지 않는다.
|
|
103
|
+
try {
|
|
104
|
+
const source = norm(from);
|
|
105
|
+
if (held.has(source) && /\.lock$/i.test(String(from)) && LOCK_INTERNAL.test(String(to))) held.delete(source);
|
|
106
|
+
} catch {}
|
|
107
|
+
return r;
|
|
108
|
+
};
|
|
47
109
|
const _a = fs.appendFileSync.bind(fs);
|
|
48
110
|
fs.appendFileSync = function (p, ...args) { try { note(p, 'append'); } catch {} return _a(p, ...args); };
|
|
49
111
|
|
|
@@ -51,3 +113,44 @@ process.on('exit', () => {
|
|
|
51
113
|
if (!OUT) return;
|
|
52
114
|
try { fs.appendFileSync(OUT, rec.map(r => JSON.stringify(r)).join('\n') + (rec.length ? '\n' : ''), 'utf8'); } catch {}
|
|
53
115
|
});
|
|
116
|
+
|
|
117
|
+
// 직접 실행 시 계측기 자체의 tombstone 수명주기를 빠르게 검증한다. 전체 e2e의 파일별
|
|
118
|
+
// 락 불변식도 같은 결함을 잡지만 수십 분이 걸리므로, 이 회귀는 밀리초 단위 smoke로 먼저 막는다.
|
|
119
|
+
if (require.main === module) {
|
|
120
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lock-probe-selftest-'));
|
|
121
|
+
try {
|
|
122
|
+
const harness = path.join(root, '.harness');
|
|
123
|
+
const state = path.join(harness, 'state.json');
|
|
124
|
+
const lock = `${state}.lock`;
|
|
125
|
+
const tombstone = `${lock}.release-0123456789abcdef0123456789abcdef`;
|
|
126
|
+
const ownerName = 'owner-0123456789abcdef0123456789abcdef.json';
|
|
127
|
+
fs.mkdirSync(harness, { recursive: true });
|
|
128
|
+
fs.mkdirSync(lock);
|
|
129
|
+
fs.writeFileSync(path.join(lock, ownerName), '{"token":"0123456789abcdef0123456789abcdef"}\n');
|
|
130
|
+
fs.writeFileSync(state, '{"phase":"held"}\n');
|
|
131
|
+
fs.renameSync(lock, tombstone);
|
|
132
|
+
const movedOwner = path.join(tombstone, ownerName);
|
|
133
|
+
// 이동된 하위 파일의 실제 쓰기/삭제도 사용자 상태 row로 보이면 안 된다.
|
|
134
|
+
fs.writeFileSync(movedOwner, '{"phase":"deletion-pending"}\n');
|
|
135
|
+
fs.unlinkSync(movedOwner);
|
|
136
|
+
fs.writeFileSync(state, '{"phase":"released"}\n');
|
|
137
|
+
const stateRows = rec.filter(row => row.file === 'state.json');
|
|
138
|
+
const passed = rec.length === 2
|
|
139
|
+
&& stateRows.length === 2
|
|
140
|
+
&& stateRows[0].locked === true
|
|
141
|
+
&& stateRows[0].heldOn.length === 1
|
|
142
|
+
&& stateRows[0].heldOn[0] === 'state.json.lock'
|
|
143
|
+
&& stateRows[1].locked === false
|
|
144
|
+
&& stateRows[1].heldOn.length === 0
|
|
145
|
+
&& rec.every(row => !/\.lock\.release-/i.test(row.file))
|
|
146
|
+
&& held.size === 0;
|
|
147
|
+
if (!passed) {
|
|
148
|
+
process.stderr.write(`lock-probe selftest failed: ${JSON.stringify({ stateRows, held: [...held], rec })}\n`);
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
} else {
|
|
151
|
+
process.stdout.write('lock-probe selftest passed: release tombstone is internal and public lock lifetime ended\n');
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|