leerness 1.36.164 → 1.36.165

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.165 — 2026-08-26
4
+
5
+ - verify-code가 fresh basic 권한에서도 Python/Go/Rust 러너를 제한적으로 실행하고, 혼합 Node/Python 테스트와 npm placeholder를 올바르게 판별하며, 실패·차단된 최신 증거가 lazy/gate를 거짓 통과하지 않도록 수정 (T-0143)
6
+
3
7
  ## 1.36.164 — 2026-08-26
4
8
 
5
9
  - `release publish --npm-publish`가 `release sync-main`과 동일한 보안 배포 경로를 사용하도록 통합했습니다. `prepublishOnly`와 pack은 npm/provider/cloud 시크릿 및 npm auth/OTP를 제거한 환경과 별도의 0바이트 임시 userconfig에서 실행하고, 그 단계가 생성한 exact `.tgz`를 권한 제한 임시 `.npmrc`와 `--ignore-scripts`로 그대로 업로드합니다. 따라서 npm이 기본 사용자 `.npmrc` 경로를 lifecycle에 다시 주입하거나 postpack이 작업 디렉터리를 바꿔도 인증정보와 검증 산출물의 경계가 유지됩니다. 토큰·OTP는 argv에 싣지 않으며 임시 인증 파일은 zeroize·삭제를 재시도합니다. 정리 실패와 `sync-main`의 npm 실패도 전체 명령 실패로 전파하고, registry 오류는 알려진 credential을 비식별화합니다. 긴 오류의 `EPUBLISHCONFLICT`도 전체 출력에서 분류한 뒤 exact version 재조회가 성공할 때만 멱등 성공으로 인정합니다. 토큰이 없으면 스크립트를 끈 업로드 단계에서만 기존 npm userconfig 로그인을 유지하며 dry-run은 인증 파일과 lifecycle을 모두 생략해 프로젝트 무쓰기를 보장합니다. 이 경계들을 실제 npm CLI·로컬 Git remote 공격성 프로브로 검증합니다.
package/README.md CHANGED
@@ -128,7 +128,7 @@ MIT
128
128
  <!-- leerness:project-readme:start -->
129
129
  ## Leerness Project Harness
130
130
 
131
- 이 프로젝트는 Leerness v1.36.164 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
131
+ 이 프로젝트는 Leerness v1.36.165 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
132
132
 
133
133
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
134
134
 
@@ -182,7 +182,7 @@ leerness memory restore decision <date|title>
182
182
 
183
183
  ### MCP server (외부 AI 통합)
184
184
 
185
- Leerness v1.36.164는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
185
+ Leerness v1.36.165는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
186
186
 
