leerness 1.36.185 → 1.36.187

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.ko.md +5 -0
  3. package/README.md +14 -4
  4. package/bin/leerness.js +291 -57
  5. package/docs/e2e-observation-contract.md +51 -0
  6. package/docs/runtime-compatibility-api.md +183 -0
  7. package/docs/state-paths-api.md +92 -0
  8. package/docs/state-scopes.md +266 -0
  9. package/docs/worktree-runtime-migration.md +278 -0
  10. package/lib/claims-baseline.js +1 -1
  11. package/lib/file-leases.js +2 -2
  12. package/lib/git.js +64 -15
  13. package/lib/io.js +9 -3
  14. package/lib/migrate.js +1 -1
  15. package/lib/preview-serve.js +5 -1
  16. package/lib/pure-utils.js +20 -0
  17. package/lib/role-fallback.js +3 -3
  18. package/lib/role-store.js +2 -2
  19. package/lib/runtime-layout.js +395 -0
  20. package/lib/runtime-writes.js +166 -0
  21. package/lib/session-close.js +1 -1
  22. package/lib/state-git.js +138 -0
  23. package/lib/state-inspect.js +39 -0
  24. package/lib/state-inventory.js +69 -0
  25. package/lib/state-paths.js +59 -0
  26. package/lib/workspace-dir.js +34 -10
  27. package/package.json +10 -6
  28. package/scripts/command-flags-probe.js +1 -1
  29. package/scripts/dead-flags-probe.js +1 -1
  30. package/scripts/e2e-child-diagnostics-probe.js +143 -0
  31. package/scripts/e2e-child-diagnostics.js +33 -0
  32. package/scripts/e2e-temp-scope-probe.js +127 -0
  33. package/scripts/e2e.js +108 -27
  34. package/scripts/encoding-selftest-probe.js +222 -0
  35. package/scripts/false-claim-probe.js +34 -3
  36. package/scripts/file-lease-probe.js +5 -2
  37. package/scripts/legacy-runtime-negative-probe.js +72 -0
  38. package/scripts/mutation-integrity-probe.js +3 -0
  39. package/scripts/role-fallback-probe.js +67 -6
  40. package/scripts/runtime-admission-probe.js +282 -0
  41. package/scripts/runtime-git-write-probe.js +116 -0
  42. package/scripts/runtime-layout-probe.js +1198 -0
  43. package/scripts/runtime-repl-probe.js +271 -0
  44. package/scripts/runtime-replacement-probe.js +190 -0
  45. package/scripts/runtime-write-probe.js +458 -0
  46. package/scripts/selftest-cleanup-probe.js +168 -0
  47. package/scripts/state-inspect-cli-probe.js +209 -0
  48. package/scripts/state-scopes-probe.js +590 -0
  49. package/scripts/workspace-dir-lock-order-probe.js +310 -35
  50. package/scripts/workspace-dir-migration-probe.js +17 -2
  51. package/scripts/workspace-selection-probe.js +230 -0
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // Observation only: never spawn, retry, change the native result or decide success.
4
+ function childDiagnostics(result, elapsedMs, timeoutMs, parsedReport) {
5
+ const r = result || {};
6
+ const small = (value, limit) => value == null ? null : String(value).slice(0, limit);
7
+ const number = value => typeof value === 'number' && Number.isFinite(value) ? value : null;
8
+ const stdout = r.stdout == null ? null : String(r.stdout);
9
+ const stderr = r.stderr == null ? null : String(r.stderr);
10
+ const report = parsedReport || {};
11
+ const selftest = report.selftest;
12
+ const failed = selftest && Array.isArray(selftest.failed) ? selftest.failed : [];
13
+ return {
14
+ status: number(r.status), signal: small(r.signal, 80),
15
+ errorCode: small(r.error && r.error.code, 80),
16
+ errno: r.error ? (number(r.error.errno) ?? small(r.error.errno, 80)) : null,
17
+ errorMessage: small(r.error && r.error.message, 160),
18
+ elapsedMs: Math.max(0, Math.round(number(elapsedMs) || 0)), timeoutMs: number(timeoutMs),
19
+ stdoutBytes: stdout === null ? null : Buffer.byteLength(stdout),
20
+ stderrBytes: stderr === null ? null : Buffer.byteLength(stderr),
21
+ stdoutTail: stdout === null ? null : stdout.slice(-256),
22
+ stderrTail: stderr === null ? null : stderr.slice(-256),
23
+ version: small(report.version, 64), mcpTools: number(report.mcpTools),
24
+ healthy: typeof report.healthy === 'boolean' ? report.healthy : null,
25
+ selftest: selftest ? {
26
+ pass: number(selftest.pass), total: number(selftest.total),
27
+ ok: typeof selftest.ok === 'boolean' ? selftest.ok : null,
28
+ failedCount: failed.length, failed: failed.slice(0, 5).map(name => String(name).slice(0, 120)),
29
+ } : null,
30
+ };
31
+ }
32
+
33
+ module.exports = { childDiagnostics };
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0182: execute the original E2E counter and failure assertion, with real
5
+ // process-local TMP isolation. No product implementation or fs/os mock.
6
+ const assert = require('assert');
7
+ const cp = require('child_process');
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const E2E = path.resolve(__dirname, 'e2e.js');
13
+ const PREFIX = 'leerness-e2e-temp-scope-';
14
+ const CASES = ['foreign-parent', 'child-residue'];
15
+ const realpath = file => fs.realpathSync.native(file);
16
+ const signature = file => ({
17
+ sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),
18
+ mtimeNs: String(fs.statSync(file, { bigint: true }).mtimeNs),
19
+ });
20
+
21
+ function uniqueIndex(source, anchor) {
22
+ const index = source.indexOf(anchor);
23
+ assert(index >= 0 && source.indexOf(anchor, index + anchor.length) < 0,
24
+ `missing or ambiguous source anchor: ${anchor}`);
25
+ return index;
26
+ }
27
+
28
+ function originalAssertion() {
29
+ const source = fs.readFileSync(E2E, 'utf8');
30
+ const first = uniqueIndex(source, '// 1.36.134:');
31
+ const last = uniqueIndex(source, '// AD(1.36.140)');
32
+ assert(last > first, 'E2E block anchors reversed');
33
+ const block = source.slice(first, last);
34
+ const start = uniqueIndex(block, ' const leftovers = ');
35
+ const end = uniqueIndex(block, ' dbg.noSwitch = ');
36
+ assert(end > start, 'counter anchors reversed');
37
+ const body = block.slice(start, end);
38
+ assert.strictEqual(body.trim().split(/\r?\n/).length, 2, 'expected original counter and bad.push only');
39
+ return new Function('fs', 'os', 'sb', 'bad', body + '\nreturn { leftovers, bad };');
40
+ }
41
+
42
+ function snapshot(directory) {
43
+ const rows = [];
44
+ const visit = file => {
45
+ const stat = fs.lstatSync(file, { bigint: true });
46
+ assert(stat.isDirectory() || stat.isFile(), 'unexpected fixture link or special file');
47
+ rows.push([path.relative(directory, file) || '.', String(stat.mode), String(stat.size),
48
+ String(stat.mtimeNs), stat.isFile() ? fs.readFileSync(file).toString('base64') : null]);
49
+ if (stat.isDirectory()) fs.readdirSync(file).sort().forEach(name => visit(path.join(file, name)));
50
+ };
51
+ visit(directory);
52
+ return rows;
53
+ }
54
+
55
+ function childCase(directory, scenario) {
56
+ assert(CASES.includes(scenario), 'unknown fixture scenario');
57
+ assert.strictEqual(path.resolve(directory), realpath(directory), 'fixture path is not canonical');
58
+ assert.strictEqual(realpath(os.tmpdir()), directory, 'child TMP isolation failed');
59
+ assert.strictEqual(path.basename(directory), scenario);
60
+ assert(new RegExp(`^${PREFIX}[A-Za-z0-9]{6}$`).test(path.basename(path.dirname(directory))));
61
+ assert.deepStrictEqual(fs.readdirSync(directory), [], 'fixture must begin empty');
62
+ const sb = path.join(directory, 'child-sb');
63
+ fs.mkdirSync(sb);
64
+ const marker = path.join(scenario === 'foreign-parent' ? directory : sb, 'leerness-probe-24680-1234567890123.sh');
65
+ fs.writeFileSync(marker, '# owned, non-executed T-0182 fixture\n', { flag: 'wx' });
66
+ const before = snapshot(directory);
67
+ const result = originalAssertion()(fs, os, sb, []);
68
+ const unchanged = JSON.stringify(snapshot(directory)) === JSON.stringify(before);
69
+ process.stdout.write(JSON.stringify({ ...result, unchanged }) + '\n');
70
+ }
71
+
72
+ function run() {
73
+ const sources = [__filename, E2E];
74
+ const sourceBefore = sources.map(signature);
75
+ const temp = realpath(os.tmpdir());
76
+ const arena = fs.mkdtempSync(path.join(temp, PREFIX));
77
+ const identity = fs.lstatSync(arena, { bigint: true });
78
+ let passed = 0;
79
+ const failures = [];
80
+ try {
81
+ for (const scenario of CASES) {
82
+ try {
83
+ const directory = path.join(arena, scenario);
84
+ fs.mkdirSync(directory);
85
+ const env = { ...process.env };
86
+ for (const key of Object.keys(env)) if (/^(TMP|TEMP|TMPDIR)$/i.test(key)) delete env[key];
87
+ Object.assign(env, { TMP: directory, TEMP: directory, TMPDIR: directory });
88
+ const result = cp.spawnSync(process.execPath, [__filename, '--child', directory, scenario],
89
+ { cwd: directory, env, encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024, windowsHide: true });
90
+ assert.strictEqual(result.status, 0, String(result.stderr || result.error || 'child did not finish'));
91
+ assert.strictEqual(result.stderr, '', 'unexpected child stderr');
92
+ const report = JSON.parse(result.stdout);
93
+ assert.strictEqual(report.unchanged, true, 'observation changed fixture bytes/mtime');
94
+ const expected = scenario === 'child-residue' ? 1 : 0;
95
+ assert.strictEqual(report.leftovers, expected, `leftovers=${report.leftovers}, expected=${expected}`);
96
+ assert.strictEqual(report.bad.length, expected, `failure assertion count=${report.bad.length}, expected=${expected}`);
97
+ passed++;
98
+ process.stdout.write(`ok - ${scenario}\n`);
99
+ } catch (error) {
100
+ failures.push(`${scenario}: ${error.message}`);
101
+ process.stdout.write(`not ok - ${scenario}: ${error.message}\n`);
102
+ }
103
+ }
104
+ } finally {
105
+ // Never enumerate or remove pre-existing global prefix artifacts.
106
+ const stat = fs.lstatSync(arena, { bigint: true });
107
+ assert(stat.isDirectory() && !stat.isSymbolicLink(), 'cleanup target is not an owned directory');
108
+ assert.strictEqual(path.resolve(arena), realpath(arena), 'cleanup canonical path changed');
109
+ assert.strictEqual(path.dirname(arena), temp, 'cleanup escaped the recorded temp parent');
110
+ assert(new RegExp(`^${PREFIX}[A-Za-z0-9]{6}$`).test(path.basename(arena)), 'cleanup prefix mismatch');
111
+ assert.strictEqual(stat.dev, identity.dev, 'cleanup device changed');
112
+ assert.strictEqual(stat.ino, identity.ino, 'cleanup directory identity changed');
113
+ fs.rmSync(arena, { recursive: true, force: true });
114
+ }
115
+ assert(!fs.existsSync(arena), 'owned arena was not removed');
116
+ assert.deepStrictEqual(sources.map(signature), sourceBefore, 'source SHA256/mtime changed');
117
+ process.stdout.write(`e2e temp scope probe: ${passed}/${CASES.length} passed; skipped 0; source unchanged; owned arena cleaned\n`);
118
+ if (failures.length) throw new Error('owned TMP contract failed: ' + failures.join('; '));
119
+ }
120
+
121
+ try {
122
+ if (process.argv[2] === '--child') childCase(process.argv[3], process.argv[4]);
123
+ else run();
124
+ } catch (error) {
125
+ process.stderr.write(`owned TMP contract failed: ${error.message}\n`);
126
+ process.exitCode = 1;
127
+ }
package/scripts/e2e.js CHANGED
@@ -5,6 +5,8 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
  const cp = require('child_process');
