leerness 1.36.160 → 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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.161 — 2026-08-25
4
+
5
+ - 전체 E2E가 잡은 ci init의 낡은 v4 기대값을 v7/Node 24 계약으로 갱신했습니다. i18n doctor는 동일 픽스처에서 영문·한국어 각각 139초/exit 0으로 재현 통과해 제품 결함이 아닌 장시간 부하 타임아웃으로 판별했으며, 테스트 상한을 600초로 조정하고 실패 진단을 보강했습니다.
6
+
7
+ - 외부 Codex 교차 검수에서 확인한 Windows 자식 env npm_execpath 불일치와 ci init 템플릿 v4 잔존을 수정했습니다. release channel/view/pack/publish 성공·실패, CLI 누락 ENOENT, 셸 메타문자 argv 보존을 실제 명령으로 검증하고 CI에 Node 24/26 전용 런타임 매트릭스를 추가했습니다. 이 실제 npm 경로 프로브는 상위 `LEERNESS_OFFLINE=1` 게이트에서도 대소문자가 다른 상속 `NPM_EXECPATH`를 제거하고 로컬 fake CLI를 단일 키로 명시해 네트워크 없이 같은 계약을 검증합니다.
8
+
9
+ - GitHub Actions의 checkout/setup-node를 Node 24 기반 v7으로 올리고, Windows 릴리스 경로가 npm.cmd 셸 대신 npm-cli.js를 Node로 직접 실행하도록 바꿔 DEP0190과 셸 메타문자 재해석 위험을 제거했습니다. Linux/macOS는 shell:false npm 실행을 유지하며 전용 release-runtime 프로브를 fast/core/full 게이트에 연결했습니다.
10
+
3
11
  ## 1.36.160 — 2026-08-25
4
12
 
5
13
  - CI 전체 E2E를 Linux/Windows에서 결정화하고, 다중 AI 세션 presence와 상태 쓰기 락을 고유 토큰 owner·heartbeat 가시성·기본 fail-closed로 강화했습니다. fencing 없는 PID/host/mtime 판정으로 기존 owner를 자동 탈취하지 않습니다.
package/README.md CHANGED
@@ -126,7 +126,7 @@ MIT
126
126
  <!-- leerness:project-readme:start -->
127
127
  ## Leerness Project Harness
128
128
 
129
- 이 프로젝트는 Leerness v1.36.160 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
129
+ 이 프로젝트는 Leerness v1.36.161 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
130
130
 
131
131
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
132
132
 
@@ -180,7 +180,7 @@ leerness memory restore decision <date|title>
180
180
 
181
181
  ### MCP server (외부 AI 통합)
182
182
 
183
- Leerness v1.36.160는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
183
+ Leerness v1.36.161는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
184
184
 