187
187
  ```jsonc
188
188
  // 카테고리별
@@ -203,7 +203,7 @@ Leerness v1.36.164는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
203
203
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
204
204
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
205
205
 
206
- 현재 누적: **v1.9.x → 1.36.164 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
206
+ 현재 누적: **v1.9.x → 1.36.165 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
207
207
 
208
208
  ### 성능 가이드
209
209
 
@@ -241,5 +241,5 @@ leerness release pack --close --auto-main-push
241
241
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
242
242
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
243
243
 
244
- Last synced by Leerness v1.36.164: 2026-08-26
244
+ Last synced by Leerness v1.36.165: 2026-08-26
245
245
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -47,7 +47,7 @@ const {
47
47
  migrateLegacyWorkspace,
48
48
  } = require('../lib/workspace-dir');
49
49
 
50
- const VERSION = '1.36.164';
50
+ const VERSION = '1.36.165';
51
51
 
52
52
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
53
53
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -5775,6 +5775,29 @@ function _selfTestCases() {
5775
5775
  const extOk = _isCommandPermitted({ mode: 'extended', shell: { exec: true, allowList: [] } }, 'python x.py', {}) === true; // exec:true → 허용
5776
5776
  return blockedDefault && blockedPy && allowedAuth && coreStillOk && extOk;
5777
5777
  } },
5778
+ { name: 'T-0143: verify-code 고정 비-JS 러너만 basic 호출단위 허용 + 임의 python 차단 유지 (행위)', run: () => {
5779
+ const basic = { mode: 'basic', shell: { exec: false, allowList: [] } };
5780
+ const scoped = { commandAllowList: VERIFY_CODE_RUNNER_ALLOW };
5781
+ return _isCommandPermitted(basic, 'pytest -q', scoped) === true
5782
+ && _isCommandPermitted(basic, 'go test ./...', scoped) === true
5783
+ && _isCommandPermitted(basic, 'cargo test', scoped) === true
5784
+ && _isCommandPermitted(basic, 'python arbitrary.py', scoped) === false
5785
+ && _isCommandPermitted(basic, 'pytest -q', {}) === false;
5786
+ } },
5787
+ { name: 'T-0143: 최신 실패/권한차단 증거는 no_test_run, 성공·optional skip 조합만 인정 (행위)', run: () => {
5788
+ const pass = '## 2026-08-26T04:00 verify-code\nCommand: pytest -q\n- test: exit=0\n';
5789
+ const blocked = pass + '\n## 2026-08-26T04:01 verify-code\nCommand: pytest -q\n- test: exit=126\n';
5790
+ const recovered = blocked + '\n## 2026-08-26T04:02 verify-code\nCommand: pytest -q\n- test: exit=0\n';
5791
+ const mixed = '## 2026-08-26T04:00 verify-code\nCommand: npm test\n- test: exit=0\n- typecheck: exit=127\n';
5792
+ const failed = '## 2026-08-26\n```\ncargo test\n1 failed\n```\n';
5793
+ const legacyPass = '## 2026-08-26\n```\ncargo test --all\n42 passed; 0 failed\n```\n';
5794
+ const passingTail = '## 2026-08-26T04:00 verify-code\n- test: exit=0 — `pytest -q`\nTail:\n- child: exit=1\n';
5795
+ const manualFail = '## 2026-08-26\nCommand: pytest -q\nExit: 0\nFAIL: expected exit=0 but got 1\n';
5796
+ return _hasSuccessfulTestRunEvidence(pass) && !_hasSuccessfulTestRunEvidence(blocked)
5797
+ && _hasSuccessfulTestRunEvidence(recovered) && _hasSuccessfulTestRunEvidence(mixed) && !_hasSuccessfulTestRunEvidence(failed)
5798
+ && _hasSuccessfulTestRunEvidence(legacyPass) && _hasSuccessfulTestRunEvidence(passingTail)
5799
+ && !_hasSuccessfulTestRunEvidence(manualFail);
5800
+ } },
5778
5801
  { name: '재실증 P1 (1.18.1): verify-claim 차단 실행은 skip(불일치 판정 아님) + 종합 라벨이 실제 cmd (소스 가드)', run: () => {
5779
5802
  const src = read(__filename);
5780
5803
  const authPass = src.includes('userAuthorized: true, timeout: 5 * 60 * 1000, kind: ' + "'verify_claim_test'");
@@ -15539,6 +15562,38 @@ function encodingCheck(root, opts = {}) {
15539
15562
  }
15540
15563
  }
15541
15564
 
15565
+ const _TEST_RUN_EVIDENCE_RE = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress|go test|cargo test|cargo nextest|rspec|rake test|rake spec|phpunit|dotnet test|mvn test|mvn verify|gradle test|gradlew test|bun test|deno test|mocha|ctest|make test|make check|mix test|swift test|flutter test|dart test|tox|unittest|nose2)\b/i;
15566
+
15567
+ function _hasSuccessfulTestRunEvidence(text) {
15568
+ // 실제 verify-code는 ISO `YYYY-MM-DDTHH:mm`, 정책 예시는 공백형
15569
+ // `YYYY-MM-DD HH:mm`을 쓴다. 둘 다 독립 블록으로 나눠 최신 결과만 본다.
15570
+ const blocks = String(text || '').split(/\n(?=##\s+\d{4}-\d{2}-\d{2}(?:T|\s))/);
15571
+ const relevant = blocks.filter(block => _TEST_RUN_EVIDENCE_RE.test(block));
15572
+ if (!relevant.length) return false;
15573
+ const latest = relevant[relevant.length - 1];
15574
+ // verify-code Tail은 테스트의 정상적인 negative-path 로그도 담는다. 그 안의
15575
+ // "expected child exit=1" 같은 문자열을 실행 결과로 오인하지 않고, 도구가 쓴
15576
+ // summary 행만 신뢰한다. 수동 증거의 Exit: 행은 아래 별도 경로에서 처리한다.
15577
+ const summary = latest.split(/^Tail:\s*$/im)[0];
15578
+ const exits = Array.from(summary.matchAll(/^\s*-\s+.+?:\s*exit\s*=\s*(null|-?\d+)\b/gim), match => {
15579
+ return match[1].toLowerCase() === 'null' ? null : Number(match[1]);
15580
+ });
15581
+ if (exits.length) {
15582
+ // 127 is verify-code's explicit optional-runner skip. A block still needs at
15583
+ // least one actual success, and permission denial (126) is never evidence.
15584
+ return exits.some(code => code === 0) && exits.every(code => code === 0 || code === 127);
15585
+ }
15586
+ const explicitFailure = /(?:^|\n)\s*(?:✗|FAIL\b)/im.test(latest)
15587
+ || /\b(?:test|lint|typecheck|build|bench)\s+failed\b/i.test(latest)
15588
+ || /\b[1-9]\d*\s+failed\b/i.test(latest);
15589
+ if (explicitFailure) return false;
15590
+ const manualExits = Array.from(latest.matchAll(/^\s*Exit\s*:\s*(null|-?\d+)\b/gim), match => {
15591
+ return match[1].toLowerCase() === 'null' ? null : Number(match[1]);
15592
+ });
15593
+ if (manualExits.length) return manualExits.some(code => code === 0) && manualExits.every(code => code === 0);
15594
+ return true;
15595
+ }
15596
+
15542
15597
  // 1.9.101: lazy detect 결과를 JSON으로 노출 (외부 AI/MCP 통합용). opts.json=true 시 verbose 출력 억제 후 JSON만.
15543
15598
  function lazyDetect(root, opts = {}) {
15544
15599
  root = absRoot(root);
@@ -15573,8 +15628,11 @@ function lazyDetect(root, opts = {}) {
15573
15628
  { kind: 'handoff_empty', severity: 'warn' });
15574
15629
  }
15575
15630
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
15576
- // 1.35.16 (lazy 헌트 FP): JS+pytest 중심 목록에 비-JS 러너 추가 — Go/Rust/Ruby/PHP/.NET/Java/Elixir/Swift/Dart/C 등이 실제 테스트를 돌려도 no_test_run 오탐(active 프로젝트에선 blocking → gate 거짓 실패)하던 것 차단. (negative finding 억제라 추가는 FP-safe.)
15577
- const hasTestRun = /\b(npm test|pnpm test|yarn test|pytest|jest|vitest|tsc|eslint|playwright|cypress|go test|cargo test|cargo nextest|rspec|rake test|rake spec|phpunit|dotnet test|mvn test|mvn verify|gradle test|gradlew test|bun test|deno test|mocha|ctest|make test|make check|mix test|swift test|flutter test|dart test|tox|unittest|nose2)\b/i.test(ev);
15631
+ // 1.35.16 (lazy 헌트 FP): JS+pytest 중심 목록에 비-JS 러너 추가 — Go/Rust/Ruby/PHP/.NET/Java/Elixir/Swift/Dart/C 등이 실제 테스트를 돌려도 no_test_run 오탐(active 프로젝트에선 blocking → gate 거짓 실패)하던 것 차단.
15632
+ // T-0143: 러너 이름만 찾으면 verify-code 가 exit=126/1 을 기록한 직후에도 lazy/gate 가 초록이 됐다.
15633
+ // 가장 최근 관련 증거 블록에 구조화 exit 가 있으면 성공(0)과 optional skip(127)만 허용하고,
15634
+ // 구조화되지 않은 기존 수동 증거는 명시적 실패 신호가 없을 때만 이전 호환 동작을 유지한다.
15635
+ const hasTestRun = _hasSuccessfulTestRunEvidence(ev);
15578
15636
  if (!hasTestRun) { issues++; _warn('review-evidence.md has no recorded test/typecheck/lint run',
15579
15637
  { kind: 'no_test_run', severity: 'warn' }); }
15580
15638
  // 1.9.7 C: TODO 자동 추적 강화 — 위치+텍스트 캡처, known-todos 비교, --auto-track 등록
@@ -23227,7 +23285,9 @@ function verifyCodeCmd(root) {
23227
23285
  try { pkg = JSON.parse(read(pkgFile)); } catch (e) { return fail('package.json 파싱 실패: ' + e.message); }
23228
23286
  const scripts = pkg.scripts || {};
23229
23287
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
23230
- if (scripts.test) tasks.push({ name: 'test', cmd: 'npm test', runtime: 'node' });
23288
+ // npm init 기본 placeholder는 실제 검증이 아니며 Python 테스트를 가리면 안 된다.
23289
+ const nodeTest = typeof scripts.test === 'string' && !/no test specified/i.test(scripts.test);
23290
+ if (nodeTest) tasks.push({ name: 'test', cmd: 'npm test', runtime: 'node' });
23231
23291
  else if (scripts['test:smoke']) tasks.push({ name: 'test', cmd: 'npm run test:smoke', runtime: 'node' });
23232
23292
  // 1.9.148: 명시 script 없어도 인기 러너 의존성 발견 시 시도
23233
23293
  else if (deps.vitest) tasks.push({ name: 'test', cmd: 'npx --yes vitest run', runtime: 'node' });
@@ -23240,9 +23300,19 @@ function verifyCodeCmd(root) {
23240
23300
  if (has('--build') && scripts.build) tasks.push({ name: 'build', cmd: 'npm run build', runtime: 'node' });
23241
23301
  if (has('--bench') && scripts.bench) tasks.push({ name: 'bench', cmd: 'npm run bench', runtime: 'node', optional: true });
23242
23302
  }
23243
- // (2) Python: pyproject.toml / setup.py / tests/ 존재 시 pytest 시도
23244
- if (exists(path.join(root, 'pyproject.toml')) || exists(path.join(root, 'setup.py')) || exists(path.join(root, 'tests'))) {
23245
- if (!tasks.find(t => t.name === 'test')) tasks.push({ name: 'test', cmd: 'pytest -q', runtime: 'python', optional: true });
23303
+ // (2) Python: Python 표식이 있으면 Node test와 독립적으로 pytest를 실행한다.
23304
+ // `tests/` 이름만으로는 JS 프로젝트를 오인하므로 그 경로는 실제 .py 파일이 있을 때만 표식이다.
23305
+ const testsDir = path.join(root, 'tests');
23306
+ let hasPythonTests = false;
23307
+ if (exists(testsDir)) {
23308
+ try {
23309
+ if (fs.statSync(testsDir).isDirectory()) {
23310
+ for (const file of walk(testsDir)) { if (/\.py$/i.test(file)) { hasPythonTests = true; break; } }
23311
+ }
23312
+ } catch {}
23313
+ }
23314
+ if (exists(path.join(root, 'pyproject.toml')) || exists(path.join(root, 'setup.py')) || hasPythonTests) {
23315
+ tasks.push({ name: 'test:python', cmd: 'pytest -q', runtime: 'python', optional: true });
23246
23316
  }
23247
23317
  // (3) Go: go.mod 존재 시 go test ./...
23248
23318
  if (exists(path.join(root, 'go.mod'))) {
@@ -23268,7 +23338,7 @@ function verifyCodeCmd(root) {
23268
23338
  const start = Date.now();
23269
23339
  // 1.9.150: runCommandSafe — cwd jail + env scrub + observability 자동 (shell:true 유지 — npm/pytest 호환)
23270
23340
  // 1.9.299 (UR-0039): scrubSecrets — 신뢰 못 할 워크스페이스 test/build 스크립트에 publish 토큰/시크릿 노출 차단.
23271
- const r = runCommandSafe(t.cmd, [], { cwd: root, root, timeout: 5 * 60 * 1000, allowShell: true, scrubSecrets: true, kind: 'verify_code_task', label: `verify-${t.name}` });
23341
+ const r = runCommandSafe(t.cmd, [], { cwd: root, root, timeout: 5 * 60 * 1000, allowShell: true, scrubSecrets: true, commandAllowList: VERIFY_CODE_RUNNER_ALLOW, kind: 'verify_code_task', label: `verify-${t.name}` });
23272
23342
  const dur = Date.now() - start;
23273
23343
  if (r.status === 0) ok(`${t.name} passed (${dur}ms)`);
23274
23344
  else if (t.optional && r.status === 127) warn(`${t.name} 스킵 (${t.cmd} 없음)`);
@@ -26025,6 +26095,9 @@ function _isCwdSafe(root, cwd) {
26025
26095
  }
26026
26096
  // basic 모드에서도 항상 허용하는 핵심 도구 (release/install 흐름 유지)
26027
26097
  const RUN_CORE_ALLOW = ['git', 'npm', 'npx', 'node', 'pnpm', 'yarn'];
26098
+ // verify-code가 파일 표식에서 직접 고정 생성하는 비-JS 러너만 호출 단위로 허용한다.
26099
+ // 전역 core allow에 넣지 않아 다른 명령 표면의 basic 권한은 넓어지지 않는다.
26100
+ const VERIFY_CODE_RUNNER_ALLOW = ['pytest', 'go', 'cargo'];
26028
26101
  // 1.18.1 (재실증 신규 P1): 명령 실행 권한 결정 — 순수 함수(테스트 가능). cwd jail 은 별도(여기서 판단 안 함).
26029
26102
  // userAuthorized: 사용자가 명시적으로 입력한 명령(예: verify-claim --test-cmd "<명령>" 또는 config testCommand)
26030
26103
  // → basic 모드 allowList 우회(명시 권한). 이전엔 coreAllow(JS 도구)만 허용해 --test-cmd "python ..." 가
@@ -26036,12 +26109,13 @@ function _isCommandPermitted(perms, cmdStr, opts) {
26036
26109
  const exec = perms.shell && perms.shell.exec !== false;
26037
26110
  if (exec) return true; // extended/full 모드 (또는 exec:true)
26038
26111
  const allow = (perms.shell && perms.shell.allowList) || [];
26112
+ const scopedAllow = Array.isArray(opts.commandAllowList) ? opts.commandAllowList : [];
26039
26113
  const first = String(cmdStr || '').trim().split(/\s+/)[0];
26040
- return RUN_CORE_ALLOW.includes(first) || allow.includes('*') || allow.includes(first);
26114
+ return RUN_CORE_ALLOW.includes(first) || scopedAllow.includes(first) || allow.includes('*') || allow.includes(first);
26041
26115
  }
26042
26116
 
26043
26117
  function runCommandSafe(cmd, args, opts) {
26044
- // opts: { cwd, root, timeout, env, stdio, kind, label, allowShell, encoding, input, allowOutsideCwd, userAuthorized }
26118
+ // opts: { cwd, root, timeout, env, stdio, kind, label, allowShell, encoding, input, allowOutsideCwd, userAuthorized, commandAllowList }
26045
26119
  opts = opts || {};
26046
26120
  const root = opts.root || opts.cwd || process.cwd();
26047
26121
  const cwd = opts.cwd || root;
@@ -30786,7 +30860,7 @@ module.exports = {
30786
30860
  // 1.9.289: shell-safe 인용 (Codex #3) — 단위 테스트
30787
30861
  _shellQuoteArg,
30788
30862
  // 1.18.1: 명령 실행 권한 결정 (재실증 신규 P1: --test-cmd 비-JS 인터프리터 거짓차단) — 단위 테스트
30789
- _isCommandPermitted, RUN_CORE_ALLOW, _uiLang, _tx,
30863
+ _isCommandPermitted, RUN_CORE_ALLOW, VERIFY_CODE_RUNNER_ALLOW, _hasSuccessfulTestRunEvidence, _uiLang, _tx,
30790
30864
  _loadEnvFile, _dotenvKeyAllowed, _scrubEnv,
30791
30865
  // 1.18.2: verify-claim 위장 스텁(빈 export 껍데기) 판정 — 단위 테스트
30792
30866
  _vcImplIsEmpty, _VC_EMPTY_SHELL_RE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.164",
3
+ "version": "1.36.165",
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,8 +48,8 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.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 && npm run test:installed && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.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",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.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 && npm run test:installed && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.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:installed": "node ./scripts/installed-cleanroom-probe.js",
55
55
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0143: verify-code must execute its own fixed Python/Go/Rust runners under
5
+ // fresh basic permissions, and a failed run must not become green gate evidence.
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const cp = require('child_process');
11
+
12
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
13
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-cross-runtime-'));
14
+ const fakeBin = path.join(tempRoot, 'fake-bin');
15
+ const baseEnv = {
16
+ ...process.env,
17
+ LEERNESS_OFFLINE: '1',
18
+ LEERNESS_NO_BANNER: '1',
19
+ LEERNESS_NO_PROMPT: '1',
20
+ LEERNESS_NO_DRIFT_CHECK: '1',
21
+ };
22
+
23
+ function run(args, cwd, env = baseEnv) {
24
+ return cp.spawnSync(process.execPath, [CLI, ...args], {
25
+ cwd,
26
+ env,
27
+ encoding: 'utf8',
28
+ timeout: 120000,
29
+ });
30
+ }
31
+
32
+ function output(result) {
33
+ return `${result.stdout || ''}\n${result.stderr || ''}`.trim();
34
+ }
35
+
36
+ function check(condition, message, result) {
37
+ if (condition) return;
38
+ const detail = result ? `\n${output(result).slice(0, 1600)}` : '';
39
+ throw new Error(`${message}${detail}`);
40
+ }
41
+
42
+ function write(file, text) {
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ fs.writeFileSync(file, text, 'utf8');
45
+ }
46
+
47
+ function writeRunner(name, stdout, exitCode) {
48
+ fs.mkdirSync(fakeBin, { recursive: true });
49
+ if (process.platform === 'win32') {
50
+ write(path.join(fakeBin, `${name}.cmd`), `@echo off\r\necho ${stdout}\r\nexit /b ${exitCode}\r\n`);
51
+ return;
52
+ }
53
+ const file = path.join(fakeBin, name);
54
+ write(file, `#!/bin/sh\nprintf '%s\\n' '${stdout.replace(/'/g, "'\\''")}'\nexit ${exitCode}\n`);
55
+ fs.chmodSync(file, 0o755);
56
+ }
57
+
58
+ function runnerEnv() {
59
+ return { ...baseEnv, PATH: `${fakeBin}${path.delimiter}${baseEnv.PATH || ''}` };
60
+ }
61
+
62
+ function initProject(name) {
63
+ const root = path.join(tempRoot, name);
64
+ fs.mkdirSync(root, { recursive: true });
65
+ const result = run(['init', root, '--yes', '--language', 'en', '--no-stale-check'], tempRoot);
66
+ check(result.status === 0, `init failed for ${name} (exit ${result.status})`, result);
67
+ return root;
68
+ }
69
+
70
+ function addDoneWork(root) {
71
+ let result = run(['task', 'add', 'cross-runtime verification', '--path', root], root);
72
+ check(result.status === 0, `task add failed (exit ${result.status})`, result);
73
+ const tracker = fs.readFileSync(path.join(root, '.leerness', 'progress-tracker.md'), 'utf8');
74
+ const ids = Array.from(tracker.matchAll(/\|\s*(T-\d{4,})\s*\|/g), match => match[1]);
75
+ const id = ids[ids.length - 1];
76
+ check(!!id, 'task id not found after task add');
77
+ result = run(['task', 'update', id, '--status', 'done', '--evidence', 'cross-runtime fixture implemented', '--path', root], root);
78
+ check(result.status === 0, `task update failed (exit ${result.status})`, result);
79
+ result = run(['session', 'close', '--path', root], root);
80
+ check(result.status === 0, `session close failed (exit ${result.status})`, result);
81
+ }
82
+
83
+ function parseJson(result, label) {
84
+ try { return JSON.parse(result.stdout || '{}'); }
85
+ catch { throw new Error(`${label} returned invalid JSON\n${output(result).slice(0, 1600)}`); }
86
+ }
87
+
88
+ try {
89
+ writeRunner('pytest', '- child: exit=1', 0);
90
+ writeRunner('go', 'ok example.test/module 0.01s', 0);
91
+ writeRunner('cargo', 'test result: ok. 2 passed; 0 failed', 0);
92
+
93
+ const passRoot = initProject('pass');
94
+ write(path.join(passRoot, 'pyproject.toml'), '[project]\nname = "probe"\nversion = "0.0.0"\n');
95
+ write(path.join(passRoot, 'go.mod'), 'module example.test/module\n\ngo 1.22\n');
96
+ write(path.join(passRoot, 'Cargo.toml'), '[package]\nname = "probe"\nversion = "0.0.0"\nedition = "2021"\n');
97
+ const passed = run(['verify-code', '--path', passRoot], passRoot, runnerEnv());
98
+ check(
99
+ passed.status === 0
100
+ && /test:python passed/.test(passed.stdout || '')
101
+ && /test:go passed/.test(passed.stdout || '')
102
+ && /test:rust passed/.test(passed.stdout || ''),
103
+ `cross-runtime verify blocked/failed under basic permissions (exit ${passed.status})`,
104
+ passed,
105
+ );
106
+ const passLazy = run(['lazy', 'detect', '--path', passRoot, '--json'], passRoot, runnerEnv());
107
+ const passLazyJson = parseJson(passLazy, 'passing lazy detect');
108
+ check(
109
+ Array.isArray(passLazyJson.findings) && !passLazyJson.findings.some(f => f.kind === 'no_test_run'),
110
+ 'successful verify Tail text was mistaken for a failed exit',
111
+ passLazy,
112
+ );
113
+
114
+ writeRunner('pytest', '1 failed in 0.01s', 1);
115
+ const failRoot = initProject('fail');
116
+ write(path.join(failRoot, 'pyproject.toml'), '[project]\nname = "probe-fail"\nversion = "0.0.0"\n');
117
+ addDoneWork(failRoot);
118
+ const failedVerify = run(['verify-code', '--path', failRoot], failRoot, runnerEnv());
119
+ check(failedVerify.status === 1, `failing pytest was not propagated (exit ${failedVerify.status})`, failedVerify);
120
+
121
+ const lazy = run(['lazy', 'detect', '--path', failRoot, '--json'], failRoot, runnerEnv());
122
+ const lazyJson = parseJson(lazy, 'lazy detect');
123
+ check(
124
+ lazy.status === 1 && Array.isArray(lazyJson.findings) && lazyJson.findings.some(f => f.kind === 'no_test_run'),
125
+ `failed verify evidence accepted by lazy detect (exit ${lazy.status})`,
126
+ lazy,
127
+ );
128
+
129
+ const gate = run(['gate', '--path', failRoot, '--json'], failRoot, runnerEnv());
130
+ const gateJson = parseJson(gate, 'gate');
131
+ const lazyStep = Array.isArray(gateJson.checks) && gateJson.checks.find(item => item.name === 'lazy detect');
132
+ check(gate.status === 1 && gateJson.ok === false && lazyStep && lazyStep.ok === false, `failed verify evidence accepted by gate (exit ${gate.status})`, gate);
133
+
134
+ writeRunner('npm', 'node suite passed', 0);
135
+ const hybridRoot = initProject('hybrid');
136
+ write(path.join(hybridRoot, 'package.json'), JSON.stringify({ name: 'hybrid-probe', version: '0.0.0', scripts: { test: 'node -e "process.exit(0)"' } }, null, 2) + '\n');
137
+ write(path.join(hybridRoot, 'pyproject.toml'), '[project]\nname = "hybrid-probe"\nversion = "0.0.0"\n');
138
+ const hybrid = run(['verify-code', '--path', hybridRoot], hybridRoot, runnerEnv());
139
+ check(
140
+ hybrid.status === 1 && /test passed/.test(hybrid.stdout || '') && /test:python failed/.test(hybrid.stdout || ''),
141
+ `passing Node test hid failing Python tests (exit ${hybrid.status})`,
142
+ hybrid,
143
+ );
144
+
145
+ writeRunner('pytest', '4 passed in 0.01s', 0);
146
+ const recoveredVerify = run(['verify-code', '--path', failRoot], failRoot, runnerEnv());
147
+ check(recoveredVerify.status === 0, `fixed pytest did not recover verify-code (exit ${recoveredVerify.status})`, recoveredVerify);
148
+ const recoveredLazy = run(['lazy', 'detect', '--path', failRoot, '--json'], failRoot, runnerEnv());
149
+ const recoveredLazyJson = parseJson(recoveredLazy, 'recovered lazy detect');
150
+ check(
151
+ recoveredLazy.status === 0 && Array.isArray(recoveredLazyJson.findings) && !recoveredLazyJson.findings.some(f => f.kind === 'no_test_run'),
152
+ `latest successful verify did not recover lazy detect (exit ${recoveredLazy.status})`,
153
+ recoveredLazy,
154
+ );
155
+ const recoveredGate = run(['gate', '--path', failRoot, '--json'], failRoot, runnerEnv());
156
+ const recoveredGateJson = parseJson(recoveredGate, 'recovered gate');
157
+ check(recoveredGate.status === 0 && recoveredGateJson.ok === true, `latest successful verify did not recover gate (exit ${recoveredGate.status})`, recoveredGate);
158
+
159
+ const placeholderRoot = initProject('placeholder');
160
+ write(path.join(placeholderRoot, 'package.json'), JSON.stringify({ name: 'placeholder-probe', version: '0.0.0', scripts: { test: 'echo "Error: no test specified" && exit 1' } }, null, 2) + '\n');
161
+ write(path.join(placeholderRoot, 'pyproject.toml'), '[project]\nname = "placeholder-probe"\nversion = "0.0.0"\n');
162
+ const placeholder = run(['verify-code', '--path', placeholderRoot], placeholderRoot, runnerEnv());
163
+ check(
164
+ placeholder.status === 0 && /verify-code \(1개\)/.test(placeholder.stdout || '') && /test:python passed/.test(placeholder.stdout || '') && !/^## test: npm test/m.test(placeholder.stdout || ''),
165
+ `npm placeholder masked Python verification (exit ${placeholder.status})`,
166
+ placeholder,
167
+ );
168
+
169
+ const jsOnlyRoot = initProject('js-only-tests-dir');
170
+ write(path.join(jsOnlyRoot, 'package.json'), JSON.stringify({ name: 'js-only-probe', version: '0.0.0' }, null, 2) + '\n');
171
+ write(path.join(jsOnlyRoot, 'tests', 'math.test.js'), 'test("math", () => {});\n');
172
+ const jsOnly = run(['verify-code', '--path', jsOnlyRoot], jsOnlyRoot, runnerEnv());
173
+ check(jsOnly.status === 0 && !/pytest -q/.test(jsOnly.stdout || ''), `JavaScript-only tests directory was misdetected as Python (exit ${jsOnly.status})`, jsOnly);
174
+
175
+ process.stdout.write('PASS verify-code cross-runtime permissions and failed-evidence gate probe\n');
176
+ } catch (error) {
177
+ process.stderr.write(`FAIL verify-code cross-runtime probe: ${error.message}\n`);
178
+ process.exitCode = 1;
179
+ } finally {
180
+ fs.rmSync(tempRoot, { recursive: true, force: true });
181
+ }