8
+ const { performance } = require('perf_hooks');
9
+ const { childDiagnostics } = require('./e2e-child-diagnostics');
8
10
  const { spawnNpmSync } = require('../lib/npm-process');
9
11
  const { gitSpawn } = require('../lib/git');
10
12
  const { EXTERNAL_AGENTS } = require('../lib/agent-registry');
@@ -1624,12 +1626,16 @@ total++;
1624
1626
  const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-fresh-'));
1625
1627
  fs.mkdirSync(path.join(tmpC, '.leerness'), { recursive: true });
1626
1628
  fs.writeFileSync(path.join(tmpC, '.leerness', 'HARNESS_VERSION'), 'leerness@1.9.30\n', 'utf8');
1627
- cp.spawnSync(process.execPath, [CLI, 'migrate', tmpC, '--yes', '--no-banner', '--no-stale-check'], { stdio: 'ignore', timeout: 60000 });
1629
+ const migrateStarted = performance.now();
1630
+ const migrated = cp.spawnSync(process.execPath, [CLI, 'migrate', tmpC, '--yes', '--no-banner', '--no-stale-check'], { stdio: 'ignore', timeout: 60000 });
1631
+ const migrateDiagnostic = childDiagnostics(migrated, performance.now() - migrateStarted, 60000);
1632
+ const handoffStarted = performance.now();
1628
1633
  const r = cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--no-drift-check'], { encoding: 'utf8', timeout: 15000 });
