leerness 1.36.148 → 1.36.161

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Windows/Linux 플랫폼 경계의 빠른 회귀:
5
+ // 1) selftest가 Windows 8.3(`RUNNER~1`) 실행 경로를 허용하는지,
6
+ // 2) 공유 hooksPath의 같은 pre-commit이 현재 sibling worktree의 handoff를 보는지.
7
+ // 전체 e2e의 같은 검증은 Windows에서 80분가량 걸리므로 CI fast 단계에서 먼저 실행한다.
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const cp = require('child_process');
13
+
14
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
15
+ const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-platform-smoke-'));
16
+ const env = Object.assign({}, process.env, {
17
+ TMPDIR: sandbox,
18
+ TEMP: sandbox,
19
+ TMP: sandbox,
20
+ LEERNESS_OFFLINE: '1',
21
+ LEERNESS_NO_PROMPT: '1',
22
+ LEERNESS_NO_STALE_CHECK: '1',
23
+ // 일부 POSIX 셸은 CDPATH를 사용한 cd 성공 경로를 stdout에 출력한다. 훅의 명령 치환이
24
+ // 그 출력에 오염되지 않는지 실제로 검증하도록 일부러 비어 있지 않게 둔다.
25
+ CDPATH: '.',
26
+ });
27
+ for (const key of [
28
+ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
29
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID',
30
+ 'CI', 'GITHUB_ACTIONS', 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM',
31
+ 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_ENFORCE_BYPASS',
32
+ 'GIT_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_NOSYSTEM',
33
+ ]) delete env[key];
34
+ env.GIT_CONFIG_GLOBAL = path.join(sandbox, 'gitconfig-global');
35
+ env.GIT_CONFIG_SYSTEM = path.join(sandbox, 'gitconfig-system');
36
+ fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '');
37
+ fs.writeFileSync(env.GIT_CONFIG_SYSTEM, '');
38
+
39
+ const run = (cmd, args, cwd, timeout = 120000) => cp.spawnSync(cmd, args, {
40
+ cwd,
41
+ env,
42
+ encoding: 'utf8',
43
+ timeout,
44
+ maxBuffer: 32 * 1024 * 1024,
45
+ });
46
+ const git = (cwd, args) => run('git', args, cwd, 60000);
47
+ const leerness = (cwd, args, timeout = 120000) => run(process.execPath, [CLI, ...args], cwd, timeout);
48
+ const parseJson = (r) => {
49
+ try { return JSON.parse(String(r.stdout || '')); } catch { return null; }
50
+ };
51
+ const output = (r) => ({
52
+ status: r && r.status,
53
+ signal: r && r.signal,
54
+ stdout: String((r && r.stdout) || '').slice(0, 500),
55
+ stderr: String((r && r.stderr) || '').slice(0, 500),
56
+ error: r && r.error ? String(r.error.code || r.error.message) : null,
57
+ });
58
+ const hookPath = (cwd) => {
59
+ const r = git(cwd, ['rev-parse', '--git-path', 'hooks/pre-commit']);
60
+ const raw = String(r.stdout || '').trim();
61
+ return path.isAbsolute(raw) ? raw : path.join(cwd, raw);
62
+ };
63
+
64
+ let failed = null;
65
+ let cleanupError = null;
66
+ const debug = {};
67
+ try {
68
+ // Windows Node 20 runner에서 enforce의 실제 sh 발화 검증까지 포함한 전체 selftest가
69
+ // 120초를 근소하게 넘는다. 전체 E2E보다 먼저 실패를 잡는 fast gate 목적은 유지하되
70
+ // 프로세스를 중간에 잘라 거짓 실패로 만들지 않도록 이 단계만 240초를 허용한다.
71
+ const selftest = leerness(sandbox, ['selftest', '--json'], 240000);
72
+ const selfJson = parseJson(selftest);
73
+ const pathCase = selfJson && Array.isArray(selfJson.results)
74
+ ? selfJson.results.find(row => /Windows 8\.3 tilde/.test(row.name || ''))
75
+ : null;
76
+ const selfFailures = selfJson && Array.isArray(selfJson.results)
77
+ ? selfJson.results.filter(row => row.ok !== true).slice(0, 10)
78
+ : [];
79
+ debug.shortPath = { command: output(selftest), case: pathCase || null, selfFailures };
80
+ if (selftest.error && selftest.error.code === 'ETIMEDOUT') {
81
+ failed = 'platform smoke 선행 selftest가 240초 안에 끝나지 않음';
82
+ } else if (!pathCase || pathCase.ok !== true) {
83
+ failed = 'Windows 8.3 실행 경로 selftest가 통과하지 않음';
84
+ } else if (selftest.status !== 0) {
85
+ failed = `platform smoke 선행 selftest 실패: ${selfFailures.map(row => row.name).join(', ') || '원인 미확인'}`;
86
+ }
87
+
88
+ const main = path.join(sandbox, 'main');
89
+ const sibling = path.join(sandbox, 'sibling');
90
+ fs.mkdirSync(main, { recursive: true });
91
+ const setup = [
92
+ git(main, ['init', '-q', '-b', 'main', '.']),
93
+ git(main, ['config', 'user.email', 'platform-smoke@example.invalid']),
94
+ git(main, ['config', 'user.name', 'platform-smoke']),
95
+ ];
96
+ fs.writeFileSync(path.join(main, 'package.json'), '{"name":"platform-smoke","version":"0.0.0"}\n');
97
+ fs.writeFileSync(path.join(main, 'seed.txt'), 'seed\n');
98
+ setup.push(git(main, ['add', '-A']), git(main, ['commit', '-qm', 'seed']));
99
+ const initMain = leerness(main, ['init', main, '--yes', '--minimal', '--no-enforce', '--no-stale-check']);
100
+ const mainHandoff = leerness(main, ['handoff', main]);
101
+ const addWorktree = git(main, ['worktree', 'add', '-qb', 'sibling', sibling]);
102
+ const initSibling = leerness(sibling, ['init', sibling, '--yes', '--minimal', '--no-enforce', '--no-stale-check']);
103
+ for (const marker of [path.join(sibling, '.harness', 'last-handoff.json'), path.join(sibling, '.harness', 'cache', 'handoffs')]) {
104
+ try { fs.rmSync(marker, { recursive: true, force: true }); } catch {}
105
+ }
106
+ debug.fixture = {
107
+ setup: setup.map(output), initMain: output(initMain), mainHandoff: output(mainHandoff),
108
+ addWorktree: output(addWorktree), initSibling: output(initSibling),
109
+ };
110
+ if (!failed && (setup.some(r => r.status !== 0) || initMain.status !== 0 || mainHandoff.status !== 0
111
+ || addWorktree.status !== 0 || initSibling.status !== 0)) {
112
+ failed = '공유 훅 worktree 픽스처 생성 실패';
113
+ }
114
+
115
+ const actual = path.join(main, '.git', 'shared-hooks');
116
+ const alias = path.join(main, '.git', 'link-hooks');
117
+ fs.mkdirSync(actual, { recursive: true });
118
+ fs.symlinkSync(actual, alias, process.platform === 'win32' ? 'junction' : 'dir');
119
+ const configs = [
120
+ git(main, ['config', 'extensions.worktreeConfig', 'true']),
121
+ git(main, ['config', '--worktree', 'core.hooksPath', alias]),
122
+ git(sibling, ['config', '--worktree', 'core.hooksPath', actual]),
123
+ ];
124
+ const before = parseJson(leerness(main, ['enforce', 'status', '--path', main, '--json']));
125
+ const installResult = leerness(main, ['enforce', 'install', '--path', main, '--json', '--skip-verify']);
126
+ const installed = parseJson(installResult);
127
+ const mainHook = hookPath(main);
128
+ const siblingHook = hookPath(sibling);
129
+ const installedHookBody = fs.existsSync(mainHook) ? fs.readFileSync(mainHook, 'utf8') : '';
130
+ debug.hooks = {
131
+ configs: configs.map(output), before, installed, install: output(installResult),
132
+ mainHook, siblingHook,
133
+ mainHookExists: fs.existsSync(mainHook), siblingHookExists: fs.existsSync(siblingHook),
134
+ sameTarget: fs.existsSync(mainHook) && fs.existsSync(siblingHook)
135
+ ? fs.realpathSync(mainHook) === fs.realpathSync(siblingHook) : false,
136
+ dynamicWorktreeRoot: installedHookBody.includes('LRN_REL=')
137
+ && installedHookBody.includes('git rev-parse --show-toplevel')
138
+ && installedHookBody.includes('CDPATH= cd "$LRN_TOP"'),
139
+ };
140
+ if (!failed && (configs.some(r => r.status !== 0) || !before || before.worktrees !== 2
141
+ || installResult.status !== 0 || !installed || installed.worktrees !== 2
142
+ || !debug.hooks.sameTarget || !debug.hooks.dynamicWorktreeRoot)) {
143
+ failed = '공유 hooksPath를 두 worktree가 함께 본다는 설치 계약 실패';
144
+ }
145
+
146
+ // main에는 fresh handoff가 있지만 sibling에는 없다. 공유 훅이 설치한 main 상태를 빌려보지 않고
147
+ // 현재 commit 대상의 toplevel을 계산해야 두 호출 모두 sibling에서 차단된다.
148
+ const directBefore = git(sibling, ['hook', 'run', 'pre-commit']);
149
+ fs.writeFileSync(path.join(sibling, 'platform-probe.txt'), 'probe\n');
150
+ const staged = git(sibling, ['add', '-A']);
151
+ const commitBefore = git(sibling, ['commit', '-m', 'must-block-before-sibling-handoff']);
152
+ const siblingHandoff = leerness(sibling, ['handoff', sibling]);
153
+ const directAfter = git(sibling, ['hook', 'run', 'pre-commit']);
154
+ const commitAfter = git(sibling, ['commit', '-m', 'passes-after-sibling-handoff']);
155
+ debug.lifecycle = {
156
+ directBefore: output(directBefore), staged: output(staged), commitBefore: output(commitBefore),
157
+ siblingHandoff: output(siblingHandoff), directAfter: output(directAfter), commitAfter: output(commitAfter),
158
+ };
159
+ if (!failed && (directBefore.status === 0 || staged.status !== 0 || commitBefore.status === 0
160
+ || siblingHandoff.status !== 0 || directAfter.status !== 0 || commitAfter.status !== 0)) {
161
+ failed = '공유 훅이 sibling handoff 전 차단/후 통과 수명주기를 지키지 않음';
162
+ }
163
+ } catch (e) {
164
+ failed = `platform smoke 예외: ${String(e && (e.stack || e.message) || e).slice(0, 800)}`;
165
+ } finally {
166
+ try {
167
+ fs.rmSync(sandbox, { recursive: true, force: true, maxRetries: 8, retryDelay: 75 });
168
+ if (fs.existsSync(sandbox)) cleanupError = 'rmSync 뒤에도 sandbox가 남음';
169
+ } catch (e) {
170
+ let remaining = [];
171
+ try { remaining = fs.readdirSync(sandbox).slice(0, 20); } catch {}
172
+ cleanupError = `${String(e && (e.code || e.message) || e).slice(0, 200)}; remaining=${JSON.stringify(remaining)}`;
173
+ }
174
+ }
175
+
176
+ if (cleanupError) {
177
+ debug.cleanup = cleanupError;
178
+ failed = failed ? `${failed}; cleanup 실패: ${cleanupError}` : `sandbox cleanup 실패: ${cleanupError}`;
179
+ }
180
+
181
+ if (failed) {
182
+ process.stderr.write(`platform smoke failed: ${failed}\n${JSON.stringify(debug, null, 2)}\n`);
183
+ process.exit(1);
184
+ }
185
+ process.stdout.write('platform smoke passed: Windows 8.3 paths + shared-hook sibling handoff lifecycle\n');
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const cp = require('child_process');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const sourceRoot = path.resolve(__dirname, '..');
10
+
11
+ function nextPatch(version) {
12
+ const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/);
13
+ if (!match) throw new Error(`unsupported fixture version: ${version}`);
14
+ return match[4] ? `${match[1]}.${match[2]}.${match[3]}` : `${match[1]}.${match[2]}.${Number(match[3]) + 1}`;
15
+ }
16
+
17
+ function versionFromSource(file) {
18
+ const match = fs.readFileSync(file, 'utf8').match(/const VERSION = '([^']+)'/);
19
+ return match && match[1];
20
+ }
21
+
22
+ function copyFixture(root) {
23
+ for (const dir of ['bin', 'lib']) fs.cpSync(path.join(sourceRoot, dir), path.join(root, dir), { recursive: true });
24
+ for (const file of ['package.json', 'README.md']) fs.copyFileSync(path.join(sourceRoot, file), path.join(root, file));
25
+ fs.mkdirSync(path.join(root, '.harness'), { recursive: true });
26
+ const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
27
+ fs.writeFileSync(path.join(root, '.harness', 'HARNESS_VERSION'), `${version}\n`);
28
+ }
29
+
30
+ function runProbe(root) {
31
+ const before = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
32
+ const expected = nextPatch(before);
33
+ const cli = path.join(root, 'bin', 'leerness.js');
34
+ // Windows는 대소문자를 구별하지 않지만 path.resolve는 문자열 대소문자를 보존한다.
35
+ // 실제 사용자가 소문자 드라이브 문자로 넘긴 경우도 자체 패키지 판별이 유지돼야 한다.
36
+ const rootArg = process.platform === 'win32' ? root.slice(0, 1).toLowerCase() + root.slice(1) : root;
37
+ const run = cp.spawnSync(process.execPath, [cli, 'release', 'bump', '--patch', '--path', rootArg], {
38
+ cwd: root,
39
+ encoding: 'utf8',
40
+ timeout: 30000
41
+ });
42
+ const after = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
43
+ const harnessVersion = fs.readFileSync(path.join(root, '.harness', 'HARNESS_VERSION'), 'utf8').trim();
44
+ const binVersion = versionFromSource(cli);
45
+ const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
46
+ const unknownFlag = /unknown flag|알 수 없는 플래그/i.test(`${run.stdout}\n${run.stderr}`);
47
+ const ok = run.status === 0 && !unknownFlag && after === expected && harnessVersion === expected
48
+ && binVersion === expected && readme.includes(`Leerness v${expected}`);
49
+ return { ok, before, expected, after, harnessVersion, binVersion, unknownFlag, exit: run.status };
50
+ }
51
+
52
+ function runGenericProbe(root) {
53
+ const readmeBefore = '# Independent package\n';
54
+ fs.mkdirSync(root);
55
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'independent-package', version: '1.0.0' }, null, 2) + '\n');
56
+ fs.writeFileSync(path.join(root, 'README.md'), readmeBefore);
57
+ const run = cp.spawnSync(process.execPath, [path.join(sourceRoot, 'bin', 'leerness.js'), 'release', 'bump', '--patch', '--path', root], {
58
+ cwd: root,
59
+ encoding: 'utf8',
60
+ timeout: 30000
61
+ });
62
+ const after = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
63
+ const readmeAfter = fs.readFileSync(path.join(root, 'README.md'), 'utf8');
64
+ const unknownFlag = /unknown flag|알 수 없는 플래그/i.test(`${run.stdout}\n${run.stderr}`);
65
+ return { ok: run.status === 0 && !unknownFlag && after === '1.0.1' && readmeAfter === readmeBefore, after, unknownFlag, exit: run.status };
66
+ }
67
+
68
+ function runPreflightFailureProbe(root) {
69
+ copyFixture(root);
70
+ const cli = path.join(root, 'bin', 'leerness.js');
71
+ const source = fs.readFileSync(cli, 'utf8');
72
+ // 유효한 JS이지만 release preflight가 요구하는 단일 선언 표면은 의도적으로 깨뜨린다.
73
+ fs.writeFileSync(cli, source.replace(/^const VERSION = '([^']+)';$/m, "const VERSION = String('$1');"));
74
+ const targets = [
75
+ path.join(root, 'package.json'),
76
+ path.join(root, '.harness', 'HARNESS_VERSION'),
77
+ cli,
78
+ path.join(root, 'README.md')
79
+ ];
80
+ const before = new Map(targets.map(file => [file, fs.readFileSync(file)]));
81
+ const run = cp.spawnSync(process.execPath, [cli, 'release', 'bump', '--patch', '--path', root, '--json'], {
82
+ cwd: root,
83
+ encoding: 'utf8',
84
+ timeout: 30000
85
+ });
86
+ const unchanged = targets.every(file => fs.readFileSync(file).equals(before.get(file)));
87
+ let payload = null; try { payload = JSON.parse(run.stdout || ''); } catch {}
88
+ return { ok: run.status !== 0 && unchanged && payload?.code === 'release_preflight_failed', exit: run.status, unchanged, code: payload?.code };
89
+ }
90
+
91
+ function runPartialRollbackProbe(root) {
92
+ copyFixture(root);
93
+ const cli = path.join(root, 'bin', 'leerness.js');
94
+ const targets = [
95
+ path.join(root, 'package.json'),
96
+ path.join(root, '.harness', 'HARNESS_VERSION'),
97
+ cli,
98
+ path.join(root, 'README.md')
99
+ ];
100
+ const before = new Map(targets.map(file => [file, fs.readFileSync(file)]));
101
+ const api = require(cli);
102
+ const snapshots = targets.map(api._releaseFileSnapshot);
103
+ const transaction = api._runReleaseTransaction(snapshots, () => {
104
+ // 실제 release 순서와 같이 package → harness → bin까지 일부 변경한 뒤 실패를 주입한다.
105
+ const pkg = JSON.parse(fs.readFileSync(targets[0], 'utf8'));
106
+ pkg.version = '9.9.9';
107
+ fs.writeFileSync(targets[0], JSON.stringify(pkg, null, 2) + '\n');
108
+ fs.writeFileSync(targets[1], '9.9.9\n');
109
+ fs.writeFileSync(targets[2], fs.readFileSync(targets[2], 'utf8')
110
+ .replace(/^const VERSION = '[^']+';$/m, "const VERSION = '9.9.9';"));
111
+ throw new Error('injected after partial release writes');
112
+ });
113
+ const unchanged = targets.every(file => fs.readFileSync(file).equals(before.get(file)));
114
+ return {
115
+ ok: transaction.ok === false && unchanged && transaction.rollbackErrors.length === 0
116
+ && /injected after partial/.test(String(transaction.error && transaction.error.message)),
117
+ unchanged,
118
+ rollbackErrors: transaction.rollbackErrors
119
+ };
120
+ }
121
+
122
+ const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-release-bump-probe-'));
123
+ try {
124
+ copyFixture(fixture);
125
+ const own = runProbe(fixture);
126
+ const generic = runGenericProbe(path.join(fixture, 'generic-package'));
127
+ const preflight = runPreflightFailureProbe(path.join(fixture, 'broken-own-package'));
128
+ const partialRollback = runPartialRollbackProbe(path.join(fixture, 'partial-rollback-own-package'));
129
+ if (!(own.ok && generic.ok && preflight.ok && partialRollback.ok)) {
130
+ process.stderr.write(`release-bump probe failed: ${JSON.stringify({ own, generic, preflight, partialRollback })}\n`);
131
+ process.exitCode = 1;
132
+ } else {
133
+ process.stdout.write(`release-bump probe passed: ${own.before} -> ${own.after}; harness ${own.harnessVersion}; generic ${generic.after}; preflight ${preflight.code}; partial rollback restored\n`);
134
+ }
135
+ } finally {
136
+ fs.rmSync(fixture, { recursive: true, force: true });
137
+ }
@@ -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');
package/scripts/smoke.js CHANGED
@@ -2,7 +2,9 @@
2
2
  'use strict';
3
3
 
4
4
  // 1.9.273 (UR-0027, GPT-5.5 리뷰): 빠른 핵심-경로 smoke 테스트.
5
- // 전체 e2e(220 케이스, 수 분)는 CI/릴리스 게이트용. 이 smoke 는 개발 중 빠른 피드백(<30s)용 —
5
+ // 전체 e2e(수백 케이스)는 CI/릴리스 게이트용. 이 smoke 는 개발 중 제한된 핵심 명령만 확인한다.
6
+ // Windows 에서는 실제 Git shell 발화 검증까지 포함한 selftest가 120초를 넘을 수 있어
7
+ // 해당 단계만 240초 상한을 사용한다(T-0133/T-0137).
6
8
  // 핵심 명령이 정상 동작하는지만 단일 임시 프로젝트에서 빠르게 확인한다.
7
9
  // 실패 시 exit 1 (CI 친화). 사용: npm run test:fast 또는 node scripts/smoke.js
8
10
 
@@ -20,7 +22,7 @@ const t0 = Date.now();
20
22
  // 단일 명령 실행 + assert. check(라벨, args, {expectFail?, match?(stdout)→bool})
21
23
  function check(label, args, opts = {}) {
22
24
  total++;
23
- const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: opts.cwd || tmp, encoding: 'utf8', timeout: 30000 });
25
+ const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: opts.cwd || tmp, encoding: 'utf8', timeout: opts.timeout || 30000 });
24
26
  let ok = (r.status === 0) === !opts.expectFail;
25
27
  if (ok && typeof opts.match === 'function') { try { ok = !!opts.match(r.stdout || '', r.stderr || ''); } catch { ok = false; } }
26
28
  process.stdout.write(`${ok ? '✓' : '✗'} ${label}${opts.expectFail ? ' (expect-fail)' : ''}\n`);
@@ -32,7 +34,7 @@ console.log(`# leerness smoke (test:fast) @ ${tmp}`);
32
34
 
33
35
  // 1) 코어 무결성
34
36
  check('--version', ['--version'], { match: s => /^\d+\.\d+\.\d+/.test(s.trim()) });
35
- check('selftest (pure 함수 무결성)', ['selftest'], { match: s => /통과/.test(s) });
37
+ check('selftest (pure 함수 무결성)', ['selftest'], { timeout: 240000, match: s => /통과/.test(s) });
36
38
 
37
39
  // 2) 설치 + 필수 파일
38
40
  check('init', ['init', tmp, '--yes', '--language', 'ko', '--skills', 'recommended']);