185
185
  ```jsonc
186
186
  // 카테고리별
@@ -201,7 +201,7 @@ Leerness v1.36.160는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
201
201
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
202
202
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
203
203
 
204
- 현재 누적: **v1.9.x → 1.36.160 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
204
+ 현재 누적: **v1.9.x → 1.36.161 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
205
205
 
206
206
  ### 성능 가이드
207
207
 
@@ -239,5 +239,5 @@ leerness release pack --close --auto-main-push
239
239
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
240
240
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
241
241
 
242
- Last synced by Leerness v1.36.160: 2026-08-25
242
+ Last synced by Leerness v1.36.161: 2026-08-25
243
243
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
8
+ const { spawnNpmSync } = require('../lib/npm-process');
8
9
  const os = require('os'); // 1.9.178: _publishToNpm 에서 os.tmpdir() 사용 (전역 import)
9
10
  const readline = require('readline');
10
11
  // 1.9.274 (UR-0025 1단계): 순수 유틸 함수 모듈 분리 (require-based, 비파괴). selftest 7종이 동작 검증.
@@ -35,7 +36,7 @@ const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits:
35
36
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
37
  const _cursorHook = require('../lib/cursor-session-hook');
37
38
 
38
- const VERSION = '1.36.160';
39
+ const VERSION = '1.36.161';
39
40
 
40
41
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
41
42
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -5237,7 +5238,9 @@ function _selfTestCases() {
5237
5238
  { name: 'GPT-5.5 전략리뷰 §6.7 (UR-0152): ci init — PR gate 워크플로 생성 + exit-code 정책 (1.9.444) + 강화(1.33.1 버전핀/권한/concurrency)', run: () => {
5238
5239
  if (typeof ciInitCmd !== 'function') return false;
5239
5240
  const wf = LEERNESS_GATE_WORKFLOW;
5240
- const contentOk = /name:\s*leerness-gate/.test(wf) && /on:\s*\n\s*pull_request:/.test(wf) && /exit code 정책/.test(wf) && /actions\/checkout@v4/.test(wf);
5241
+ const contentOk = /name:\s*leerness-gate/.test(wf) && /on:\s*\n\s*pull_request:/.test(wf) && /exit code 정책/.test(wf)
5242
+ && /actions\/checkout@v7/.test(wf) && /actions\/setup-node@v7/.test(wf)
5243
+ && /node-version:\s*'24'/.test(wf) && /package-manager-cache:\s*false/.test(wf);
5241
5244
  // 1.33.1 강화: 버전 핀(leerness@x.y.z gate, latest 와 동일) + 최소권한 permissions + concurrency cancel
5242
5245
  const hardened = new RegExp('leerness@' + VERSION.replace(/\./g, '\\.') + ' gate \\.').test(wf)
5243
5246
  && /permissions:\s*\n\s*contents: read/.test(wf)
@@ -22618,7 +22621,7 @@ function releaseChannelCmd(root) {
22618
22621
  const tag = _resolveNpmTag(arg('--npm-tag', null));
22619
22622
  let distTags = null;
22620
22623
  if (!has('--offline') && process.env.LEERNESS_OFFLINE !== '1') {
22621
- try { const r = cp.spawnSync('npm', ['view', 'leerness', 'dist-tags', '--json'], { encoding: 'utf8', timeout: 10000, shell: true }); if (r.status === 0 && r.stdout) distTags = JSON.parse(r.stdout); } catch {}
22624
+ try { const r = spawnNpmSync(['view', 'leerness', 'dist-tags', '--json'], { encoding: 'utf8', timeout: 10000 }); if (r.status === 0 && r.stdout) distTags = JSON.parse(r.stdout); } catch {}
22622
22625
  }
22623
22626
  if (json) { log(JSON.stringify({ version: v, defaultPublishTag: tag, distTags, policy: { stable: 'latest', experimental: 'next' } }, null, 2)); return; }
22624
22627
  log(`# leerness release channel (1.9.275, UR-0026)`);
