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
package/scripts/e2e.js
CHANGED
|
@@ -10,6 +10,14 @@ const cp = require('child_process');
|
|
|
10
10
|
// 1.36.87: 일부 e2e 타임아웃을 상향했다 — 격리 실측(agents list 5.2s · init --language 8.5s) 대비
|
|
11
11
|
// 여유가 3배 미만이라 전체 스위트 부하에서 간헐 초과했다. 검사 대상은 속도가 아니라 동작이다.
|
|
12
12
|
process.env.LEERNESS_OFFLINE = process.env.LEERNESS_OFFLINE || '1';
|
|
13
|
+
// The historical end-to-end fixtures assert the Korean default surface. Keep auto
|
|
14
|
+
// detection deterministic on English CI hosts without LEERNESS_LANG, which would
|
|
15
|
+
// override per-project English manifests and contaminate in-process doctor checks.
|
|
16
|
+
// Language-specific cases below pass their own LANG/LC_ALL values explicitly.
|
|
17
|
+
process.env.LC_ALL = '';
|
|
18
|
+
process.env.LC_CTYPE = '';
|
|
19
|
+
process.env.LANG = '';
|
|
20
|
+
process.env.LANGUAGE = 'ko';
|
|
13
21
|
// 1.9.284 (UR-0029): e2e 속도 — 기본 roadmap.html(70KB HTML) 자동 생성 OFF (roadmap 전용 테스트 블록만 일시 ON).
|
|
14
22
|
// 대부분의 init/session 테스트는 roadmap 을 검증하지 않으므로 생성 비용 제거 → 5분 내 완료.
|
|
15
23
|
process.env.LEERNESS_NO_AUTO_ROADMAP = '1';
|
|
@@ -1688,7 +1696,7 @@ total++;
|
|
|
1688
1696
|
fs.writeFileSync(shPath, body, 'utf8');
|
|
1689
1697
|
}
|
|
1690
1698
|
// handoff 호출 → reminder 자동 생성
|
|
1691
|
-
cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 10000 });
|
|
1699
|
+
cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact', '--writeback'], { encoding: 'utf8', timeout: 10000 });
|
|
1692
1700
|
const remPath = path.join(tmpC, '.harness', 'agent-reminders.md');
|
|
1693
1701
|
const ok = fs.existsSync(remPath) && /drift critical/.test(fs.readFileSync(remPath, 'utf8'));
|
|
1694
1702
|
console.log(ok ? '✓ B(1.9.38) drift critical → agent-reminders.md 자동 생성' : `✗ reminder 파일 실패`);
|
|
@@ -2473,6 +2481,17 @@ total++;
|
|
|
2473
2481
|
if (!(okPatch && okMinor && okMajor)) failed++;
|
|
2474
2482
|
}
|
|
2475
2483
|
|
|
2484
|
+
// T-0122: --patch는 경고 없이 수용하고, leerness 패키지의 package/bin/README 버전 표면을 함께 갱신한다.
|
|
2485
|
+
total++;
|
|
2486
|
+
{
|
|
2487
|
+
const probe = cp.spawnSync(process.execPath, [path.join(__dirname, 'release-bump-probe.js')], {
|
|
2488
|
+
cwd: path.resolve(__dirname, '..'), encoding: 'utf8', timeout: 30000
|
|
2489
|
+
});
|
|
2490
|
+
const ok = probe.status === 0 && /release-bump probe passed/.test(probe.stdout || '') && !/unknown flag|알 수 없는 플래그/i.test(probe.stderr || '');
|
|
2491
|
+
console.log(ok ? '✓ B(T-0122) release bump: --patch + package/bin/README 동기화' : `✗ B(T-0122) release bump 동기화 실패 (exit=${probe.status})`);
|
|
2492
|
+
if (!ok) { failed++; process.stdout.write(probe.stdout || ''); process.stderr.write(probe.stderr || ''); }
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2476
2495
|
// 1.9.8: release note → CHANGELOG.md 자동 갱신
|
|
2477
2496
|
total++;
|
|
2478
2497
|
{
|
|
@@ -2953,7 +2972,10 @@ total++;
|
|
|
2953
2972
|
cp.spawnSync(process.execPath, [CLI, 'init', aDir, '--minimal', '--no-env', '--yes'], { encoding: 'utf8', timeout: 30000 });
|
|
2954
2973
|
// adapter cursor: .cursor + .mcp.json 생성, .claude 미생성(minimal이라 commands는 있으나 cursor adapter는 안 건드림)
|
|
2955
2974
|
const rCur = cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', aDir], { encoding: 'utf8', timeout: 15000 });
|
|
2956
|
-
const cursorOk = rCur.status === 0
|
|
2975
|
+
const cursorOk = rCur.status === 0
|
|
2976
|
+
&& fs.existsSync(path.join(aDir, '.cursor', 'rules', 'leerness.mdc'))
|
|
2977
|
+
&& fs.existsSync(path.join(aDir, '.cursor', 'hooks.json'))
|
|
2978
|
+
&& fs.existsSync(path.join(aDir, '.cursor', 'hooks', 'leerness-session.cjs'));
|
|
2957
2979
|
let mcpOk = false;
|
|
2958
2980
|
try { const m = JSON.parse(fs.readFileSync(path.join(aDir, '.mcp.json'), 'utf8')); mcpOk = m.mcpServers && m.mcpServers.leerness && m.mcpServers.leerness.args.join(' ') === '-y leerness mcp serve'; } catch {}
|
|
2959
2981
|
// adapter list --json: 9종
|
|
@@ -2965,7 +2987,7 @@ total++;
|
|
|
2965
2987
|
const rDry = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--dry-run', '--path', dDir], { encoding: 'utf8', timeout: 15000 });
|
|
2966
2988
|
const dryOk = /\[dry-run\]/.test(rDry.stdout) && fs.readdirSync(dDir).length === 0;
|
|
2967
2989
|
const ok = cursorOk && mcpOk && listOk && dryOk;
|
|
2968
|
-
console.log(ok ? '✓ B(1.9.280) adapter: cursor(.cursor
|
|
2990
|
+
console.log(ok ? '✓ B(1.9.280) adapter: cursor(.cursor rules+MCP+session hook)/list 9종/dry-run(0파일)' : `✗ adapter 실패 (cursor=${cursorOk} mcp=${mcpOk} list=${listOk} dry=${dryOk})`);
|
|
2969
2991
|
if (!ok) { failed++; console.log((rCur.stdout || '').slice(0, 200)); }
|
|
2970
2992
|
}
|
|
2971
2993
|
|
|
@@ -6724,12 +6746,15 @@ total++;
|
|
|
6724
6746
|
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
|
|
6725
6747
|
const ci = cp.spawnSync(process.execPath, [CLI, 'ci', 'init', d], { encoding: 'utf8', timeout: 15000 });
|
|
6726
6748
|
const wf = fs.readFileSync(path.join(d, '.github', 'workflows', 'leerness-gate.yml'), 'utf8');
|
|
6727
|
-
// 버전 핀(설치 버전 == package.json) · 미핀 latest 부재 · 최소권한 · concurrency 취소 · gate 호출
|
|
6749
|
+
// 버전 핀(설치 버전 == package.json) · 미핀 latest 부재 · 최소권한 · concurrency 취소 · Node 24 액션/런타임 · gate 호출
|
|
6728
6750
|
const pinned = new RegExp('run: npx -y leerness@' + ver.replace(/\./g, '\\.') + ' gate \\.').test(wf);
|
|
6729
6751
|
const noUnpinned = !/npx -y leerness gate \./.test(wf);
|
|
6730
6752
|
const perms = /permissions:\n\s*contents: read/.test(wf);
|
|
6731
6753
|
const conc = /concurrency:\n\s*group: leerness-gate-\$\{\{ github\.ref \}\}\n\s*cancel-in-progress: true/.test(wf);
|
|
6732
|
-
const stillGate = /leerness-gate/.test(wf) && /pull_request:/.test(wf)
|
|
6754
|
+
const stillGate = /leerness-gate/.test(wf) && /pull_request:/.test(wf)
|
|
6755
|
+
&& /actions\/checkout@v7/.test(wf) && /actions\/setup-node@v7/.test(wf)
|
|
6756
|
+
&& /node-version:\s*'24'/.test(wf) && /package-manager-cache:\s*false/.test(wf)
|
|
6757
|
+
&& !/actions\/(?:checkout|setup-node)@v4/.test(wf);
|
|
6733
6758
|
fs.rmSync(d, { recursive: true, force: true });
|
|
6734
6759
|
ok = ci.status === 0 && pinned && noUnpinned && perms && conc && stillGate;
|
|
6735
6760
|
} catch {}
|
|
@@ -7194,12 +7219,15 @@ total++;
|
|
|
7194
7219
|
// ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
|
|
7195
7220
|
// 1.36.87: doctor 는 내부에서 selftest 를 돌린다 — 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
|
|
7196
7221
|
// 제한을 올린 대신 종료코드도 함께 단언한다 — 타임아웃(status=null)이 출력 매칭만으로 통과하면 안 된다(codex 26차 #11).
|
|
7197
|
-
// 1.36.
|
|
7198
|
-
//
|
|
7199
|
-
//
|
|
7200
|
-
|
|
7201
|
-
const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout:
|
|
7202
|
-
const
|
|
7222
|
+
// 1.36.161: 전체 E2E 108분 부하에서는 300s도 한 번 초과했다. 같은 픽스처 격리 실측은 en/ko 각각 139s,
|
|
7223
|
+
// 종료 0·언어 계약 통과였다. 측정 대상은 속도가 아니라 언어 렌더이므로 4배 이상 여유를 주고,
|
|
7224
|
+
// 다시 실패하면 status/error/시간/출력 꼬리를 아래 디버그에 남긴다.
|
|
7225
|
+
const docEnAt = Date.now();
|
|
7226
|
+
const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 600000, cwd: d });
|
|
7227
|
+
const docEnMs = Date.now() - docEnAt;
|
|
7228
|
+
const docKoAt = Date.now();
|
|
7229
|
+
const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 600000, cwd: d });
|
|
7230
|
+
const docKoMs = Date.now() - docKoAt;
|
|
7203
7231
|
const docEn = out(docEnR), docKo = out(docKoR);
|
|
7204
7232
|
const doctorOk = docEnR.status === 0 && docKoR.status === 0
|
|
7205
7233
|
&& /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
|
|
@@ -7224,11 +7252,11 @@ total++;
|
|
|
7224
7252
|
cp.spawnSync(process.execPath, [CLI, 'init', de, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
|
|
7225
7253
|
const snap = path.join(de, '.harness', 'environment.json');
|
|
7226
7254
|
const forceEnvChange = () => { try { const s = JSON.parse(fs.readFileSync(snap, 'utf8')); if (s.node) s.node.version = 'v0.0.0-test'; fs.writeFileSync(snap, JSON.stringify(s, null, 2) + '\n'); } catch {} };
|
|
7227
|
-
cp.spawnSync(process.execPath, [CLI, 'handoff', de], { encoding: 'utf8', timeout: 25000 }); // 첫 캡처(silent)
|
|
7255
|
+
cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--writeback'], { encoding: 'utf8', timeout: 25000 }); // 첫 legacy projection 캡처(silent)
|
|
7228
7256
|
forceEnvChange();
|
|
7229
|
-
const edEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
|
|
7257
|
+
const edEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--language', 'en', '--writeback'], { encoding: 'utf8', timeout: 25000 }));
|
|
7230
7258
|
forceEnvChange(); // en 실행이 스냅샷 갱신 → ko 위해 재변경
|
|
7231
|
-
const edKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de], { encoding: 'utf8', timeout: 25000 }));
|
|
7259
|
+
const edKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--writeback'], { encoding: 'utf8', timeout: 25000 }));
|
|
7232
7260
|
const edEnLines = edEn.split('\n').filter(l => /Runtime environment|env detect|change\(s\) detected/i.test(l));
|
|
7233
7261
|
const edEnOk = /Runtime environment/.test(edEn) && edEnLines.length >= 1 && !edEnLines.some(l => H.test(l));
|
|
7234
7262
|
const edKoOk = /실행 환경/.test(edKo);
|
|
@@ -7243,11 +7271,11 @@ total++;
|
|
|
7243
7271
|
try { const s = JSON.parse(fs.readFileSync(ssnap, 'utf8')); if (s.node) s.node.version = 'v0.0.0-test'; fs.writeFileSync(ssnap, JSON.stringify(s, null, 2) + '\n'); } catch {}
|
|
7244
7272
|
fs.writeFileSync(sfail, JSON.stringify({ failures: [{ cmd: 'ls && pwd', exitCode: 1, shell: 'powershell-5.1', issues: ['ps5-chain'] }] }, null, 2) + '\n');
|
|
7245
7273
|
};
|
|
7246
|
-
cp.spawnSync(process.execPath, [CLI, 'handoff', ds], { encoding: 'utf8', timeout: 25000 }); // 첫 캡처(silent)
|
|
7274
|
+
cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--writeback'], { encoding: 'utf8', timeout: 25000 }); // 첫 legacy projection 캡처(silent)
|
|
7247
7275
|
seedSh();
|
|
7248
|
-
const shEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
|
|
7276
|
+
const shEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--language', 'en', '--writeback'], { encoding: 'utf8', timeout: 25000 }));
|
|
7249
7277
|
seedSh(); // en 실행이 스냅샷 갱신 → ko 위해 재구성
|
|
7250
|
-
const shKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds], { encoding: 'utf8', timeout: 25000 }));
|
|
7278
|
+
const shKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--writeback'], { encoding: 'utf8', timeout: 25000 }));
|
|
7251
7279
|
const shEnLines = shEn.split('\n').filter(l => /shell guard|shell failure|review past shell|shell-guard|check before running/i.test(l));
|
|
7252
7280
|
const shEnOk = /Terminal shell guard/.test(shEn) && shEnLines.length >= 2 && !shEnLines.some(l => H.test(l));
|
|
7253
7281
|
const shKoOk = /셸 가드/.test(shKo);
|
|
@@ -7278,7 +7306,10 @@ total++;
|
|
|
7278
7306
|
ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
|
|
7279
7307
|
// 1.36.87: 단언 18개를 && 로 묶고 실패 시 어느 것인지 알려주지 않아, 진단하려면 22분짜리 스위트를 통째로
|
|
7280
7308
|
// 다시 돌려야 했다. 다른 블록들과 같은 규율으로 실패 플래그를 노출한다.
|
|
7281
|
-
if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk,
|
|
7309
|
+
if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk,
|
|
7310
|
+
doctor: { en: { status: docEnR.status, error: docEnR.error && docEnR.error.code, ms: docEnMs, tail: docEn.trim().split(/\r?\n/).slice(-2) },
|
|
7311
|
+
ko: { status: docKoR.status, error: docKoR.error && docKoR.error.code, ms: docKoMs, tail: docKo.trim().split(/\r?\n/).slice(-2) } },
|
|
7312
|
+
hoEnOk, hoKoOk, edEnOk, edKoOk, shEnOk, shKoOk, agEnOk, agKoOk, f3En, f3Ko, f4 }));
|
|
7282
7313
|
} catch (e) { console.log(' [i18n 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
7283
7314
|
console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4/1.30.5) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash/워크플로가이드/verify-claim) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
|
|
7284
7315
|
if (!ok) failed++;
|
|
@@ -9179,7 +9210,11 @@ total++;
|
|
|
9179
9210
|
&& (r.j.refusals || []).length === 1 && (r.j.refusals || [])[0].code === 'provider_not_authenticated';
|
|
9180
9211
|
// 같은 실행파일을 가리키는 **정상 별칭** 전부에서 유지돼야 한다 — 문자열 완전일치로 비교하면
|
|
9181
9212
|
// `codex.cmd`·대문자·절대경로 등록만으로 이번에 되살린 차단이 다시 사라진다(실측).
|
|
9182
|
-
|
|
9213
|
+
// POSIX executable names are case-sensitive and do not resolve `.cmd`; those are aliases only on Windows.
|
|
9214
|
+
// Treating CODEX as codex on Linux would hide a genuinely different (or missing) executable.
|
|
9215
|
+
const aliases = [null, 'codex']
|
|
9216
|
+
.concat(isWin ? ['CODEX', 'codex.cmd'] : [])
|
|
9217
|
+
.concat([path.join(shimDir, isWin ? 'codex.cmd' : 'codex')]);
|
|
9183
9218
|
let aliasOk = true, aliasBad = '';
|
|
9184
9219
|
for (const al of aliases) {
|
|
9185
9220
|
const w = mk(al);
|
|
@@ -10112,7 +10147,7 @@ total++;
|
|
|
10112
10147
|
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
10113
10148
|
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 120000 });
|
|
10114
10149
|
fs.writeFileSync(path.join(d, '.harness', 'agent-reminders.md'), body);
|
|
10115
|
-
cp.spawnSync(process.execPath, [CLI, 'handoff', '.'], { cwd: d, encoding: 'utf8', timeout: 180000 });
|
|
10150
|
+
cp.spawnSync(process.execPath, [CLI, 'handoff', '.', '--writeback'], { cwd: d, encoding: 'utf8', timeout: 180000 });
|
|
10116
10151
|
return path.join(d, '.harness', 'agent-reminders.md');
|
|
10117
10152
|
};
|
|
10118
10153
|
const B = '# 🔔 자동 reminder\ndrift critical 감지\n';
|
|
@@ -10485,6 +10520,8 @@ total++;
|
|
|
10485
10520
|
};
|
|
10486
10521
|
const DERIVED = /^(cache\/|last-handoff\.json|environment\.json|tech-profile\.json|update-cache\.json|provider-probe-cache\.json|leerness\.html)/;
|
|
10487
10522
|
// 스토어를 각각 건드리는 변경 명령들. seed 로 기존 항목을 만들어 **읽기가 실제로 일어나게** 한다.
|
|
10523
|
+
const SEED = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d],
|
|
10524
|
+
{ cwd: d, encoding: 'utf8', timeout: 300000, env: ENV0 });
|
|
10488
10525
|
const CASES = [
|
|
10489
10526
|
['task add', ['task', 'add', '락프로브']],
|
|
10490
10527
|
['decision add', ['decision', 'add', '락프로브', '--why', 'x']],
|
|
@@ -10492,6 +10529,16 @@ total++;
|
|
|
10492
10529
|
['rule add', ['rule', 'add', '락프로브', '--trigger', 'every-session']],
|
|
10493
10530
|
['rule pause', ['rule', 'pause', 'R-0001'], ['rule', 'add', '씨앗룰', '--trigger', 'every-session']],
|
|
10494
10531
|
['plan add', ['plan', 'add', '락프로브']],
|
|
10532
|
+
// 1.36.152 (T-0115): 같은 RMW 클래스의 덜 쓰이는 진입점도 실행 계측에 넣는다.
|
|
10533
|
+
['creds register', ['creds', 'register', '락프로브', '--env-var', 'LOCK_PROBE']],
|
|
10534
|
+
['creds refresh', ['creds', 'refresh', '락프로브'], d => SEED(d, ['creds', 'register', '락프로브', '--env-var', 'LOCK_PROBE'])],
|
|
10535
|
+
// team ID는 ASCII slug만 허용한다. 표시 라벨과 달리 seed/삭제 대상은 실제 유효 ID를 쓴다.
|
|
10536
|
+
['team remove', ['team', 'remove', 'lock-probe'], d => SEED(d, ['team', 'add', 'lock-probe', '--name', 'lock'])],
|
|
10537
|
+
['memory restore decisions', ['memory', 'restore', 'decisions', '락프로브'], d => { SEED(d, ['decision', 'add', '락프로브', '--why', 'x']); SEED(d, ['decision', 'drop', '락프로브']); }],
|
|
10538
|
+
['memory restore lessons', ['memory', 'restore', 'lessons', '락프로브'], d => { SEED(d, ['lesson', 'save', '락프로브']); SEED(d, ['lesson', 'drop', '락프로브']); }],
|
|
10539
|
+
['memory restore plan', ['memory', 'restore', 'plan', '락프로브'], d => { SEED(d, ['plan', 'add', '락프로브']); SEED(d, ['plan', 'remove', '락프로브']); }],
|
|
10540
|
+
['plan drop', ['plan', 'drop', '락프로브']],
|
|
10541
|
+
['plan remove', ['plan', 'remove', '락프로브'], d => SEED(d, ['plan', 'add', '락프로브'])],
|
|
10495
10542
|
['preview add', ['preview', 'add', '락프로브', '--design', 'd', '--features', 'f']],
|
|
10496
10543
|
['requests add', ['requests', 'add', '락프로브']],
|
|
10497
10544
|
['next-action add', ['next-action', 'add', '락프로브']],
|
|
@@ -12378,9 +12425,9 @@ total++;
|
|
|
12378
12425
|
{ runners: selfNoBin.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
|
|
12379
12426
|
// ⚠ 비교 대상을 `orphanScripts` 하나로 두면 약한 불변식이다(검수 P3) — 판정 전체를 비교한다.
|
|
12380
12427
|
// `orphanFiles` 를 통째로 빼면 자기참조 회귀를 못 잡는다(검수 P3) — **측정된 차이만 허용**한다.
|
|
12381
|
-
// 허용치:
|
|
12382
|
-
//
|
|
12383
|
-
const _FILE_ALLOW = new Set(['scripts/e2e-core.js']);
|
|
12428
|
+
// 허용치: e2e.js가 직접 실행하는 보조 테스트 세 개. e2e.js를 입력에서 빼면 이 파일들은
|
|
12429
|
+
// 고아처럼 보이지만, 실제 CI 진입점인 e2e.js에서 실행된다. 그 밖의 차이는 자기참조 회귀다.
|
|
12430
|
+
const _FILE_ALLOW = new Set(['scripts/e2e-core.js', 'scripts/e2e-concurrency.js', 'scripts/release-bump-probe.js']);
|
|
12384
12431
|
const _shape = (x) => JSON.stringify({
|
|
12385
12432
|
s: x.orphanScripts.slice().sort(),
|
|
12386
12433
|
d: x.dynamicRunners.slice().sort(), p: x.enumPrefixes.slice().sort(),
|
|
@@ -12793,7 +12840,7 @@ total++;
|
|
|
12793
12840
|
if (!planned.includes('.cursor/mcp.json')) bad.push(`②b계획에 .cursor/mcp.json 없음(${JSON.stringify(planned)})`);
|
|
12794
12841
|
const rR = RA(dR, ['adapter', 'cursor', '--path', dR]);
|
|
12795
12842
|
if (rR.status !== 0) bad.push(`②bcursor adapter exit=${rR.status}`);
|
|
12796
|
-
for (const f of ['.cursor/mcp.json', '.mcp.json']) {
|
|
12843
|
+
for (const f of ['.cursor/mcp.json', '.mcp.json', '.cursor/hooks.json', '.cursor/hooks/leerness-session.cjs']) {
|
|
12797
12844
|
if (fs.existsSync(path.join(dR, f)) && !planned.includes(f)) bad.push(`②b계획에 없던 파일을 생성: ${f}`);
|
|
12798
12845
|
}
|
|
12799
12846
|
dbg.cursor = { planned };
|
|
@@ -12832,6 +12879,7 @@ total++;
|
|
|
12832
12879
|
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDECODE',
|
|
12833
12880
|
'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
|
|
12834
12881
|
'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE', 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'CI', 'GITHUB_ACTIONS',
|
|
12882
|
+
'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID',
|
|
12835
12883
|
'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envS[k];
|
|
12836
12884
|
// 캐너리 키는 **실행 시 생성**한다 — 소스에 적으면 이 파일이 러너로 읽히는 자기참조 함정(8회차)에 걸린다.
|
|
12837
12885
|
const KEY = (tag) => `sess-${process.pid.toString(36)}-${tag}`;
|
|
@@ -12930,17 +12978,20 @@ total++;
|
|
|
12930
12978
|
if (!(sj.total >= 1)) bad.push('⑥sessions 가 레코드를 못 봄');
|
|
12931
12979
|
}
|
|
12932
12980
|
|
|
12933
|
-
// ⑦ git 유출 없음 — 세션
|
|
12981
|
+
// ⑦ git 유출 없음 — 세션 파일과 기본 handoff의 동적 projection은 보이지 않고,
|
|
12982
|
+
// **의도적으로 만든 다른 .harness 파일은 보인다**(git 이 실제로 관측 중임을 증명).
|
|
12934
12983
|
const dG = mkS('git');
|
|
12935
12984
|
const gi = cp.spawnSync('git', ['init', '-q'], { cwd: dG, encoding: 'utf8', timeout: 60000 });
|
|
12936
12985
|
if (gi.status !== 0) bad.push('⑦git init 실패 — 이 검사는 스킵이 아니라 실패다(측정 붕괴를 통과로 세지 않는다)');
|
|
12937
12986
|
else {
|
|
12938
12987
|
RS(dG, ['handoff', dG], { CLAUDE_CODE_SESSION_ID: KEY('g') }); cliCases++;
|
|
12988
|
+
fs.writeFileSync(path.join(dG, '.harness', 'git-visible-control.txt'), 'control\n');
|
|
12939
12989
|
// ⚠ `--porcelain` 만 쓰면 untracked 디렉터리가 `?? .harness/` 한 줄로 **접혀서** 양쪽 판정이 모두
|
|
12940
12990
|
// 공허해진다(내 대조군이 실제로 그걸 잡았다). `-uall` 로 파일 단위 열거를 강제한다.
|
|
12941
12991
|
const st = String(cp.spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dG, encoding: 'utf8', timeout: 60000 }).stdout || '');
|
|
12942
12992
|
if (/cache[\\/]sessions/.test(st)) bad.push('⑦세션 파일이 git 에 노출됨');
|
|
12943
|
-
if (
|
|
12993
|
+
if (/environment\.json|last-handoff\.json|tech-profile\.json/.test(st)) bad.push('⑦기본 handoff 동적 projection이 git 에 노출됨');
|
|
12994
|
+
if (!/git-visible-control\.txt/.test(st)) bad.push('⑦대조군 실패 — git 이 .harness 를 아예 안 보고 있음(위 판정이 공허)');
|
|
12944
12995
|
observedRecords += nRec(dG);
|
|
12945
12996
|
}
|
|
12946
12997
|
|
|
@@ -13251,10 +13302,13 @@ total++;
|
|
|
13251
13302
|
if (present !== N) bad.push(`①동시 쓰기 유실 ${N - present}행 (${present}/${N} 생존)`);
|
|
13252
13303
|
if (crashed > 0) bad.push(`①자식 크래시 ${crashed}건 — rename/락 경합에서 죽었다: ${errs[0] || '?'}`);
|
|
13253
13304
|
// 1.36.132 (검수 P1): 유실 0 만 보면 "보호 없이 진행했는데 운 좋게 안 겹친" 경우를 통과시킨다.
|
|
13254
|
-
// fail-open 은 그 자체로
|
|
13255
|
-
|
|
13305
|
+
// 사용자 상태의 fail-open 은 그 자체로 실패다. 1.36.160부터 손실 허용을 명시한 usage telemetry만
|
|
13306
|
+
// 별도 문구+파일명으로 fail-open 할 수 있으므로 상태 보호 실패와 섞어 세지 않는다.
|
|
13307
|
+
const lockWarnings = errs.filter(e => /락 획득 실패/.test(e));
|
|
13308
|
+
const telemetryFailOpen = lockWarnings.filter(e => /비핵심 기록만 보호 없이 진행합니다: usage-stats\.json\b/.test(e)).length;
|
|
13309
|
+
const failOpen = lockWarnings.length - telemetryFailOpen;
|
|
13256
13310
|
if (failOpen > 0) bad.push(`①락 fail-open ${failOpen}건 — 보호 없이 진행했다(유실이 없어도 실패다)`);
|
|
13257
|
-
dbg.concurrent = { N, present, crashed, failOpen
|
|
13311
|
+
dbg.concurrent = { N, present, crashed, failOpen, telemetryFailOpen, ms: Date.now() - t0 };
|
|
13258
13312
|
}
|
|
13259
13313
|
|
|
13260
13314
|
// ② 배달 — 세션 신호가 **4표면 전부**에 나온다. 종전엔 평문 헤드라인 1곳뿐이었고,
|
|
@@ -13468,7 +13522,7 @@ total++;
|
|
|
13468
13522
|
ok = bad.length === 0;
|
|
13469
13523
|
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
13470
13524
|
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
13471
|
-
console.log(ok ? `✓ AA(1.36.132/UR-0069) 다중세션 토대: 동시 24쓰기 무유실·무크래시(
|
|
13525
|
+
console.log(ok ? `✓ AA(1.36.132/UR-0069) 다중세션 토대: 동시 24쓰기 무유실·무크래시(상태락 fail-open 차단 · 비핵심 telemetry 분리 · rename 동시독자 EPERM 차단) · 세션 신호가 4표면 전부에 도달(평문·--compact·--json·훅, 훅은 쓰기 0 유지) · claude 아닌 에이전트도 주소를 갖고 서로를 봄 + 주소 없으면 그 사실을 말함 · 죽은 채널(agent-reminders) 노출 · 단일 흐름 오탐 0 ${JSON.stringify(dbg)}`
|
|
13472
13526
|
: '✗ 1.36.132 다중세션 토대 실패 ' + JSON.stringify({ bad: bad.slice(0, 10), dbg }));
|
|
13473
13527
|
if (!ok) failed++;
|
|
13474
13528
|
}
|
|
@@ -13698,6 +13752,11 @@ total++;
|
|
|
13698
13752
|
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
13699
13753
|
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
13700
13754
|
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
|
|
13755
|
+
// handoff freshness is session-scoped and handoff-only. Keep the fixture on one explicit address so both the
|
|
13756
|
+
// CLI and the git hook inspect the same ignored marker instead of the presence lifecycle/legacy projection.
|
|
13757
|
+
const ENFORCE_SESSION = 'e2e-enforce-runtime';
|
|
13758
|
+
envP.LEERNESS_SESSION_ID = ENFORCE_SESSION;
|
|
13759
|
+
const handoffRecord = (d) => path.join(d, '.harness', 'cache', 'handoffs', ENFORCE_SESSION + '.json');
|
|
13701
13760
|
const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
13702
13761
|
{ cwd: d, encoding: 'utf8', timeout: 300000, env: envP, maxBuffer: 32 * 1024 * 1024 });
|
|
13703
13762
|
// ⚠ 1.36.140 (재검수 P1): 픽스처가 사용자의 **전역/시스템 git 설정을 상속**하면,
|
|
@@ -13821,22 +13880,34 @@ total++;
|
|
|
13821
13880
|
// (제품이 쓰는 판별을 테스트가 직접 재현해 본다: handoff 흔적이 창 안이면 통과, 창 밖이면 차단.)
|
|
13822
13881
|
{
|
|
13823
13882
|
const d = cases.ok;
|
|
13883
|
+
RP(d, ['handoff', d]);
|
|
13824
13884
|
const ri = J(RP(d, ['enforce', 'install', '--path', d, '--json']));
|
|
13825
13885
|
if (!ri || ri.verified !== 'fired') bad.push(`⑩정상 저장소 설치의 verified=${ri && ri.verified} (기대 fired)`);
|
|
13826
13886
|
const hookP = ri && ri.hook;
|
|
13827
13887
|
if (hookP && fs.existsSync(hookP)) {
|
|
13828
|
-
const lhp =
|
|
13888
|
+
const lhp = handoffRecord(d);
|
|
13829
13889
|
const stamp = (ageH) => {
|
|
13830
|
-
fs.writeFileSync(lhp, JSON.stringify({ last: new Date(Date.now() - ageH * 3600000).toISOString() }, null, 2));
|
|
13831
13890
|
const t = new Date(Date.now() - ageH * 3600000);
|
|
13832
13891
|
try { fs.utimesSync(lhp, t, t); } catch {}
|
|
13833
13892
|
};
|
|
13834
|
-
|
|
13893
|
+
let sh = 'sh';
|
|
13894
|
+
if (process.platform === 'win32') {
|
|
13895
|
+
const ep = G(d, ['--exec-path']);
|
|
13896
|
+
const installRoot = ep.status === 0 ? path.resolve(String(ep.stdout || '').trim(), '..', '..', '..') : null;
|
|
13897
|
+
if (installRoot) {
|
|
13898
|
+
sh = [path.join(installRoot, 'bin', 'sh.exe'), path.join(installRoot, 'usr', 'bin', 'sh.exe')].find(p => fs.existsSync(p)) || sh;
|
|
13899
|
+
}
|
|
13900
|
+
}
|
|
13901
|
+
const run = () => cp.spawnSync(sh, [hookP], { cwd: d, encoding: 'utf8', timeout: 20000, env: envP }).status;
|
|
13835
13902
|
stamp(0); const a = run();
|
|
13836
13903
|
stamp(240); const b = run();
|
|
13837
|
-
|
|
13838
|
-
|
|
13839
|
-
|
|
13904
|
+
// 외부 Codex P1: presence JSON의 mtime이 아니라 handoff-only marker를 봐야 한다.
|
|
13905
|
+
// stale marker 뒤 session close를 해도 gate가 열리면 close가 handoff로 둔갑한 것이다.
|
|
13906
|
+
RP(d, ['session', 'close', d, '--json']);
|
|
13907
|
+
const c = run();
|
|
13908
|
+
if (a === null || b === null || c === null) bad.push('⑩sh 로 훅을 돌리지 못함(계측 불가)');
|
|
13909
|
+
else if (!(a === 0 && b !== 0 && c !== 0)) bad.push(`⑩훅 freshness 불변식 실패(창안=${a} 창밖=${b} close후=${c}) — close가 handoff로 둔갑하거나 판별이 공허하다`);
|
|
13910
|
+
dbg.discriminates = { inWindow: a, outWindow: b, afterClose: c };
|
|
13840
13911
|
}
|
|
13841
13912
|
}
|
|
13842
13913
|
|
|
@@ -13857,7 +13928,7 @@ total++;
|
|
|
13857
13928
|
const ins = J(RP(P, ['enforce', 'install', '--path', P, '--json']));
|
|
13858
13929
|
if (!ins || ins.ok !== true) bad.push(`⑬하위 디렉토리 설치 실패 ok=${ins && ins.ok} code=${ins && ins.code}`);
|
|
13859
13930
|
if (!ins || ins.verified !== 'fired') bad.push(`⑬하위 설치 verified=${ins && ins.verified}`);
|
|
13860
|
-
const lhp =
|
|
13931
|
+
const lhp = handoffRecord(P);
|
|
13861
13932
|
const stamp = (ageH) => { const t = new Date(Date.now() - ageH * 3600000); try { fs.utimesSync(lhp, t, t); } catch {} };
|
|
13862
13933
|
// 오래된 handoff → **루트에서** 커밋하면 차단돼야 한다(여기가 우회 지점이었다).
|
|
13863
13934
|
stamp(200);
|
|
@@ -13920,7 +13991,7 @@ total++;
|
|
|
13920
13991
|
if (/ENFORCE_PROBE|leerness-probe/.test(body)) bad.push('⑮출하되는 훅에 검증용 우회 스위치가 남아 있다');
|
|
13921
13992
|
} else bad.push('⑮훅 파일을 찾지 못함(계측 붕괴)');
|
|
13922
13993
|
// ② 행위로 확인 — 오래된 handoff 에서 어떤 스위치를 써도 커밋이 통과하면 안 된다.
|
|
13923
|
-
const lhp =
|
|
13994
|
+
const lhp = handoffRecord(d);
|
|
13924
13995
|
const old = new Date(Date.now() - 200 * 3600000); try { fs.utimesSync(lhp, old, old); } catch {}
|
|
13925
13996
|
fs.writeFileSync(path.join(d, 'p1.txt'), '1'); G(d, ['add', '-A']);
|
|
13926
13997
|
const viaEnv = cp.spawnSync('git', ['--no-optional-locks', '-C', d, 'commit', '-m', 'env'],
|
|
@@ -14032,14 +14103,14 @@ total++;
|
|
|
14032
14103
|
dbg.cfgFail = { code: r && r.code, hookLeft: left, recovered: r2 && r2.ok };
|
|
14033
14104
|
}
|
|
14034
14105
|
|
|
14035
|
-
// ⑫ **설치 행위가 자기가 설치하는 게이트를 약화시키지 않는다.** 발화 검증은
|
|
14036
|
-
// 임시로
|
|
14106
|
+
// ⑫ **설치 행위가 자기가 설치하는 게이트를 약화시키지 않는다.** 발화 검증은 현재 세션의 runtime record를
|
|
14107
|
+
// 임시로 다루며, 훅이 신선도를 판정하는 근거가 그 파일의 **mtime** 이다 — 안 되돌리면 설치만으로
|
|
14037
14108
|
// "방금 handoff 했다" 가 되어 다음 windowHours 동안 무임승차가 생긴다(실측으로 그렇게 만들었다가 고쳤다).
|
|
14038
14109
|
{
|
|
14039
14110
|
const d = mk('selfweaken', commit);
|
|
14040
14111
|
RP(d, ['handoff', d]);
|
|
14041
|
-
const lhp =
|
|
14042
|
-
if (!fs.existsSync(lhp)) bad.push('⑫셋업 실패:
|
|
14112
|
+
const lhp = handoffRecord(d);
|
|
14113
|
+
if (!fs.existsSync(lhp)) bad.push('⑫셋업 실패: 세션 handoff record 없음');
|
|
14043
14114
|
else {
|
|
14044
14115
|
const oldT = new Date(Date.now() - 100 * 3600000);
|
|
14045
14116
|
fs.utimesSync(lhp, oldT, oldT);
|
|
@@ -14048,7 +14119,7 @@ total++;
|
|
|
14048
14119
|
const ins = J(RP(d, ['enforce', 'install', '--path', d, '--json']));
|
|
14049
14120
|
const after = Math.round(fs.statSync(lhp).mtimeMs);
|
|
14050
14121
|
if (!ins || ins.ok !== true) bad.push(`⑫설치 실패 ok=${ins && ins.ok}`);
|
|
14051
|
-
if (fs.readFileSync(lhp, 'utf8') !== before_txt) bad.push('⑫설치가
|
|
14122
|
+
if (fs.readFileSync(lhp, 'utf8') !== before_txt) bad.push('⑫설치가 세션 handoff record 내용을 바꿨다');
|
|
14052
14123
|
if (Math.abs(after - before) > 1500) bad.push(`⑫설치가 handoff mtime 을 갱신했다(${before}→${after}) — 설치가 게이트를 약화시킨다`);
|
|
14053
14124
|
// 행위로 확인 — 오래된 handoff 상태이므로 설치 직후 커밋은 **차단**돼야 한다.
|
|
14054
14125
|
fs.writeFileSync(path.join(d, 'z.txt'), 'z'); G(d, ['add', '-A']);
|
|
@@ -14171,14 +14242,19 @@ total++;
|
|
|
14171
14242
|
// ("실패했다" 로 재면 다른 이유로 실패해도 통과하는 공허한 단언이 된다.)
|
|
14172
14243
|
const gm = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
|
|
14173
14244
|
const own = path.join(sb, 'own'), victim = path.join(sb, 'victim');
|
|
14245
|
+
const pathKey = (p) => {
|
|
14246
|
+
let q = path.resolve(String(p));
|
|
14247
|
+
try { q = fs.realpathSync.native ? fs.realpathSync.native(q) : fs.realpathSync(q); } catch {}
|
|
14248
|
+
return process.platform === 'win32' ? q.toLowerCase() : q;
|
|
14249
|
+
};
|
|
14174
14250
|
const seenDir = (envx) => {
|
|
14175
14251
|
const r = gm.gitSpawn(['rev-parse', '--absolute-git-dir'], { cwd: own, encoding: 'utf8', env: envx });
|
|
14176
14252
|
return r.status === 0 ? path.resolve(String(r.stdout).trim()) : 'ERR:' + r.status;
|
|
14177
14253
|
};
|
|
14178
14254
|
const clean = seenDir(envP);
|
|
14179
14255
|
const poisonedDir = seenDir(Object.assign({}, envP, { GIT_DIR: path.join(victim, '.git') }));
|
|
14180
|
-
if (clean !== path.resolve(own, '.git')) bad.push(`④정상 환경에서 제 저장소를 못 찾음(${clean}) — 판별 불가`);
|
|
14181
|
-
if (poisonedDir !== clean) bad.push(`④GIT_DIR 이 초크포인트를 통과해 대상이 바뀜(${poisonedDir})`);
|
|
14256
|
+
if (pathKey(clean) !== pathKey(path.resolve(own, '.git'))) bad.push(`④정상 환경에서 제 저장소를 못 찾음(${clean}) — 판별 불가`);
|
|
14257
|
+
if (pathKey(poisonedDir) !== pathKey(clean)) bad.push(`④GIT_DIR 이 초크포인트를 통과해 대상이 바뀜(${poisonedDir})`);
|
|
14182
14258
|
dbg.chokeScrub = { clean, poisoned: poisonedDir };
|
|
14183
14259
|
}
|
|
14184
14260
|
ok = bad.length === 0;
|
|
@@ -14348,13 +14424,15 @@ total++;
|
|
|
14348
14424
|
if (!targetHook) bad.push('⑤지목한 저장소에 훅이 없음 — 강제가 아예 걸리지 않았다');
|
|
14349
14425
|
dbg.cfgInject = { foreignHook, targetHook };
|
|
14350
14426
|
}
|
|
14351
|
-
// ⑥ 공유 판정이 **모양이 아니라 실제 위치**로 —
|
|
14427
|
+
// ⑥ 공유 판정이 **모양이 아니라 실제 위치**로 — Windows 에서는 `.GIT/hooks` 가 `.git/hooks` 와
|
|
14428
|
+
// 같은 위치라 비공유다. POSIX 에서는 서로 다른 외부 경로이므로 공유 위험으로 분류해야 한다.
|
|
14352
14429
|
{
|
|
14353
14430
|
const d = mk('casehooks');
|
|
14354
14431
|
G(d, ['config', 'core.hooksPath', '.GIT/hooks']);
|
|
14355
14432
|
const j = J(RP(d, ['enforce', 'status', '--path', d, '--json']));
|
|
14356
|
-
|
|
14357
|
-
|
|
14433
|
+
const expected = process.platform === 'win32' ? false : true;
|
|
14434
|
+
if (!j || j.hooksPathShared !== expected) bad.push(`⑥플랫폼 경로 의미 오판(hooksPathShared=${j && j.hooksPathShared}, expected=${expected})`);
|
|
14435
|
+
dbg.caseHooks = { shared: j && j.hooksPathShared, expected };
|
|
14358
14436
|
}
|
|
14359
14437
|
// ⑦ 연결된 worktree 사실을 고지한다(설정이 없어도 훅은 공용이다).
|
|
14360
14438
|
{
|
|
@@ -14519,9 +14597,18 @@ total++;
|
|
|
14519
14597
|
envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gitconfig-system');
|
|
14520
14598
|
try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
|
|
14521
14599
|
const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
|
|
14522
|
-
const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14523
|
-
{ cwd: d, encoding: 'utf8', timeout: 60000, env: envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14600
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14601
|
+
{ cwd: d, encoding: 'utf8', timeout: 60000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14524
14602
|
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
14603
|
+
// ③과 ③a가 같은 초기 저장소 계약을 사용한다. ③a는 별도 블록이므로 이 범위에 둬야 한다.
|
|
14604
|
+
const mkRepo = (name) => {
|
|
14605
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14606
|
+
G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
|
|
14607
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14608
|
+
fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
|
|
14609
|
+
RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
|
|
14610
|
+
return d;
|
|
14611
|
+
};
|
|
14525
14612
|
const cwd = path.join(sb, 'cwd'); fs.mkdirSync(cwd, { recursive: true });
|
|
14526
14613
|
try {
|
|
14527
14614
|
// ① 전수 — 제품 자신에게 명령 목록을 묻고, 하위명령까지 펴서 없는 `--path` 로 부른다.
|
|
@@ -14578,14 +14665,6 @@ total++;
|
|
|
14578
14665
|
}
|
|
14579
14666
|
// ③ worktree 형제 차단을 **추정하지 않고 각 worktree 에게 묻는다**.
|
|
14580
14667
|
{
|
|
14581
|
-
const mkRepo = (name) => {
|
|
14582
|
-
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14583
|
-
G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
|
|
14584
|
-
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14585
|
-
fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
|
|
14586
|
-
RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
|
|
14587
|
-
return d;
|
|
14588
|
-
};
|
|
14589
14668
|
const m1 = mkRepo('wt-shared');
|
|
14590
14669
|
const add1 = G(m1, ['worktree', 'add', '-qb', 'lk', path.join(sb, 'wt-shared-linked')]);
|
|
14591
14670
|
const j1 = J(RP(m1, ['enforce', 'install', '--path', m1, '--json', '--skip-verify']));
|
|
@@ -14602,11 +14681,56 @@ total++;
|
|
|
14602
14681
|
}
|
|
14603
14682
|
dbg.worktree = { shared: j1 && j1.worktrees, perWorktree: j2 && j2.worktrees };
|
|
14604
14683
|
}
|
|
14684
|
+
// ③a 아직 없는 pre-commit의 **조상** junction도 실제 위치로 풀어 형제 차단을 숨기지 않는다.
|
|
14685
|
+
// 첫 판은 훅 파일 자체만 realpath 했다. 설치 전에는 파일이 없어 두 worktree가 같은 훅을 보는데도 1개라고
|
|
14686
|
+
// 답했고, 설치 직후 형제 커밋은 실제로 차단됐다. 가장 깊은 기존 조상(link-hooks)을 풀어야 한다.
|
|
14687
|
+
{
|
|
14688
|
+
const main = mkRepo('wt-ancestor-junction');
|
|
14689
|
+
const sibling = path.join(sb, 'wt-ancestor-junction-linked');
|
|
14690
|
+
const add = G(main, ['worktree', 'add', '-qb', 'lj', sibling]);
|
|
14691
|
+
let linked = false;
|
|
14692
|
+
const actual = path.join(main, '.git', 'shared-hooks');
|
|
14693
|
+
const alias = path.join(main, '.git', 'link-hooks');
|
|
14694
|
+
let before = null, installed = null, siblingCommit = null;
|
|
14695
|
+
if (add.status !== 0) bad.push(`③a junction worktree 생성 실패(${add.status}) — 판별 불가`);
|
|
14696
|
+
else {
|
|
14697
|
+
try {
|
|
14698
|
+
fs.mkdirSync(actual, { recursive: true });
|
|
14699
|
+
fs.mkdirSync(path.join(sibling, '.harness'), { recursive: true });
|
|
14700
|
+
fs.symlinkSync(actual, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
|
14701
|
+
linked = true;
|
|
14702
|
+
} catch (e) {
|
|
14703
|
+
dbg.ancestorJunction = { skipped: 'junction/symlink 생성 불가', error: String(e && e.code || e).slice(0, 80) };
|
|
14704
|
+
}
|
|
14705
|
+
if (linked) {
|
|
14706
|
+
const cfg = [
|
|
14707
|
+
G(main, ['config', 'extensions.worktreeConfig', 'true']),
|
|
14708
|
+
G(main, ['config', '--worktree', 'core.hooksPath', alias]),
|
|
14709
|
+
G(sibling, ['config', '--worktree', 'core.hooksPath', actual]),
|
|
14710
|
+
];
|
|
14711
|
+
if (cfg.some((r) => r.status !== 0)) bad.push('③a worktree별 hooksPath 설정 실패 — 판별 불가');
|
|
14712
|
+
else {
|
|
14713
|
+
before = J(RP(main, ['enforce', 'status', '--path', main, '--json']));
|
|
14714
|
+
installed = J(RP(main, ['enforce', 'install', '--path', main, '--json', '--skip-verify']));
|
|
14715
|
+
// Some Git for Windows runners skip the pre-commit hook for an empty
|
|
14716
|
+
// commit. Stage a real change so this always measures the normal path.
|
|
14717
|
+
fs.writeFileSync(path.join(sibling, 'ancestor-junction-probe.txt'), 'probe\n');
|
|
14718
|
+
G(sibling, ['add', '-A']);
|
|
14719
|
+
siblingCommit = G(sibling, ['commit', '-m', 'ancestor-junction-probe']);
|
|
14720
|
+
if (!before || before.worktrees !== 2) bad.push(`③a 없는 훅 파일의 조상 junction을 못 풀어 형제 차단을 숨김(before=${before && before.worktrees})`);
|
|
14721
|
+
if (!installed || installed.worktrees !== 2) bad.push(`③a 설치 응답도 공유 worktree를 1개로 오보고(installed=${installed && installed.worktrees})`);
|
|
14722
|
+
if (!siblingCommit || siblingCommit.status === 0) bad.push('③a 픽스처가 실제 형제 커밋 차단을 증명하지 못함');
|
|
14723
|
+
}
|
|
14724
|
+
dbg.ancestorJunction = { before: before && before.worktrees, installed: installed && installed.worktrees,
|
|
14725
|
+
siblingCommitExit: siblingCommit && siblingCommit.status, sameTarget: fs.realpathSync(alias) === fs.realpathSync(actual) };
|
|
14726
|
+
}
|
|
14727
|
+
}
|
|
14728
|
+
}
|
|
14605
14729
|
// ④ 세척 클래스의 **경계** — 지워야 할 것과 남겨야 할 것을 같이 잰다(한쪽만 재면 과세척이 통과한다).
|
|
14606
14730
|
{
|
|
14607
14731
|
const g = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
|
|
14608
|
-
const mustDrop = ['GIT_DIR', 'git_dir', '
|
|
14609
|
-
const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
|
|
14732
|
+
const mustDrop = ['GIT_DIR', 'git_dir', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_7', 'GIT_CONFIG_VALUE_7'];
|
|
14733
|
+
const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
|
|
14610
14734
|
const droppedNot = mustDrop.filter((k) => !g._shouldDrop(k));
|
|
14611
14735
|
const keptNot = mustKeep.filter((k) => g._shouldDrop(k));
|
|
14612
14736
|
if (droppedNot.length) bad.push(`④세척해야 하는데 안 함: ${droppedNot.join(', ')}`);
|
|
@@ -14616,7 +14740,7 @@ total++;
|
|
|
14616
14740
|
ok = bad.length === 0;
|
|
14617
14741
|
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
14618
14742
|
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
14619
|
-
console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정 · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
|
|
14743
|
+
console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정(없는 훅 파일의 조상 junction 포함) · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
|
|
14620
14744
|
: '✗ 1.36.143 경로/공유 판정 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
14621
14745
|
if (!ok) failed++;
|
|
14622
14746
|
}
|
|
@@ -14684,6 +14808,22 @@ total++;
|
|
|
14684
14808
|
if (okExits.some((x) => x !== 0)) bad.push(`①플래그 없는 정상 실행이 막힘(exits=${JSON.stringify(okExits)})`);
|
|
14685
14809
|
dbg.dryRun = { wrote: wrote.length, notRefused: notRefused.length, realExits: okExits };
|
|
14686
14810
|
}
|
|
14811
|
+
// ①a T-0117 재검수: 기존 픽스처는 세션 주소를 지워 프레즌스 기록 경로가 아예 실행되지 않았다.
|
|
14812
|
+
// 그러면 `session close --dry-run` 이 .harness/cache/sessions/.host-salt와 세션 JSON을
|
|
14813
|
+
// 남기는 실제 변경을 "트리 불변"으로 오판한다. 주소가 있는 실제 Codex/Claude 환경을 명시해
|
|
14814
|
+
// 캐시도 dry-run 계약(영속 파일 변경 0)의 대상임을 실행으로 고정한다.
|
|
14815
|
+
{
|
|
14816
|
+
const d = mkGit('dry-presence');
|
|
14817
|
+
const sessionEnv = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'dryguard001' });
|
|
14818
|
+
const before = snap(d);
|
|
14819
|
+
const r = RP(d, ['session', 'close', '--dry-run', '--path', d], sessionEnv);
|
|
14820
|
+
const changed = diffKeys(before, snap(d));
|
|
14821
|
+
const sessionDir = path.join(d, '.harness', 'cache', 'sessions');
|
|
14822
|
+
if (r.status === 0) bad.push('①a session close --dry-run 이 쓰기 차단 뒤에도 성공이라 보고');
|
|
14823
|
+
if (changed.length) bad.push(`①a 세션 주소가 있을 때 --dry-run 이 바꾼 것: ${changed.slice(0, 4).join(',')}`);
|
|
14824
|
+
if (fs.existsSync(sessionDir)) bad.push('①a --dry-run 이 session-presence cache를 생성');
|
|
14825
|
+
dbg.dryPresence = { exit: r.status, changed: changed.length, sessionCacheCreated: fs.existsSync(sessionDir) };
|
|
14826
|
+
}
|
|
14687
14827
|
// ② `--path=` / 값 없는 `--path` 가 가드를 우회해 cwd 에 쓰지 않는다.
|
|
14688
14828
|
{
|
|
14689
14829
|
const d = mkGit('emptypath');
|
|
@@ -15083,16 +15223,21 @@ total++;
|
|
|
15083
15223
|
return d;
|
|
15084
15224
|
};
|
|
15085
15225
|
try {
|
|
15086
|
-
// ① 명시
|
|
15226
|
+
// ① 명시 주소는 상태 귀속에서 child 억제를 이긴다. presence는 부모의 env 주소까지 상속될 수
|
|
15227
|
+
// 있으므로 child를 계속 억제한다. 실제 MCP 호출별 귀속은 바로 아래 행위검사가 증명한다.
|
|
15087
15228
|
{
|
|
15088
15229
|
const SP = require(path.resolve(__dirname, '..', 'lib', 'session-presence'));
|
|
15089
15230
|
const plain = SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'session-alpha01' });
|
|
15090
15231
|
const child = SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'session-alpha01', CLAUDE_CODE_CHILD_SESSION: '1' });
|
|
15091
15232
|
const inferred = SP.deriveSessionKey({ CLAUDE_CODE_SESSION_ID: 'claude-sess-0001', CLAUDE_CODE_CHILD_SESSION: '1' });
|
|
15233
|
+
const explicitSuppression = SP.suppressionReason({ LEERNESS_SESSION_ID: 'session-alpha01', CLAUDE_CODE_CHILD_SESSION: '1' }, true);
|
|
15234
|
+
const inferredSuppression = SP.suppressionReason({ CLAUDE_CODE_SESSION_ID: 'claude-sess-0001', CLAUDE_CODE_CHILD_SESSION: '1' }, true);
|
|
15092
15235
|
if (plain !== 'session-alpha01') bad.push('①명시 주소를 못 읽음 — 판별 불가');
|
|
15093
15236
|
if (child !== 'session-alpha01') bad.push('①child 억제가 명시 주소를 삼킴');
|
|
15094
15237
|
if (inferred !== null) bad.push('①추론 주소에서 child 억제가 사라짐 — sub-agent 가 별도 세션이 된다(회귀)');
|
|
15095
|
-
|
|
15238
|
+
if (explicitSuppression !== 'child-agent') bad.push(`①presence가 상속된 child 주소를 독립 세션으로 오인(${explicitSuppression})`);
|
|
15239
|
+
if (inferredSuppression !== 'child-agent') bad.push(`①주소 없는 child presence 억제가 사라짐(${inferredSuppression})`);
|
|
15240
|
+
dbg.keySource = { plain, child, inferredUnderChild: inferred, explicitSuppression, inferredSuppression };
|
|
15096
15241
|
}
|
|
15097
15242
|
// ② **행위** — 자식 세션 env 를 상속한 MCP 서버에서도 증거가 안 섞인다.
|
|
15098
15243
|
{
|
|
@@ -15172,11 +15317,157 @@ total++;
|
|
|
15172
15317
|
ok = bad.length === 0;
|
|
15173
15318
|
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
15174
15319
|
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
15175
|
-
console.log(ok ? `✓ AL(1.36.148) 명시
|
|
15320
|
+
console.log(ok ? `✓ AL(1.36.148) 상태용 명시 주소는 child에서도 유지·presence 상속 주소는 억제 · 자식 env MCP 에서도 귀속 정상 · 경로형 run id 가 .leerness 밖으로 못 나감 · --path 에 파일 거절(디렉토리는 통과) · dry-run 이 **외부 명령 실행**까지 막음(플래그 없으면 실행) ${JSON.stringify(dbg)}`
|
|
15176
15321
|
: '✗ 1.36.148 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
15177
15322
|
if (!ok) failed++;
|
|
15178
15323
|
}
|
|
15179
15324
|
|
|
15325
|
+
total++;
|
|
15326
|
+
// AM(1.36.149) — 1.36.148 의 run-id 경로 차단은 소유 맵만 덮었다.
|
|
15327
|
+
// legacy `currentRunId` 와 디스크 run record 의 `run_id` 도 같은 파일 경로 병목을 탄다.
|
|
15328
|
+
// 둘 중 하나가 경로형이면 state record/handoff 가 프로젝트 밖에 쓰기 전에 손상으로 멈춰야 한다.
|
|
15329
|
+
{
|
|
15330
|
+
let ok = false; const bad = []; const dbg = {};
|
|
15331
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-149-'));
|
|
15332
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
15333
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
15334
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
15335
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
|
|
15336
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
15337
|
+
{ cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
15338
|
+
const mk = (name) => {
|
|
15339
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
15340
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
15341
|
+
const ri = RP(d, ['init', d, '--yes', '--minimal']);
|
|
15342
|
+
if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
|
|
15343
|
+
return d;
|
|
15344
|
+
};
|
|
15345
|
+
try {
|
|
15346
|
+
// ① global/legacy slot: 소유 맵이 없는 구버전 state 도 입력 경로가 될 수 있다.
|
|
15347
|
+
{
|
|
15348
|
+
const d = mk('current-id');
|
|
15349
|
+
RP(d, ['state', 'start', 'A', '--json']);
|
|
15350
|
+
const stf = path.join(d, '.leerness', 'state.json');
|
|
15351
|
+
const st = JSON.parse(fs.readFileSync(stf, 'utf8'));
|
|
15352
|
+
delete st.runsBySession;
|
|
15353
|
+
st.currentRunId = '../../escaped-current';
|
|
15354
|
+
fs.writeFileSync(stf, JSON.stringify(st, null, 2) + '\n');
|
|
15355
|
+
const r = RP(d, ['state', 'handoff', '요약']);
|
|
15356
|
+
const escaped = fs.existsSync(path.join(d, 'escaped-current.json')) || fs.existsSync(path.join(d, 'escaped-current.md'));
|
|
15357
|
+
if (r.status === 0) bad.push('①경로형 currentRunId 인데 성공이라 보고');
|
|
15358
|
+
if (escaped) bad.push('①경로형 currentRunId 로 .leerness 밖에 씀');
|
|
15359
|
+
dbg.currentRunId = { exit: r.status, escaped };
|
|
15360
|
+
}
|
|
15361
|
+
// ② run record: 파일명은 안전해도 내부 run_id 를 저장 대상으로 다시 쓰면 탈출한다.
|
|
15362
|
+
{
|
|
15363
|
+
const d = mk('record-id');
|
|
15364
|
+
RP(d, ['state', 'start', 'A', '--json']);
|
|
15365
|
+
const rf = path.join(d, '.leerness', 'runs', 'run-0001.json');
|
|
15366
|
+
const rec = JSON.parse(fs.readFileSync(rf, 'utf8'));
|
|
15367
|
+
rec.run_id = '../../escaped-record';
|
|
15368
|
+
fs.writeFileSync(rf, JSON.stringify(rec, null, 2) + '\n');
|
|
15369
|
+
const r = RP(d, ['state', 'handoff', '요약']);
|
|
15370
|
+
const escaped = fs.existsSync(path.join(d, 'escaped-record.json')) || fs.existsSync(path.join(d, 'escaped-record.md'));
|
|
15371
|
+
if (r.status === 0) bad.push('②경로형 record.run_id 인데 성공이라 보고');
|
|
15372
|
+
if (escaped) bad.push('②경로형 record.run_id 로 .leerness 밖에 씀');
|
|
15373
|
+
dbg.recordRunId = { exit: r.status, escaped };
|
|
15374
|
+
}
|
|
15375
|
+
ok = bad.length === 0;
|
|
15376
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
15377
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
15378
|
+
console.log(ok ? `✓ AM(1.36.149) legacy currentRunId·run record run_id 모두 경로형이면 .leerness 밖 쓰기 전에 손상으로 차단 ${JSON.stringify(dbg)}`
|
|
15379
|
+
: '✗ 1.36.149 run-id 경로 차단 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
15380
|
+
if (!ok) failed++;
|
|
15381
|
+
}
|
|
15382
|
+
|
|
15383
|
+
total++;
|
|
15384
|
+
// AN(1.36.150/T-0120) — `GIT_CONFIG_NOSYSTEM`은 저장소 위치 주입이 아니라 호출자가 시스템 설정을
|
|
15385
|
+
// 숨기겠다는 의미다. 설치기만 이를 지우면 system core.hooksPath에 훅을 쓰지만, 같은 셸의 실제 git commit은
|
|
15386
|
+
// 기본 .git/hooks를 보아 오래된 handoff도 통과한다. 설치 경로와 실제 커밋 경로가 같은 환경에서 일치해야 한다.
|
|
15387
|
+
{
|
|
15388
|
+
let ok = false; const bad = []; const dbg = {};
|
|
15389
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-150-'));
|
|
15390
|
+
const stripGitTargetEnv = (source) => {
|
|
15391
|
+
const out = Object.assign({}, source);
|
|
15392
|
+
const drop = new Set(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT']);
|
|
15393
|
+
for (const k of Object.keys(out)) if (drop.has(k.toUpperCase())) delete out[k];
|
|
15394
|
+
return out;
|
|
15395
|
+
};
|
|
15396
|
+
const envP = stripGitTargetEnv(process.env);
|
|
15397
|
+
envP.TMPDIR = sb; envP.TEMP = sb; envP.TMP = sb;
|
|
15398
|
+
envP.LEERNESS_OFFLINE = '1'; envP.LEERNESS_NO_PROMPT = '1';
|
|
15399
|
+
envP.LEERNESS_SESSION_ID = 'e2e-enforce-nosystem';
|
|
15400
|
+
envP.GIT_CONFIG_GLOBAL = path.join(sb, 'global.gitconfig');
|
|
15401
|
+
envP.GIT_CONFIG_SYSTEM = path.join(sb, 'system.gitconfig');
|
|
15402
|
+
envP.GIT_CONFIG_NOSYSTEM = '1';
|
|
15403
|
+
const G = (d, a, env) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: env || envP });
|
|
15404
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
15405
|
+
{ cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
15406
|
+
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
15407
|
+
try {
|
|
15408
|
+
const d = path.join(sb, 'repo'), systemHooks = path.join(sb, 'system-hooks');
|
|
15409
|
+
fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(systemHooks, { recursive: true });
|
|
15410
|
+
fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, '');
|
|
15411
|
+
fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, `[core]\n\thooksPath = ${systemHooks.replace(/\\/g, '/')}\n`);
|
|
15412
|
+
const setup = [G(d, ['init', '-q', '-b', 'main', '.']), G(d, ['config', 'user.email', 't@example.invalid']), G(d, ['config', 'user.name', 'T'])];
|
|
15413
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}\n');
|
|
15414
|
+
setup.push(G(d, ['add', '-A']), G(d, ['commit', '-qm', 'initial']));
|
|
15415
|
+
if (setup.some((r) => r.status !== 0)) bad.push('①git 픽스처를 만들지 못함 — 판별 불가');
|
|
15416
|
+
const withSystem = Object.assign({}, envP); delete withSystem.GIT_CONFIG_NOSYSTEM;
|
|
15417
|
+
const systemCfg = G(d, ['config', '--show-scope', '--get', 'core.hooksPath'], withSystem);
|
|
15418
|
+
const callerCfg = G(d, ['config', '--show-scope', '--get', 'core.hooksPath']);
|
|
15419
|
+
if (systemCfg.status !== 0 || callerCfg.status === 0) bad.push('①GIT_CONFIG_NOSYSTEM 픽스처가 system 설정을 갈라내지 못함 — 판별 불가');
|
|
15420
|
+
const init = RP(d, ['init', d, '--yes', '--minimal']);
|
|
15421
|
+
const handoff = RP(d, ['handoff', d]);
|
|
15422
|
+
const install = J(RP(d, ['enforce', 'install', '--path', d, '--json', '--skip-verify']));
|
|
15423
|
+
if (init.status !== 0 || handoff.status !== 0 || !install || !install.ok) bad.push(`①설치 흐름 실패(init=${init.status}, handoff=${handoff.status}, install=${install && install.ok})`);
|
|
15424
|
+
else {
|
|
15425
|
+
const raw = String(G(d, ['rev-parse', '--git-path', 'hooks/pre-commit']).stdout || '').trim();
|
|
15426
|
+
const callerHook = path.isAbsolute(raw) ? raw : path.join(d, raw);
|
|
15427
|
+
const old = new Date(Date.now() - 72 * 3600 * 1000);
|
|
15428
|
+
// 1.36.155부터 enforce freshness는 presence가 아니라 handoff 전용 marker를 본다.
|
|
15429
|
+
fs.utimesSync(path.join(d, '.harness', 'cache', 'handoffs', 'e2e-enforce-nosystem.json'), old, old);
|
|
15430
|
+
fs.writeFileSync(path.join(d, 'after.txt'), 'after\n'); G(d, ['add', '-A']);
|
|
15431
|
+
const commit = G(d, ['commit', '-qm', 'must-be-blocked']);
|
|
15432
|
+
const gitModule = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
|
|
15433
|
+
if (gitModule._shouldDrop('GIT_CONFIG_NOSYSTEM')) bad.push('①GIT_CONFIG_NOSYSTEM을 내부 git 에서만 삭제');
|
|
15434
|
+
if (!gitModule._shouldDrop('GIT_CONFIG_PARAMETERS')) bad.push('①일회성 config 주입까지 보존 — 세척 경계 후퇴');
|
|
15435
|
+
if (!fs.existsSync(callerHook)) bad.push(`①설치 훅과 실제 git 훅 경로 불일치(install=${install.hook}, caller=${callerHook})`);
|
|
15436
|
+
if (commit.status === 0) bad.push('①오래된 handoff인데 실제 git commit이 통과 — 훅 우회');
|
|
15437
|
+
dbg.nosystem = { installHook: install.hook, callerHook, callerHasHook: fs.existsSync(callerHook), commitExit: commit.status,
|
|
15438
|
+
systemScope: systemCfg.status, callerScope: callerCfg.status };
|
|
15439
|
+
}
|
|
15440
|
+
ok = bad.length === 0;
|
|
15441
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
15442
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
15443
|
+
console.log(ok ? `✓ AN(1.36.150/T-0120) GIT_CONFIG_NOSYSTEM을 보존해 설치기와 같은 셸의 git이 같은 hooksPath를 보고, 오래된 handoff 커밋이 실제로 차단됨 ${JSON.stringify(dbg)}`
|
|
15444
|
+
: '✗ 1.36.150 GIT_CONFIG_NOSYSTEM 훅 경로 일치 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
15445
|
+
if (!ok) failed++;
|
|
15446
|
+
}
|
|
15447
|
+
|
|
15448
|
+
total++;
|
|
15449
|
+
// AO(1.36.152/T-0115) — write 시점의 락 관측만으로는 "읽은 사본도 락 안인가"를 증명할 수 없다.
|
|
15450
|
+
// 첫 프로세스의 최종 rename을 강제로 멈추고 두 번째 쓰기를 붙여, 서로 다른 RMW 여섯 쌍이 모두 생존하는지 확인한다.
|
|
15451
|
+
{
|
|
15452
|
+
let ok = false; const dbg = {};
|
|
15453
|
+
try {
|
|
15454
|
+
const race = cp.spawnSync(process.execPath, [path.join(__dirname, 'e2e-concurrency.js'), CLI], {
|
|
15455
|
+
cwd: path.resolve(__dirname, '..'),
|
|
15456
|
+
encoding: 'utf8',
|
|
15457
|
+
timeout: 180000,
|
|
15458
|
+
env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_LOCK_WARN: '1' }),
|
|
15459
|
+
maxBuffer: 16 * 1024 * 1024
|
|
15460
|
+
});
|
|
15461
|
+
let report = null; try { report = JSON.parse(String(race.stdout || '').trim()); } catch {}
|
|
15462
|
+
dbg.exit = race.status; dbg.report = report;
|
|
15463
|
+
if (!report) dbg.stderr = String(race.stderr || '').slice(0, 500);
|
|
15464
|
+
ok = race.status === 0 && !!report && report.ok === true
|
|
15465
|
+
&& Array.isArray(report.results) && report.results.length === 6 && report.results.every(r => r.ok === true);
|
|
15466
|
+
} catch (e) { dbg.err = String(e && e.message || e).slice(0, 200); }
|
|
15467
|
+
console.log(ok ? '✓ AO(1.36.152/T-0115) 지연 경쟁 6쌍 모두 exit 0 + 양쪽 기록 보존'
|
|
15468
|
+
: '✗ 1.36.152 동시 RMW 회귀 ' + JSON.stringify(dbg));
|
|
15469
|
+
if (!ok) failed++;
|
|
15470
|
+
}
|
|
15471
|
+
|
|
15180
15472
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
15181
15473
|
if (failed > 0) process.exit(1);
|
|
15182
|
-
|