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 +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +18 -14
- package/lib/npm-process.js +90 -0
- package/package.json +4 -4
- package/scripts/e2e.js +18 -9
- package/scripts/release-runtime-probe.js +210 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
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 =
|
|
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 =
|
|
22675
|
-
cwd: root, encoding: 'utf8',
|
|
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 =
|
|
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 =
|
|
22714
|
-
cwd: root, encoding: 'utf8',
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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@
|
|
25863
|
-
' - uses: actions/setup-node@
|
|
25865
|
+
' - uses: actions/checkout@v7',
|
|
25866
|
+
' - uses: actions/setup-node@v7',
|
|
25864
25867
|
' with:',
|
|
25865
|
-
" node-version: '
|
|
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.
|
|
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)
|
|
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.
|
|
7220
|
-
//
|
|
7221
|
-
//
|
|
7222
|
-
|
|
7223
|
-
const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout:
|
|
7224
|
-
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;
|
|
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,
|
|
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');
|