1634
+ const handoffDiagnostic = childDiagnostics(r, performance.now() - handoffStarted, 15000);
1629
1635
  const ok = r.status === 0
1630
1636
  && /최근.*시간 전 migrate 차분|AI must re-read/.test(r.stdout);
1631
1637
  console.log(ok ? '✓ B(1.9.41) handoff: 최근 migrate 차분 자동 표시 (24h 내)' : `✗ handoff 차분 알림 실패`);
1632
- if (!ok) { failed++; console.log(r.stdout.slice(-500)); }
1638
+ if (!ok) { failed++; console.log(JSON.stringify({ migrate: migrateDiagnostic, handoff: handoffDiagnostic })); }
1633
1639
  }
1634
1640
 
1635
1641
  // 1.9.40 회귀: release pack 통합 명령 + audit README mismatch 감지
@@ -3920,7 +3926,7 @@ total++;
3920
3926
  // 1.9.315 회귀 (UR-0054 설치리뷰): doc/surface 정합 — doctor 진단 명령 + stale MCP 카운트 동적화(commands/banner)
3921
3927
  total++;
3922
3928
  {
3923
- let ok = false;
3929
+ let ok = false; const dbg = {};
3924
3930
  try {
3925
3931
  // 1.36.100: 이 호출만 30초로 남아 있었다 — doctor 는 selftest 를 내장해 격리 실측 31s(게시된 1.36.98 도 동일)라
3926
3932
  // 전체 스위트 부하에서 간헐 초과했고, 실제로 게이트 한 번을 실패시켰다(내 변경 때문이 아니라 잠복 결함).
@@ -3929,16 +3935,22 @@ total++;
3929
3935
  // 1.36.105: 여유가 1.2배뿐이라 전체 부하에서 반복해 터진다(이번 세션 1회 · 1.36.100 에서 1회).
3930
3936
  // 측정 근거 — **게시된 1.36.104** 격리 실측 doctor 97s / selftest 99s, 작업 트리 101s/94s (동일 수준).
3931
3937
  // 즉 내 변경이 느리게 만든 게 아니라 제한이 처음부터 빠듯했다. 3배 여유로 올린다(검사 대상은 속도가 아니라 동작).
3938
+ const doctorStarted = performance.now();
3932
3939
  const r = cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { encoding: 'utf8', timeout: 300000 });
3940
+ const doctorElapsed = performance.now() - doctorStarted;
3933
3941
  let j = null; try { j = JSON.parse(r.stdout); } catch {}
3942
+ dbg.doctor = childDiagnostics(r, doctorElapsed, 300000, j);
3934
3943
  const doctorOk = j && j.version && typeof j.mcpTools === 'number' && j.mcpTools >= 80 && j.selftest && j.selftest.total > 0 && j.healthy === true && r.status === 0;
3935
3944
  // commands 요약 + banner 가 실제 MCP 수 노출 (하드코딩 65/46 아님)
3945
+ const commandsStarted = performance.now();
3936
3946
  const rc = cp.spawnSync(process.execPath, [CLI, 'commands'], { encoding: 'utf8', timeout: 15000 });
3947
+ dbg.commands = childDiagnostics(rc, performance.now() - commandsStarted, 15000);
3937
3948
  const dynOk = j && new RegExp('MCP 도구: ' + j.mcpTools).test(rc.stdout || '') && !/MCP 도구: 65\b/.test(rc.stdout || '');
3938
3949
  ok = doctorOk && dynOk;
3939
- } catch {}
3950
+ dbg.doctorOk = !!doctorOk; dbg.dynOk = !!dynOk;
3951
+ } catch (error) { dbg.error = String(error && error.message || error).slice(0, 160); }
3940
3952
  console.log(ok ? '✓ B(1.9.315) doc/surface: doctor 진단(selftest+버전+셸) + commands MCP 카운트 동적 (UR-0054)' : '✗ doc/surface 실패');