@@ -22671,8 +22674,8 @@ function _publishToNpm(root, opts = {}) {
22671
22674
 
22672
22675
  // 1) 이미 publish된 버전인지 확인 (npm view <pkg>@<version> version)
22673
22676
  try {
22674
- const viewR = cp.spawnSync('npm', ['view', `${pkgName}@${pkgVersion}`, 'version'], {
22675
- cwd: root, encoding: 'utf8', shell: true, timeout: 15000
22677
+ const viewR = spawnNpmSync(['view', `${pkgName}@${pkgVersion}`, 'version'], {
22678
+ cwd: root, encoding: 'utf8', timeout: 15000
22676
22679
  });
22677
22680
  if (viewR.status === 0 && (viewR.stdout || '').trim() === pkgVersion) {
22678
22681
  log(` ✓ 이미 npm registry에 publish됨 — skip`);
@@ -22684,7 +22687,7 @@ function _publishToNpm(root, opts = {}) {
22684
22687
  if (!opts.forcePublish) {
22685
22688
  let publishedLatest = null;
22686
22689
  try {
22687
- const latestR = cp.spawnSync('npm', ['view', pkgName, 'version'], { cwd: root, encoding: 'utf8', shell: true, timeout: 15000 });
22690
+ const latestR = spawnNpmSync(['view', pkgName, 'version'], { cwd: root, encoding: 'utf8', timeout: 15000 });
22688
22691
  if (latestR.status === 0) publishedLatest = (latestR.stdout || '').trim();
22689
22692
  } catch {}
22690
22693
  const gate = _shouldPublishNpm(pkgVersion, publishedLatest, false);
@@ -22710,8 +22713,8 @@ function _publishToNpm(root, opts = {}) {
22710
22713
  if (otp) baseArgs.push(`--otp=${otp}`);
22711
22714
  const args = opts.dryRun ? [...baseArgs, '--dry-run'] : baseArgs;
22712
22715
  log(` ${opts.dryRun ? '(dry-run) ' : ''}npm publish 시도 중... (dist-tag: ${npmTag})`);
22713
- const pubR = cp.spawnSync('npm', args, {
22714
- cwd: root, encoding: 'utf8', shell: true, timeout: 60000,
22716
+ const pubR = spawnNpmSync(args, {
22717
+ cwd: root, encoding: 'utf8', timeout: 60000,
22715
22718
  env: { ...process.env, npm_config_loglevel: 'warn' }
22716
22719
  });
22717
22720
  if (pubR.status === 0) {
@@ -22785,7 +22788,7 @@ async function releasePackCmd(root) {
22785
22788
 
22786
22789
  // 2. npm pack
22787
22790
  if (!dryRun) {
22788
- const r = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
22791
+ const r = spawnNpmSync(['pack'], { cwd: root, encoding: 'utf8' });
22789
22792
  if (r.status !== 0) { fail('npm pack 실패'); log(r.stderr); process.exitCode = 1; return; }
22790
22793
  const tarMatch = (r.stdout || '').match(/[^\s]+\.tgz/);
22791
22794
  if (tarMatch) ok(`npm pack → ${tarMatch[0]}`);
@@ -22866,7 +22869,7 @@ function releasePublish(root) {
22866
22869
  if (has('--pack') || has('--npm-publish') || (!has('--git-push') && !has('--gh-release') && !has('--gh-pages'))) {
22867
22870
  if (dryRun) { log('(dry-run) npm pack 생략 (실행 안 함)'); }
22868
22871
  else {
22869
- const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
22872
+ const packR = spawnNpmSync(['pack'], { cwd: root, encoding: 'utf8' });
22870
22873
  if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
22871
22874
  ok('npm pack 완료');
22872
22875
  }
@@ -22923,7 +22926,7 @@ function releasePublish(root) {
22923
22926
  const npmTag = _resolveNpmTag(arg('--npm-tag', null));
22924
22927
  const args = dryRun ? ['publish', '--dry-run', '--tag', npmTag] : ['publish', '--access', 'public', '--tag', npmTag];
22925
22928
  log('npm ' + args.join(' '));
22926
- const r = cp.spawnSync('npm', args, { cwd: root, encoding: 'utf8', shell: true });
22929
+ const r = spawnNpmSync(args, { cwd: root, encoding: 'utf8' });
22927
22930
  log((r.stdout || '').split('\n').slice(-5).join('\n'));
22928
22931
  if (r.status !== 0) { fail('npm publish 실패'); process.exitCode = 1; return; }
22929
22932
  }
@@ -25859,10 +25862,11 @@ const LEERNESS_GATE_WORKFLOW = [
25859
25862
  ' gate:',
25860
25863
  ' runs-on: ubuntu-latest',
25861
25864
  ' steps:',
25862
- ' - uses: actions/checkout@v4',
25863
- ' - uses: actions/setup-node@v4',
25865
+ ' - uses: actions/checkout@v7',
25866
+ ' - uses: actions/setup-node@v7',
25864
25867
  ' with:',
25865
- " node-version: '20'",
25868
+ " node-version: '24'",
25869
+ ' package-manager-cache: false',
25866
25870
  ' - name: leerness gate',
25867
25871
  ' run: npx -y leerness@' + VERSION + ' gate .',
25868
25872
  '',
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const cp = require('child_process');
6
+
7
+ function _isFile(filePath) {
8
+ if (!filePath) return false;
9
+ try { return fs.statSync(filePath).isFile(); } catch { return false; }
10
+ }
11
+
12
+ function _envValue(env, name) {
13
+ if (!env) return null;
14
+ if (env[name]) return env[name];
15
+ const key = Object.keys(env).find(candidate => candidate.toLowerCase() === name.toLowerCase());
16
+ return key ? env[key] : null;
17
+ }
18
+
19
+ function _npmCliCandidates(execPath, env) {
20
+ const nodeDir = path.dirname(execPath);
21
+ const envExecPath = _envValue(env, 'npm_execpath');
22
+ const envNpmCli = /[\\/]npm-cli\.(?:c?js)$/i.test(String(envExecPath || '')) ? envExecPath : null;
23
+ return [
24
+ envNpmCli,
25
+ path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
26
+ path.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
27
+ path.resolve(nodeDir, '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
28
+ ].filter(Boolean);
29
+ }
30
+
31
+ function resolveNpmCliPath(options = {}) {
32
+ const execPath = options.execPath || process.execPath;
33
+ const env = options.env || process.env;
34
+ const candidates = _npmCliCandidates(execPath, env);
35
+ for (const candidate of candidates) {
36
+ if (_isFile(candidate)) return candidate;
37
+ }
38
+
39
+ if ((options.platform || process.platform) !== 'win32') return null;
40
+ const where = cp.spawnSync('where.exe', ['npm.cmd'], {
41
+ encoding: 'utf8',
42
+ env,
43
+ timeout: 5000,
44
+ windowsHide: true,
45
+ });
46
+ if (where.status !== 0) return null;
47
+ for (const wrapper of String(where.stdout || '').split(/\r?\n/).filter(Boolean)) {
48
+ const candidate = path.join(path.dirname(wrapper.trim()), 'node_modules', 'npm', 'bin', 'npm-cli.js');
49
+ if (_isFile(candidate)) return candidate;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ function _missingNpmResult() {
55
+ const error = new Error('npm CLI JavaScript entrypoint not found');
56
+ error.code = 'ENOENT';
57
+ return {
58
+ pid: 0,
59
+ output: [null, '', error.message],
60
+ stdout: '',
61
+ stderr: error.message,
62
+ status: null,
63
+ signal: null,
64
+ error,
65
+ };
66
+ }
67
+
68
+ function spawnNpmSync(args, options = {}) {
69
+ if (!Array.isArray(args)) throw new TypeError('npm args must be an array');
70
+ const npmArgs = args.map(value => String(value));
71
+ const hasExplicitCli = Object.prototype.hasOwnProperty.call(options, 'npmCliPath');
72
+ const { npmCliPath: explicitCli, ...childOptions } = options;
73
+ const spawnOptions = { ...childOptions, shell: false };
74
+ const env = spawnOptions.env || process.env;
75
+ const resolvedCli = hasExplicitCli
76
+ ? (_isFile(explicitCli) ? explicitCli : null)
77
+ : resolveNpmCliPath({ execPath: process.execPath, env, platform: process.platform });
78
+
79
+ // npm_execpath가 가리키는 npm-cli.js를 우선하면 npm/pnpm을 섞어 실행하지 않고,
80
+ // Windows에서도 .cmd 셸 래퍼 없이 argv를 그대로 보존할 수 있다.
81
+ if (resolvedCli) {
82
+ if (spawnOptions.windowsHide === undefined) spawnOptions.windowsHide = true;
83
+ return cp.spawnSync(process.execPath, [resolvedCli, ...npmArgs], spawnOptions);
84
+ }
85
+
86
+ if (process.platform === 'win32' || hasExplicitCli) return _missingNpmResult();
87
+ return cp.spawnSync('npm', npmArgs, spawnOptions);
88
+ }
89
+
90
+ module.exports = { resolveNpmCliPath, spawnNpmSync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.160",
3
+ "version": "1.36.161",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -48,11 +48,11 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
51
+ "test": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
53
53
  "test:commands": "node ./scripts/e2e-command-surface.js",
54
54
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
55
- "test:fast": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/smoke.js",
55
+ "test:fast": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/smoke.js",
56
56
  "test:smoke": "node ./scripts/smoke.js",
57
57
  "lint": "node ./scripts/lint.js",
58
58
  "prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
package/scripts/e2e.js CHANGED
@@ -6746,12 +6746,15 @@ total++;
6746
6746
  cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
6747
6747
  const ci = cp.spawnSync(process.execPath, [CLI, 'ci', 'init', d], { encoding: 'utf8', timeout: 15000 });
6748
6748
  const wf = fs.readFileSync(path.join(d, '.github', 'workflows', 'leerness-gate.yml'), 'utf8');
6749
- // 버전 핀(설치 버전 == package.json) · 미핀 latest 부재 · 최소권한 · concurrency 취소 · gate 호출
6749
+ // 버전 핀(설치 버전 == package.json) · 미핀 latest 부재 · 최소권한 · concurrency 취소 · Node 24 액션/런타임 · gate 호출
6750
6750
  const pinned = new RegExp('run: npx -y leerness@' + ver.replace(/\./g, '\\.') + ' gate \\.').test(wf);
6751
6751
  const noUnpinned = !/npx -y leerness gate \./.test(wf);
6752
6752
  const perms = /permissions:\n\s*contents: read/.test(wf);
6753
6753
  const conc = /concurrency:\n\s*group: leerness-gate-\$\{\{ github\.ref \}\}\n\s*cancel-in-progress: true/.test(wf);
6754
- const stillGate = /leerness-gate/.test(wf) && /pull_request:/.test(wf) && /actions\/checkout@v4/.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);
6755
6758
  fs.rmSync(d, { recursive: true, force: true });
6756
6759
  ok = ci.status === 0 && pinned && noUnpinned && perms && conc && stillGate;
6757
6760
  } catch {}
@@ -7216,12 +7219,15 @@ total++;
7216
7219
  // ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
7217
7220
  // 1.36.87: doctor 는 내부에서 selftest 를 돌린다 — 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
7218
7221
  // 제한을 올린 대신 종료코드도 함께 단언한다 — 타임아웃(status=null)이 출력 매칭만으로 통과하면 안 된다(codex 26차 #11).
7219
- // 1.36.127 (실측): 1.36.105 가 형제 블록의 doctor 제한을 120s→300s 로 올리며 "여유가 1.2배뿐이라
7220
- // 전체 부하에서 반복해 터진다" 고 적었는데, **이 클론은 120s 로 남아 있었다**(같은 수정의 누락분).
7221
- // 이번 게이트에서 실제로 여기서만 터졌고, 격리 실측은 `doctor --language en` **120s**(= 제한과 동일, 여유 1.0배).
7222
- // 측정 대상은 속도가 아니라 언어 렌더이므로 넉넉히 준다.
7223
- const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 300000, cwd: d });
7224
- const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 300000, cwd: d });
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;
7225
7231
  const docEn = out(docEnR), docKo = out(docKoR);
7226
7232
  const doctorOk = docEnR.status === 0 && docKoR.status === 0
7227
7233
  && /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
@@ -7300,7 +7306,10 @@ total++;
7300
7306
  ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
7301
7307
  // 1.36.87: 단언 18개를 && 로 묶고 실패 시 어느 것인지 알려주지 않아, 진단하려면 22분짜리 스위트를 통째로
7302
7308
  // 다시 돌려야 했다. 다른 블록들과 같은 규율으로 실패 플래그를 노출한다.
7303
- if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk, hoEnOk, hoKoOk, edEnOk, edKoOk, shEnOk, shKoOk, agEnOk, agKoOk, f3En, f3Ko, f4 }));
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 }));
7304
7313
  } catch (e) { console.log(' [i18n 디버그] 예외: ' + ((e && e.message) || e)); }
7305
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 행위 회귀 가드 실패');
7306
7315
  if (!ok) failed++;
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const cp = require('child_process');
8
+
9
+ const root = path.resolve(__dirname, '..');
10
+ const cliPath = path.join(root, 'bin', 'leerness.js');
11
+ const npmProcessPath = path.join(root, 'lib', 'npm-process.js');
12
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-release-runtime-'));
13
+ const marker = path.join(sandbox, 'shell-injection-marker');
14
+ const fakeNpmDir = path.join(sandbox, 'fake-npm');
15
+ const fakeNpmCli = path.join(fakeNpmDir, 'npm-cli.js');
16
+ const fakeNpmLog = path.join(sandbox, 'fake-npm-calls.jsonl');
17
+ const failures = [];
18
+
19
+ function readFakeCalls() {
20
+ try {
21
+ return fs.readFileSync(fakeNpmLog, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
22
+ } catch { return []; }
23
+ }
24
+
25
+ function runCli(args, env = process.env) {
26
+ return cp.spawnSync(process.execPath, [cliPath, ...args], {
27
+ cwd: sandbox,
28
+ env,
29
+ encoding: 'utf8',
30
+ timeout: 60000,
31
+ });
32
+ }
33
+
34
+ function overrideEnvCaseInsensitive(base, overrides) {
35
+ const env = { ...base };
36
+ for (const [name, value] of Object.entries(overrides)) {
37
+ for (const key of Object.keys(env)) {
38
+ if (key.toLowerCase() === name.toLowerCase()) delete env[key];
39
+ }
40
+ env[name] = value;
41
+ }
42
+ return env;
43
+ }
44
+
45
+ try {
46
+ fs.mkdirSync(fakeNpmDir, { recursive: true });
47
+ fs.writeFileSync(path.join(sandbox, 'package.json'), JSON.stringify({ name: 'release-runtime-probe', version: '9.9.9' }) + '\n');
48
+ fs.writeFileSync(fakeNpmCli, [
49
+ "'use strict';",
50
+ "const fs = require('fs');",
51
+ 'const args = process.argv.slice(2);',
52
+ "if (process.env.LEERNESS_NPM_PROBE_LOG) fs.appendFileSync(process.env.LEERNESS_NPM_PROBE_LOG, JSON.stringify(args) + '\\n');",
53
+ "if (args[0] === '--version') process.stdout.write('99.0.0\\n');",
54
+ "else if (args[0] === 'view' && args.includes('dist-tags')) process.stdout.write(JSON.stringify({ latest: '9.9.9', next: '10.0.0-next.1' }) + '\\n');",
55
+ "else if (args[0] === 'view') process.stdout.write('9.9.9\\n');",
56
+ "else if (args[0] === 'pack') process.stdout.write('release-runtime-probe-9.9.9.tgz\\n');",
57
+ "else if (args[0] === 'publish') process.stdout.write('+ release-runtime-probe@9.9.9\\n');",
58
+ 'const exitCode = Number(process.env.LEERNESS_NPM_PROBE_EXIT || 0);',
59
+ 'if (Number.isFinite(exitCode) && exitCode !== 0) process.exit(exitCode);',
60
+ ].join('\n') + '\n');
61
+
62
+ const childScript = [
63
+ `const { spawnNpmSync } = require(${JSON.stringify(npmProcessPath)});`,
64
+ 'const warnings = [];',
65
+ "process.on('warning', warning => warnings.push({ code: warning.code, message: warning.message }));",
66
+ "const result = spawnNpmSync(['--version'], { encoding: 'utf8', timeout: 30000 });",
67
+ 'setImmediate(() => {',
68
+ ' process.stdout.write(JSON.stringify({',
69
+ ' status: result.status,',
70
+ " stdout: String(result.stdout || '').trim(),",
71
+ " stderr: String(result.stderr || ''),",
72
+ ' error: result.error ? String(result.error.code || result.error.message) : null,',
73
+ ' warnings,',
74
+ ' }));',
75
+ '});',
76
+ ].join('\n');
77
+ const child = cp.spawnSync(process.execPath, ['--trace-deprecation', '-e', childScript], {
78
+ cwd: root,
79
+ encoding: 'utf8',
80
+ timeout: 60000,
81
+ });
82
+ let result = null;
83
+ try { result = JSON.parse(String(child.stdout || '')); } catch {}
84
+ if (child.status !== 0 || !result || result.status !== 0 || !/^\d+\.\d+\.\d+/.test(result.stdout || '')) {
85
+ failures.push(`shell 없는 npm --version 실행 실패: ${String(child.stderr || child.stdout || '').slice(0, 300)}`);
86
+ }
87
+ const warningText = `${String(child.stderr || '')} ${JSON.stringify(result && result.warnings || [])}`;
88
+ if (/DEP0190|shell option true/i.test(warningText)) failures.push('npm 실행에서 DEP0190 경고가 발생함');
89
+
90
+ const { resolveNpmCliPath, spawnNpmSync } = require(npmProcessPath);
91
+ const fakeEnv = overrideEnvCaseInsensitive(process.env, {
92
+ // test:fast deliberately runs the product offline. These cases exercise the
93
+ // real npm-backed release paths with a local fake CLI, so keep that parent
94
+ // policy from short-circuiting the behavior under test.
95
+ LEERNESS_OFFLINE: '0',
96
+ npm_execpath: fakeNpmCli,
97
+ LEERNESS_NPM_PROBE_LOG: fakeNpmLog,
98
+ });
99
+ const casingProbe = overrideEnvCaseInsensitive(
100
+ { NPM_EXECPATH: path.join(sandbox, 'inherited-wrong-npm-cli.js') },
101
+ { npm_execpath: fakeNpmCli },
102
+ );
103
+ const casingKeys = Object.keys(casingProbe).filter(key => key.toLowerCase() === 'npm_execpath');
104
+ if (casingKeys.length !== 1 || casingKeys[0] !== 'npm_execpath' || casingProbe.npm_execpath !== fakeNpmCli) {
105
+ failures.push(`Windows npm_execpath 대소문자 alias 정규화 실패: ${JSON.stringify(casingKeys)}`);
106
+ }
107
+ const resolved = resolveNpmCliPath({ env: fakeEnv });
108
+ if (resolved !== fakeNpmCli) {
109
+ failures.push(`자식 env의 npm_execpath를 사용하지 않음: ${resolved || '(없음)'}`);
110
+ }
111
+ fs.writeFileSync(fakeNpmLog, '');
112
+ const argvResult = spawnNpmSync([
113
+ '--version',
114
+ '&&',
115
+ process.execPath,
116
+ '-e',
117
+ `require('fs').writeFileSync(${JSON.stringify(marker)}, 'unsafe')`,
118
+ ], { cwd: sandbox, env: fakeEnv, encoding: 'utf8', timeout: 30000 });
119
+ const argvCalls = readFakeCalls();
120
+ const expectedArgv = ['--version', '&&', process.execPath, '-e', `require('fs').writeFileSync(${JSON.stringify(marker)}, 'unsafe')`];
121
+ if (argvResult.status !== 0 || argvCalls.length !== 1 || JSON.stringify(argvCalls[0]) !== JSON.stringify(expectedArgv)) {
122
+ failures.push(`npm argv 보존 실패: ${JSON.stringify(argvCalls)}`);
123
+ }
124
+ if (fs.existsSync(marker)) failures.push('npm 인자의 셸 메타문자가 별도 명령으로 실행됨');
125
+
126
+ const missing = spawnNpmSync(['--version'], {
127
+ npmCliPath: path.join(sandbox, 'missing', 'npm-cli.js'),
128
+ cwd: sandbox,
129
+ encoding: 'utf8',
130
+ });
131
+ if (missing.status !== null || !missing.error || missing.error.code !== 'ENOENT') {
132
+ failures.push(`npm CLI 누락 결과가 spawn 호환 ENOENT가 아님: ${JSON.stringify({ status: missing.status, code: missing.error && missing.error.code })}`);
133
+ }
134
+
135
+ fs.writeFileSync(fakeNpmLog, '');
136
+ const channel = runCli(['release', 'channel', sandbox, '--json'], fakeEnv);
137
+ let channelJson = null;
138
+ try { channelJson = JSON.parse(String(channel.stdout || '')); } catch {}
139
+ const channelCalls = readFakeCalls();
140
+ if (channel.status !== 0 || !channelJson || !channelJson.distTags || channelJson.distTags.latest !== '9.9.9'
141
+ || !channelCalls.some(call => JSON.stringify(call) === JSON.stringify(['view', 'leerness', 'dist-tags', '--json']))) {
142
+ failures.push(`release channel 실제 npm view 경로 실패: exit=${channel.status} calls=${JSON.stringify(channelCalls)}`);
143
+ }
144
+
145
+ const failingEnv = { ...fakeEnv, LEERNESS_NPM_PROBE_EXIT: '7' };
146
+ const channelFailure = runCli(['release', 'channel', sandbox, '--json'], failingEnv);
147
+ let channelFailureJson = null;
148
+ try { channelFailureJson = JSON.parse(String(channelFailure.stdout || '')); } catch {}
149
+ if (channelFailure.status !== 0 || !channelFailureJson || channelFailureJson.distTags !== null) {
150
+ failures.push(`release channel npm view 실패 폴백 오류: exit=${channelFailure.status}`);
151
+ }
152
+ const packFailure = runCli(['release', 'pack', sandbox, '--no-readme-sync'], failingEnv);
153
+ if (packFailure.status !== 1 || !/npm pack 실패/.test(String(packFailure.stdout || ''))) {
154
+ failures.push(`release pack npm 실패 전파 오류: exit=${packFailure.status}`);
155
+ }
156
+ const publishFailure = runCli(['release', 'publish', sandbox, '--dry-run', '--npm-publish'], failingEnv);
157
+ if (publishFailure.status !== 1 || !/npm publish 실패/.test(String(publishFailure.stdout || ''))) {
158
+ failures.push(`release publish npm 실패 전파 오류: exit=${publishFailure.status}`);
159
+ }
160
+
161
+ const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'ci.yml'), 'utf8');
162
+ const refs = Array.from(workflow.matchAll(/uses:\s*actions\/(checkout|setup-node)@(v\d+)/g), match => `${match[1]}@${match[2]}`);
163
+ if (refs.length < 6 || refs.some(ref => !/@v7$/.test(ref))) {
164
+ failures.push(`CI 액션 런타임 major 불일치: ${refs.join(', ') || '(없음)'}`);
165
+ }
166
+ if (!/release-runtime:\s*[\s\S]*node:\s*\[24, 26\]/.test(workflow)
167
+ || !/name:\s*release runtime \(shell-free npm\)/.test(workflow)) {
168
+ failures.push('CI가 전체 게이트와 Node 24/26 매트릭스에서 릴리스 런타임 프로브를 실행하지 않음');
169
+ }
170
+
171
+ const ciFixture = path.join(sandbox, 'ci-init-fixture');
172
+ fs.mkdirSync(ciFixture, { recursive: true });
173
+ const ciInit = runCli(['ci', 'init', ciFixture, '--force', '--json'], { ...process.env, LEERNESS_OFFLINE: '1' });
174
+ const generatedWorkflowPath = path.join(ciFixture, '.github', 'workflows', 'leerness-gate.yml');
175
+ const generatedWorkflow = fs.existsSync(generatedWorkflowPath) ? fs.readFileSync(generatedWorkflowPath, 'utf8') : '';
176
+ if (ciInit.status !== 0 || !/actions\/checkout@v7/.test(generatedWorkflow)
177
+ || !/actions\/setup-node@v7/.test(generatedWorkflow)
178
+ || !/node-version:\s*'24'/.test(generatedWorkflow)
179
+ || !/package-manager-cache:\s*false/.test(generatedWorkflow)
180
+ || /actions\/(?:checkout|setup-node)@v4/.test(generatedWorkflow)) {
181
+ failures.push(`ci init 생성 워크플로 Node 24 런타임 전환 실패: exit=${ciInit.status}`);
182
+ }
183
+
184
+ const cliSource = fs.readFileSync(path.join(root, 'bin', 'leerness.js'), 'utf8');
185
+ const publishStart = cliSource.indexOf('function _publishToNpm');
186
+ const publishEnd = cliSource.indexOf('\nasync function releasePackCmd', publishStart);
187
+ const publishBlock = publishStart >= 0 && publishEnd > publishStart
188
+ ? cliSource.slice(publishStart, publishEnd) : '';
189
+ if (!publishBlock.includes('spawnNpmSync') || /cp\.spawnSync\(['"]npm['"]|shell:\s*true/.test(publishBlock)) {
190
+ failures.push('_publishToNpm이 shell 없는 공용 npm 실행 경로를 사용하지 않음');
191
+ }
192
+ const releaseStart = cliSource.indexOf('function releasePackCmd');
193
+ const releaseEnd = cliSource.indexOf('// ===== 1.9.7 A:', releaseStart);
194
+ const releaseBlock = releaseStart >= 0 && releaseEnd > releaseStart
195
+ ? cliSource.slice(releaseStart, releaseEnd) : '';
196
+ if (/cp\.spawnSync\(['"]npm['"]/.test(releaseBlock)) {
197
+ failures.push('release pack/publish 경로에 직접 npm spawn이 남아 있음');
198
+ }
199
+ } catch (error) {
200
+ failures.push(String(error && (error.stack || error.message) || error));
201
+ } finally {
202
+ try { fs.rmSync(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } catch {}
203
+ }
204
+
205
+ if (failures.length) {
206
+ process.stderr.write(`release runtime probe failed:\n- ${failures.join('\n- ')}\n`);
207
+ process.exit(1);
208
+ }
209
+
210
+ process.stdout.write('release runtime probe passed: Node 24 actions + shell-free npm argv\n');