3941
- if (!ok) failed++;
3953
+ if (!ok) { failed++; console.log(JSON.stringify(dbg)); }
3942
3954
  }
3943
3955
 
3944
3956
  // 1.9.316 회귀 (drift 마커 버그): session-handoff 'Last generated' 중복 누적 방지 + drift 'session close 누락' 클리어
@@ -5286,27 +5298,36 @@ total++;
5286
5298
  // 그 측정을 래칫으로 고정한다: 성공 경로의 `--json` 은 stderr 가 비어야 한다.
5287
5299
  total++;
5288
5300
  {
5289
- let ok = false; const bad = [];
5301
+ let ok = false; const bad = []; const diagnostics = {};
5290
5302
  const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-json121-'));
5291
5303
  const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
5292
5304
  try {
5293
5305
  const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
5294
5306
  fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0","scripts":{"test":"node t.js"}}');
5295
- cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
5307
+ const initStarted = performance.now();
5308
+ const initialized = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
5309
+ diagnostics.init = childDiagnostics(initialized, performance.now() - initStarted, 300000);
5296
5310
  // 성공 경로만 — 인자 없이도 동작하는 조회성 명령 (사용법 오류 경로는 stderr 진단이 관례라 대상 아님)
5297
5311
  const CMDS = ['audit', 'health', 'doctor', 'drift', 'plan', 'task', 'pulse', 'tech', 'handoff',
5298
5312
  'capabilities', 'commands', 'which', 'gate', 'dashboard', 'state'];
5299
5313
  for (const c of CMDS) {
5300
- const r = cp.spawnSync(process.execPath, [CLI, c, '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 180000, env: ENV });
5314
+ // Node18 reproduced doctor being cut at180s before its nested report. Only
5315
+ // this wrapper covers the900s inner limit plus90s environment/startup budget.
5316
+ const timeoutMs = c === 'doctor' ? 990000 : 180000;
5317
+ const started = performance.now();
5318
+ const r = cp.spawnSync(process.execPath, [CLI, c, '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: timeoutMs, env: ENV });
5319
+ const elapsedMs = performance.now() - started;
5301
5320
  const out = String(r.stdout || ''), err = String(r.stderr || '');
5302
- if (!out.trim()) { bad.push(`${c}:출력없음`); continue; }
5321
+ if (!out.trim()) { bad.push(`${c}:출력없음`); diagnostics[c] = childDiagnostics(r, elapsedMs, timeoutMs); continue; }
5303
5322
  let j = null; try { j = JSON.parse(out.trim()); } catch {}
5304
- if (!j) { bad.push(`${c}:stdout이JSON아님`); continue; }
5323
+ if (!j) { bad.push(`${c}:stdout이JSON아님`); diagnostics[c] = childDiagnostics(r, elapsedMs, timeoutMs); continue; }
5324
+ const previousFailures = bad.length;
5305
5325
  // 문구가 "0 바이트" 면 검사도 0 바이트여야 한다 — `trim()===''` 은 개행만 있는 출력을 통과시킨다(검수 P3).
5306
5326
  if (err.length !== 0) bad.push(`${c}:stderr잡음(${err.length}B:${err.trim().split('\n')[0].slice(0, 40)})`);
5307
5327
  // "성공 경로 15종" 이라고 말하려면 **성공인지도** 봐야 한다 — 실패 JSON(`ok:false`, exit 1)도 세고 있었다(검수 P2).
5308
5328
  if (r.status !== 0) bad.push(`${c}:exit=${r.status}`);
5309
5329
  if (j.ok === false) bad.push(`${c}:ok=false`);
5330
+ if (bad.length > previousFailures) diagnostics[c] = childDiagnostics(r, elapsedMs, timeoutMs, j);
5310
5331
  }
5311
5332
  // 판별력 — 이 검사가 실제로 무언가를 실행했는지(전부 건너뛰면 공허하게 통과한다)
5312
5333
  if (bad.length === 0 && CMDS.length < 10) bad.push('대상이_너무적음');
@@ -5315,7 +5336,7 @@ total++;
5315
5336
  finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
5316
5337
  console.log(ok ? '✓ B(1.36.121) --json stderr 계약 스윕: 조회성 15종 모두 stdout 단일 JSON + stderr 0'
5317
5338
  : '✗ --json stderr 계약 스윕 실패 ' + JSON.stringify(bad.slice(0, 6)));
5318
- if (!ok) failed++;
5339
+ if (!ok) { failed++; console.log(JSON.stringify(diagnostics)); }
5319
5340
  }
5320
5341
 
5321
5342
  // 1.36.124 — MCP 서버를 **실제 JSON-RPC 로** 두드린다. 단위 테스트가 초록이어도 살아 있는 통신은 깨질 수 있다.
@@ -13871,7 +13892,7 @@ total++;
13871
13892
  }
13872
13893
 
13873
13894
  // ③ verify-claim 이 **사실이 아닌 것을 말하지 않는다** — git 이 PATH 에 없을 뿐인 진짜 저장소를
13874
- // "저장소가 아니다" 라고 단정했다.
13895
+ // "저장소가 아니다" 라고 단정하지 않는다. 새 admission 은 git_missing 으로 bookkeeping 전에 거부한다.
13875
13896
  {
13876
13897
  const d = mkRepo('claim');
13877
13898
  fs.writeFileSync(path.join(d, 'a.js'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
@@ -13885,12 +13906,29 @@ total++;
13885
13906
  };
13886
13907
  const normal = getGit();
13887
13908
  const emptyDir = fs.mkdtempSync(path.join(sb, 'nogitpath-'));
13888
- const noGit = getGit({ PATH: emptyDir + path.delimiter + path.dirname(process.execPath) });
13909
+ const snapshot = () => {
13910
+ const rows = [];
13911
+ const visit = (p, rel) => {
13912
+ const st = fs.lstatSync(p, { bigint: true });
13913
+ const data = st.isDirectory() ? ['directory'] : st.isFile() ? ['file', fs.readFileSync(p).toString('base64')]
13914
+ : st.isSymbolicLink() ? ['link', fs.readlinkSync(p)] : ['other'];
13915
+ rows.push([rel, String(st.mode), String(st.size), String(st.mtimeNs), data]);
13916
+ if (st.isDirectory()) for (const name of fs.readdirSync(p).sort()) visit(path.join(p, name), rel + '/' + name);
13917
+ };
13918
+ visit(d, '.');
13919
+ return JSON.stringify(rows);
13920
+ };
13921
+ const beforeNoGit = snapshot();
13922
+ const noGitResult = RP(d, ['verify-claim', 'T-0001', '--path', d, '--json'],
13923
+ { PATH: emptyDir + path.delimiter + path.dirname(process.execPath) });
13924
+ const noGit = JSON.parse(String(noGitResult.stdout || ''));
13925
+ const unchanged = snapshot() === beforeNoGit;
13889
13926
  if (!normal || normal.parse === false) bad.push('③정상 PATH 에서 --json 파싱 실패(계측 붕괴)');
13890
- if (!noGit || noGit.parse === false) bad.push('③git 없는 PATH 에서 --json 파싱 실패');
13891
- else if (noGit.reason === 'not-a-git-repo' || noGit.reason === 'not-a-repo') bad.push(`③git 부재를 "저장소 아님" 이라 단정(${noGit.reason})`);
13892
- else if (noGit.reason !== 'no-git') bad.push(`③git 부재 사유=${noGit.reason} (기대 no-git)`);
13893
- dbg.claim = { normal: normal && normal.reason !== undefined ? normal.reason : (normal && normal.applicable), noGit: noGit && noGit.reason };
13927
+ if (noGitResult.status !== 1 || !noGit || noGit.ok !== false || noGit.code !== 'runtime_layout_incompatible'
13928
+ || !String(noGit.error || '').includes('(git_missing)')) bad.push(`③git 부재 admission 계약 불일치: exit=${noGitResult.status} report=${JSON.stringify(noGit)}`);
13929
+ if (!unchanged) bad.push('③git 부재 거부가 대상 전체 트리의 bytes/mtime 을 바꿈');
13930
+ dbg.claim = { normal: normal && normal.reason !== undefined ? normal.reason : (normal && normal.applicable),
13931
+ noGit: noGit && noGit.code, exit: noGitResult.status, unchanged };
13894
13932
  }
13895
13933
 
13896
13934
  // ④ 세션 레코드가 **모르는 키를 조용히 지우지 않는다**. 버전 혼재가 이 기계에 실재한다(전역본 < 저장소).
@@ -14005,6 +14043,42 @@ total++;
14005
14043
  try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
14006
14044
  const G = (d, a) => gitSpawn(a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
14007
14045
  const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14046
+ const targetSnapshot = (d) => {
14047
+ const rows = [];
14048
+ const visit = (p, rel) => {
14049
+ const st = fs.lstatSync(p, { bigint: true });
14050
+ const data = st.isDirectory() ? ['directory'] : st.isFile() ? ['file', fs.readFileSync(p).toString('base64')]
14051
+ : st.isSymbolicLink() ? ['link', fs.readlinkSync(p)] : null;
14052
+ if (!data) throw new Error('enforce fixture 에 예상하지 않은 파일 종류: ' + rel);
14053
+ rows.push([rel, String(st.mode), String(st.size), String(st.mtimeNs), data]);
14054
+ if (st.isDirectory()) for (const name of fs.readdirSync(p).sort()) visit(path.join(p, name), rel + '/' + name);
14055
+ };
14056
+ visit(d, '.');
14057
+ return JSON.stringify(rows);
14058
+ };
14059
+ // 실제 CLI는 bookkeeping 전 admission에서 거부한다. JSON 누락/다른 오류/성공/어떤 쓰기도 통과시키지 않는다.
14060
+ const deniedEnforce = (d, sub, reason = 'git_repository_unavailable') => {
14061
+ const before = targetSnapshot(d);
14062
+ const r = RP(d, ['enforce', sub, '--path', d, '--json']);
14063
+ const report = JSON.parse(String(r.stdout || ''));
14064
+ const observed = /^Runtime layout blocks writes \(([a-z_]{1,80})\)\./.exec(String(report && report.error || ''));
14065
+ const unchanged = targetSnapshot(d) === before;
14066
+ if (r.status !== 1 || !report || report.ok !== false || report.code !== 'runtime_layout_incompatible'
14067
+ || !observed || observed[1] !== reason) bad.push(`admission ${path.basename(d)}/${sub}: exit=${r.status} report=${JSON.stringify(report)}`);
14068
+ if (!unchanged) bad.push(`admission ${path.basename(d)}/${sub}: 대상 전체 bytes/mtime 변경`);
14069
+ (dbg.admission || (dbg.admission = [])).push({ target: path.basename(d), sub, exit: r.status, reason: observed && observed[1], unchanged });
14070
+ return report;
14071
+ };
14072
+ // 조기 CLI 거부가 기존 저수준 술어/권고 계약 검사를 지우지 않게 실제 제품 함수를 별도 child에서 호출한다.
14073
+ // 제품을 복사/재구현하지 않고 require.main 가드 아래 원본 전체를 메모리 compile해 이 함수만 export한다.
14074
+ const lowLevelEnforce = (d, sub) => cp.spawnSync(process.execPath, ['-e', [
14075
+ "const fs = require('fs'), path = require('path'), Module = require('module');",
14076
+ 'const [cli, root, sub] = process.argv.slice(1);',
14077
+ "process.argv = [process.execPath, cli, 'enforce', sub, '--path', root, '--json'];",
14078
+ 'const loaded = new Module(cli, module); loaded.filename = cli; loaded.paths = Module._nodeModulePaths(path.dirname(cli));',
14079
+ "loaded._compile(fs.readFileSync(cli, 'utf8') + '\\nmodule.exports.__e2eEnforce = enforceCmd;\\n', cli);",
14080
+ 'loaded.exports.__e2eEnforce(root, sub);',
14081
+ ].join('\n'), CLI, d, sub], { cwd: d, encoding: 'utf8', timeout: 300000, env: envP, maxBuffer: 32 * 1024 * 1024 });
14008
14082
  // git 자신이 말하는 훅 경로(core.hooksPath 반영) — 하드코딩하면 이 클래스를 못 잡는다.
14009
14083
  const _gitHookOf = (d) => {
14010
14084
  const r = G(d, ['rev-parse', '--git-path', 'hooks/pre-commit']);
@@ -14034,8 +14108,10 @@ total++;
14034
14108
  };
14035
14109
  const seen = {};
14036
14110
  for (const [label, d] of Object.entries(cases)) {
14037
- const st = J(RP(d, ['enforce', 'status', '--path', d, '--json']));
14038
- const ins = J(RP(d, ['enforce', 'install', '--path', d, '--json']));
14111
+ const damaged = label === 'brokenHead' || label === 'brokenDir';
14112
+ if (damaged) { deniedEnforce(d, 'status'); deniedEnforce(d, 'install'); }
14113
+ const st = J(damaged ? lowLevelEnforce(d, 'status') : RP(d, ['enforce', 'status', '--path', d, '--json']));
14114
+ const ins = J(damaged ? lowLevelEnforce(d, 'install') : RP(d, ['enforce', 'install', '--path', d, '--json']));
14039
14115
  if (!st) { bad.push(`①${label}: status --json 파싱 실패`); continue; }
14040
14116
  if (!ins) { bad.push(`①${label}: install --json 파싱 실패`); continue; }
14041
14117
  seen[label] = { gitState: st.gitState, installable: st.installable, insOk: ins.ok === true, code: ins.code || null };
@@ -14054,10 +14130,11 @@ total++;
14054
14130
  if (seen['not-a-repo'] && seen['not-a-repo'].gitState === (seen.ok || {}).gitState) bad.push('①git 아님과 정상이 같은 상태로 보고됨');
14055
14131
  if (seen.brokenHead && seen.brokenHead.gitState === (seen.ok || {}).gitState) bad.push('①HEAD 손상과 정상이 같은 상태로 보고됨');
14056
14132
 
14057
- // ⑥ audit 도 **같은 어휘**로 답한다(종전엔 install 이 ok:true 인 저장소에서 audit 만 실패했다).
14133
+ // ⑥ CLI admission과 별도로 저수준 audit 도 **같은 어휘**로 답한다(종전엔 install 이 ok:true 인 저장소에서 audit 만 실패했다).
14058
14134
  {
14059
14135
  const d = cases.brokenHead;
14060
- const au = J(RP(d, ['enforce', 'audit', '--path', d, '--json']));
14136
+ for (const target of [cases.brokenHead, cases.brokenDir]) deniedEnforce(target, 'audit');
14137
+ const au = J(lowLevelEnforce(d, 'audit'));
14061
14138
  if (!au) bad.push('⑥audit --json 파싱 실패');
14062
14139
  else {
14063
14140
  if (au.ok !== false) bad.push(`⑥손상 저장소인데 audit ok=${au.ok}`);
@@ -14200,7 +14277,7 @@ total++;
14200
14277
  RP(b, ['init', b, '--yes', '--minimal']);
14201
14278
  try { fs.rmSync(path.join(b, '.git'), { recursive: true, force: true }); } catch {}
14202
14279
  gitSpawn(['init', '-q', '--bare', path.join(b, '.git')], { encoding: 'utf8' });
14203
- const bi = J(RP(b, ['enforce', 'install', '--path', b, '--json']));
14280
+ const bi = deniedEnforce(b, 'install', 'git_bare_unsupported');
14204
14281
  if (bi && bi.ok === true) bad.push('⑭bare 저장소에 "설치됨" 이라 답함(강제될 수 없는 곳이다)');
14205
14282
  // (c) **실패한 설치는 흔적을 남기지 않는다.** 실제로 실패하는 경우로 잰다(bare 저장소).
14206
14283
  // ⚠ `core.hooksPath` 를 없는 디렉토리로 두는 것은 실패 케이스가 아니다 — 제품이 그 디렉토리를 만들고
@@ -14238,7 +14315,8 @@ total++;
14238
14315
  const viaRun = G(d, ['hook', 'run', 'pre-commit', '--', '--leerness-probe']);
14239
14316
  if (viaRun.status === 0) bad.push('⑮`git hook run` 에 인자를 넘겨 강제가 우회됨');
14240
14317
  // ③ 임시 사본이 남지 않는다.
14241
- const leftovers = fs.readdirSync(os.tmpdir()).filter(f => /^leerness-probe-/.test(f)).length;
14318
+ // 자식 CLI의 전용 TMP만 검사한다. 부모 공용 Temp의 파일은 이 블록 소유가 아니다.
14319
+ const leftovers = fs.readdirSync(sb).filter(f => /^leerness-probe-/.test(f)).length;
14242
14320
  if (leftovers > 0) bad.push(`⑮검증용 임시 사본 ${leftovers}개 잔존`);
14243
14321
  dbg.noSwitch = { hookHasSwitch: hookP && fs.existsSync(hookP) ? /ENFORCE_PROBE|leerness-probe/.test(fs.readFileSync(hookP, 'utf8')) : null,
14244
14322
  envBypass: viaEnv.status === 0, runBypass: viaRun.status === 0, leftovers };
@@ -14307,7 +14385,7 @@ total++;
14307
14385
  const h1 = fs.existsSync(hp) ? fs.readFileSync(hp, 'utf8') : null;
14308
14386
  const c1 = fs.existsSync(cp2) ? fs.readFileSync(cp2, 'utf8') : null;
14309
14387
  G(d, ['config', 'core.bare', 'true']); // 재설치를 실패시킨다
14310
- const i2 = J(RP(d, ['enforce', 'install', '--path', d, '--json']));
14388
+ const i2 = deniedEnforce(d, 'install', 'git_bare_unsupported');
14311
14389
  if (i2 && i2.ok === true) bad.push('⑱실패시켰는데 설치 성공이라 답함(계측 붕괴)');
14312
14390
  const h2 = fs.existsSync(hp) ? fs.readFileSync(hp, 'utf8') : null;
14313
14391
  const c2 = fs.existsSync(cp2) ? fs.readFileSync(cp2, 'utf8') : null;
@@ -14368,19 +14446,22 @@ total++;
14368
14446
  }
14369
14447
 
14370
14448
  // ⑪ `check` 는 네 번째 표면인데 git 상태를 아예 안 봤다 — 훅이 개념적으로 부르는 표면이라 여기서 조용하면 강제가 조용해진다.
14371
- // 단 **차단하지는 않는다**(커밋 훅 경로에서 우리 판정 실패로 사용자 커밋을 막지 않는다).
14449
+ // 저수준 권고는 **차단하지 않는다**. CLI 자체는 그보다 먼저 admission에서 명시적으로 거부한다.
14372
14450
  {
14373
- const ck = J(RP(cases.brokenHead, ['enforce', 'check', '--path', cases.brokenHead, '--json']));
14451
+ for (const target of [cases.brokenHead, cases.brokenDir]) deniedEnforce(target, 'check');
14452
+ const lowCheck = lowLevelEnforce(cases.brokenHead, 'check');
14453
+ const ck = J(lowCheck);
14374
14454
  if (!ck) bad.push('⑪손상 저장소 check --json 파싱 실패');
14375
14455
  else {
14376
14456
  if (!ck.gitState) bad.push('⑪check 가 git 상태를 말하지 않음(조용한 통과)');
14377
14457
  if (ck.enforced !== false) bad.push(`⑪git 을 못 쓰는데 enforced=${ck.enforced}`);
14378
14458
  if (ck.ok !== true) bad.push('⑪check 가 사용자 커밋을 막았다(권고 원칙 위반)');
14459
+ if (lowCheck.status !== 0) bad.push(`⑪저수준 check 권고가 exit=${lowCheck.status} 로 차단`);
14379
14460
  }
14380
14461
  // 대조군 — 정상 저장소에서는 이 경로가 발동하지 않는다.
14381
14462
  const ck2 = J(RP(cases.ok, ['enforce', 'check', '--path', cases.ok, '--json']));
14382
14463
  if (ck2 && ck2.note === 'git_unusable') bad.push('⑪정상 저장소인데 git 사용불가로 판정(오탐)');
14383
- dbg.check = { broken: ck && ck.gitState, normalNote: ck2 && ck2.note };
14464
+ dbg.check = { broken: ck && ck.gitState, lowLevelExit: lowCheck.status, normalNote: ck2 && ck2.note };
14384
14465
  }
14385
14466
  dbg.states = seen;
14386
14467
  ok = bad.length === 0;