leerness 1.36.164 → 1.36.166

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.166 — 2026-08-26
4
+
5
+ - T-0144: unknown/foreign/malformed CLI 플래그를 migration과 쓰기 전에 exit 1로 거부하고 commands 카탈로그의 verify-code·contract verify 누락을 보완했습니다. 명령별 strict route와 값 누락·부울 equals·language 선택값 회귀 probe를 npm test에 추가했습니다.
6
+
7
+ ## 1.36.165 — 2026-08-26
8
+
9
+ - verify-code가 fresh basic 권한에서도 Python/Go/Rust 러너를 제한적으로 실행하고, 혼합 Node/Python 테스트와 npm placeholder를 올바르게 판별하며, 실패·차단된 최신 증거가 lazy/gate를 거짓 통과하지 않도록 수정 (T-0143)
10
+
3
11
  ## 1.36.164 — 2026-08-26
4
12
 
5
13
  - `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.166 하네스를 사용합니다. 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.166는 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.166 릴리스 태그 이력** (수백 라운드) · _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.166: 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.166';
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') 시 호스트 프로세스 오염.
@@ -711,17 +711,57 @@ function _optSub(a) {
711
711
  if (/^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(v)) return undefined; // 선행 구분자 path-like → 하위명령 아님
712
712
  return v;
713
713
  }
714
- function nonFlagArgs() {
714
+ // 같은 이름이 route 에 따라 boolean/value 두 계약을 갖는 역사적 표면.
715
+ // update --check (boolean)
716
+ // referee add --check "command" (value)
717
+ // preview serve --keep (boolean)
718
+ // init/release cleanup --keep N (value)
719
+ // 전역 registry 하나로 arity 를 추측하면 정상 명령을 거부하거나 positional 값을 삼키므로,
720
+ // 명령/하위명령이 확인된 뒤 두 번째 pass 에서만 좁게 override 한다.
721
+ function _commandBooleanValueFlags(cmd, sub) {
722
+ const out = new Set();
723
+ if (cmd === 'update') out.add('--check');
724
+ if (cmd === 'preview' && sub === 'serve') out.add('--keep');
725
+ return out;
726
+ }
727
+ function _nonFlagArgsPass(booleanValueFlags = new Set()) {
715
728
  const out = [];
716
729
  const withValue = _VALUE_FLAGS;
717
730
  const a = process.argv.slice(2);
718
731
  for (let i = 0; i < a.length; i++) {
719
732
  const x = a[i];
720
- if (x.startsWith('-')) { if (withValue.has(x) && a[i+1] && !a[i+1].startsWith('-')) i++; continue; }
733
+ if (x.startsWith('-')) {
734
+ const name = x.split('=')[0];
735
+ if (withValue.has(name) && !booleanValueFlags.has(name) && !x.includes('=') && a[i+1] && !a[i+1].startsWith('-')) i++;
736
+ continue;
737
+ }
721
738
  out.push(x);
722
739
  }
723
740
  return out;
724
741
  }
742
+ function _commandSubHint(cmd) {
743
+ const raw = process.argv.slice(2);
744
+ const at = raw.indexOf(cmd);
745
+ if (at < 0) return undefined;
746
+ for (let i = at + 1; i < raw.length; i++) {
747
+ const token = raw[i];
748
+ if (token.startsWith('-')) {
749
+ const name = token.split('=')[0];
750
+ // preview 의 --keep 은 serve 를 알아야 boolean 임을 알 수 있고, serve 는 --keep 뒤에도 올 수 있다.
751
+ // 이 1개만 subcommand 탐색에서 provisional boolean 으로 보고 실제 arity 는 route 확정 후 검증한다.
752
+ const ambiguousBeforeSubcommand = cmd === 'preview' && name === '--keep';
753
+ if (!ambiguousBeforeSubcommand && _VALUE_FLAGS.has(name) && !token.includes('=') && raw[i + 1] && !raw[i + 1].startsWith('-')) i++;
754
+ continue;
755
+ }
756
+ return token;
757
+ }
758
+ return undefined;
759
+ }
760
+ function nonFlagArgs() {
761
+ const initial = _nonFlagArgsPass();
762
+ const overrides = _commandBooleanValueFlags(initial[0], _commandSubHint(initial[0]) || initial[1]);
763
+ return overrides.size ? _nonFlagArgsPass(overrides) : initial;
764
+ }
725
765
  function argAll(name) {
726
766
  const out = []; const a = process.argv;
727
767
  for (let i = 0; i < a.length; i++) if (a[i] === name && a[i+1] && !a[i+1].startsWith('-')) out.push(a[++i]);
@@ -4823,7 +4863,7 @@ function _selfTestCases() {
4823
4863
  { name: 'CV-2/UR-0077: fetchNpmLatest 신형 Node win EINVAL 회피 (npm JS 진입점 + try/catch)', run: () => { if (typeof fetchNpmLatest !== 'function') return false; const src = read(__filename); const i = src.indexOf('function fetchNpmLatest'); if (i < 0) return false; const body = src.slice(i, i + 1600); return body.includes("spawnNpmSync(['view', pkg, 'version']") && /try \{/.test(body) && !/execFile\(|cmd\.exe \/d/.test(body); } },
4824
4864
  { name: 'CV-1/UR-0076: arg() --path=값 파싱 + _resolveRoot(--path>positional>cwd) 행위', run: () => { if (typeof _resolveRoot !== 'function') return false; const save = process.argv; try { process.argv = ['node', 'h', 'context', '--path=/tmp/eqform']; const eq = arg('--path', null) === '/tmp/eqform'; process.argv = ['node', 'h', 'context', 'X', '--path', '/tmp/flag']; const flagWins = _resolveRoot('X') === '/tmp/flag'; process.argv = ['node', 'h', 'context', '/tmp/pos']; const posWins = _resolveRoot('/tmp/pos') === '/tmp/pos'; process.argv = ['node', 'h', 'context']; const cwdFb = _resolveRoot(undefined) === process.cwd(); return eq && flagWins && posWins && cwdFb; } finally { process.argv = save; } } },
4825
4865
  { name: 'CV-4/UR-0079: _pruneArchives archive retention (최신 keep 유지, 오래된 prune) 행위', run: () => { if (typeof _pruneArchives !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_prune_')); try { const adir = path.join(tmp, '.leerness', 'archive'); fs.mkdirSync(adir, { recursive: true }); for (let i = 0; i < 5; i++) fs.mkdirSync(path.join(adir, 'leerness-1.9.' + i + '-stamp')); const pruned = _pruneArchives(tmp, 2); const left = fs.readdirSync(adir).filter(n => /^leerness-/.test(n)).length; return pruned === 3 && left === 2; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
4826
- { name: 'CV-7/UR-0082: commands 카탈로그 + help 에 누락 명령군 등재 (표면 drift 가드)', run: () => { const src = read(__filename); const ci = src.indexOf('function commandsCmd'); const hi = src.indexOf('function help('); if (ci < 0 || hi < 0) return false; const cbody = src.slice(ci, ci + 8000); const hbody = src.slice(hi, hi + 7000); const must = ['install-safety', 'feature add', 'creds list', 'incident list', 'webhook serve', 'deploy auto', 'runs list', 'permissions list', 'whats-new', 'migrate audit']; return must.every(c => cbody.includes(c)) && hbody.includes('install-safety') && hbody.includes('feature add'); } },
4866
+ { name: 'CV-7/UR-0082: commands 카탈로그 + help 에 누락 명령군 등재 (표면 drift 가드)', run: () => { const src = read(__filename); const ci = src.indexOf('function commandsCmd'); const hi = src.indexOf('function help('); if (ci < 0 || hi < 0) return false; const cbody = src.slice(ci, ci + 8000); const hbody = src.slice(hi, hi + 7000); const must = ['verify-code', 'contract verify', 'install-safety', 'feature add', 'creds list', 'incident list', 'webhook serve', 'deploy auto', 'runs list', 'permissions list', 'whats-new', 'migrate audit']; return must.every(c => cbody.includes(c)) && hbody.includes('install-safety') && hbody.includes('feature add'); } },
4827
4867
  { name: 'UR-0083(4th외부평가 9.3): auto-update hook 비침투 (update --quiet 모드 + hook --check --quiet + 업그레이드)', run: () => { const src = read(__filename); const quietMode = /const quiet = !!opts\.quiet \|\| has\('--quiet'\)/.test(src); const hookQuiet = src.includes("command: 'leerness update --check --quiet'"); const upgrade = /includes\('leerness update --check'\) && !h\.command\.includes\('--quiet'\)/.test(src); return quietMode && hookQuiet && upgrade; } },
4828
4868
  { name: 'CV-6/UR-0081: 시크릿 스캐너 FP/FN — _isPlaceholderSecret + _looksSecretLike 행위', run: () => { if (typeof _isPlaceholderSecret !== 'function' || typeof _looksSecretLike !== 'function') return false; const fp = _isPlaceholderSecret('change-me') && _isPlaceholderSecret('your-api-key-here') && _isPlaceholderSecret('<token>') && _isPlaceholderSecret('') && !_isPlaceholderSecret('hunter2realpass'); const fn = _looksSecretLike('secret123') && _looksSecretLike('a'.repeat(24)) && !_looksSecretLike('processEnv') && !_looksSecretLike('reqBodyPassword'); return fp && fn; } },
4829
4869
  { name: 'UR-0025: _mergeLines/_mergeEnvLines 순수 코어 모듈 분리 + 행위 (1.9.367)', run: () => { if (typeof _mergeLines !== 'function' || typeof _mergeEnvLines !== 'function') return false; const m = require('../lib/pure-utils'); const moved = m._mergeLines === _mergeLines && m._mergeEnvLines === _mergeEnvLines; const ml = _mergeLines('a\n', ['a', 'b']) === 'a\nb\n'; const meKeep = _mergeEnvLines('FOO=keep\n', ['FOO=new']) === 'FOO=keep\n'; const meAdd = _mergeEnvLines('FOO=keep\n', ['BAR=add']).includes('BAR=add'); return moved && ml && meKeep && meAdd; } },
@@ -5755,7 +5795,7 @@ function _selfTestCases() {
5755
5795
  } },
5756
5796
  { name: '범용성 P2 (UR-0048): task update unknown flag 거부 + did-you-mean(인수인계 유실 차단) (1.17.5)', run: () => {
5757
5797
  const src = read(__filename);
5758
- const wired = src.includes('function _rejectUnknownFlags(allowed, usageHint)') && src.includes("'unknown_flag'") && src.includes("_rejectUnknownFlags(['--status', '--evidence', '--next', '--note']");
5798
+ const wired = src.includes('function _rejectUnknownFlags(allowed, usageHint, options = {})') && src.includes("'unknown_flag'") && src.includes("_rejectUnknownFlags(['--status', '--evidence', '--next', '--note']");
5759
5799
  // did-you-mean prefix 로직: --next-action 은 --next 를 prefix 로 가짐
5760
5800
  const dymOk = '--next-action'.startsWith('--next');
5761
5801
  return wired && dymOk;
@@ -5775,6 +5815,29 @@ function _selfTestCases() {
5775
5815
  const extOk = _isCommandPermitted({ mode: 'extended', shell: { exec: true, allowList: [] } }, 'python x.py', {}) === true; // exec:true → 허용
5776
5816
  return blockedDefault && blockedPy && allowedAuth && coreStillOk && extOk;
5777
5817
  } },
5818
+ { name: 'T-0143: verify-code 고정 비-JS 러너만 basic 호출단위 허용 + 임의 python 차단 유지 (행위)', run: () => {
5819
+ const basic = { mode: 'basic', shell: { exec: false, allowList: [] } };
5820
+ const scoped = { commandAllowList: VERIFY_CODE_RUNNER_ALLOW };
5821
+ return _isCommandPermitted(basic, 'pytest -q', scoped) === true
5822
+ && _isCommandPermitted(basic, 'go test ./...', scoped) === true
5823
+ && _isCommandPermitted(basic, 'cargo test', scoped) === true
5824
+ && _isCommandPermitted(basic, 'python arbitrary.py', scoped) === false
5825
+ && _isCommandPermitted(basic, 'pytest -q', {}) === false;
5826
+ } },
5827
+ { name: 'T-0143: 최신 실패/권한차단 증거는 no_test_run, 성공·optional skip 조합만 인정 (행위)', run: () => {
5828
+ const pass = '## 2026-08-26T04:00 verify-code\nCommand: pytest -q\n- test: exit=0\n';
5829
+ const blocked = pass + '\n## 2026-08-26T04:01 verify-code\nCommand: pytest -q\n- test: exit=126\n';
5830
+ const recovered = blocked + '\n## 2026-08-26T04:02 verify-code\nCommand: pytest -q\n- test: exit=0\n';
5831
+ const mixed = '## 2026-08-26T04:00 verify-code\nCommand: npm test\n- test: exit=0\n- typecheck: exit=127\n';
5832
+ const failed = '## 2026-08-26\n```\ncargo test\n1 failed\n```\n';
5833
+ const legacyPass = '## 2026-08-26\n```\ncargo test --all\n42 passed; 0 failed\n```\n';
5834
+ const passingTail = '## 2026-08-26T04:00 verify-code\n- test: exit=0 — `pytest -q`\nTail:\n- child: exit=1\n';
5835
+ const manualFail = '## 2026-08-26\nCommand: pytest -q\nExit: 0\nFAIL: expected exit=0 but got 1\n';
5836
+ return _hasSuccessfulTestRunEvidence(pass) && !_hasSuccessfulTestRunEvidence(blocked)
5837
+ && _hasSuccessfulTestRunEvidence(recovered) && _hasSuccessfulTestRunEvidence(mixed) && !_hasSuccessfulTestRunEvidence(failed)
5838
+ && _hasSuccessfulTestRunEvidence(legacyPass) && _hasSuccessfulTestRunEvidence(passingTail)
5839
+ && !_hasSuccessfulTestRunEvidence(manualFail);
5840
+ } },
5778
5841
  { name: '재실증 P1 (1.18.1): verify-claim 차단 실행은 skip(불일치 판정 아님) + 종합 라벨이 실제 cmd (소스 가드)', run: () => {
5779
5842
  const src = read(__filename);
5780
5843
  const authPass = src.includes('userAuthorized: true, timeout: 5 * 60 * 1000, kind: ' + "'verify_claim_test'");
@@ -10458,21 +10521,24 @@ function _p0103FlagsOk() {
10458
10521
  const eqOk = pv2.includes('equals form title') && !pv2.includes('ZZEQ');
10459
10522
  const rtEq = R(['agents', 'route', '--tier=normal', 'fix the parser']);
10460
10523
  const eqRoute = !/비어 있|empty_task/.test((rtEq.stdout || '') + (rtEq.stderr || ''));
10461
- // ④ 동적 레지스트리 플래그(brief set 은 arg('--'+f.flag) 로 만든다)에 오탐 경고가 없어야 한다 — 판별 대조군 포함
10524
+ // ④ 동적 레지스트리 플래그(brief set 은 arg('--'+f.flag) 로 만든다)에 오탐 실패가 없어야 한다 — 판별 대조군 포함
10462
10525
  const W2 = /알 수 없는 플래그|Unknown flag/;
10463
- const dynOk = !W2.test((R(['brief', 'set', '--intro', 'hello']).stderr) || '')
10464
- && W2.test((R(['brief', 'set', '--introo', 'x']).stderr) || '');
10465
- // ⑤ 오타 가시성 — 판별 대조군: 실재 플래그는 조용하고, 없는 플래그만 경고한다.
10526
+ const dynGood = R(['brief', 'set', '--intro', 'hello']);
10527
+ const dynBad = R(['brief', 'set', '--introo', 'x']);
10528
+ const dynOk = !W2.test((dynGood.stdout || '') + (dynGood.stderr || ''))
10529
+ && dynBad.status !== 0 && W2.test((dynBad.stdout || '') + (dynBad.stderr || ''));
10530
+ // ⑤ 오타 fail-closed — 판별 대조군: 실재 플래그는 조용하고, 없는 플래그만 구조화 실패한다.
10466
10531
  const good = R(['pulse', '--json']);
10467
10532
  const typo = R(['pulse', '--json', '--nosuchflagzz']);
10468
10533
  const quietOnReal = !/알 수 없는 플래그|Unknown flag/.test(good.stderr || '');
10469
10534
  // 프로젝트가 OS locale 에 따라 en 으로 초기화돼도 같은 계약을 검증한다.
10470
10535
  // 경고 정규식은 바로 위의 ko/en 공용 단일출처를 재사용해 CI locale 에 종속되지 않게 한다.
10471
- const warnsOnTypo = W2.test(typo.stderr || '') && (typo.stderr || '').includes('--nosuchflagzz');
10472
- // 경고는 stderr 전용 — stdout(기계 소비자) 은 오염되지 않는다
10473
- const stdoutClean = !/알 수 없는 플래그|Unknown flag/.test(typo.stdout || '');
10474
- let jsonStillValid = false; try { JSON.parse((typo.stdout || '').trim()); jsonStillValid = true; } catch { jsonStillValid = false; }
10475
- return titleClean && pathClean && eqOk && eqRoute && dynOk && quietOnReal && warnsOnTypo && stdoutClean && jsonStillValid;
10536
+ let typoJson = null; try { typoJson = JSON.parse((typo.stdout || '').trim()); } catch { typoJson = null; }
10537
+ const rejectsTypo = typo.status !== 0 && typoJson?.code === 'unknown_flag'
10538
+ && String(typoJson?.error || '').includes('--nosuchflagzz');
10539
+ // JSON 오류는 stdout 단일 객체, stderr 무오염이다.
10540
+ const stderrClean = !(typo.stderr || '').trim();
10541
+ return titleClean && pathClean && eqOk && eqRoute && dynOk && quietOnReal && rejectsTypo && stderrClean;
10476
10542
  } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
10477
10543
  }
10478
10544
  // 1.36.102 (명령 표면 감사 P1 2건): 회귀 가드.
@@ -10902,6 +10968,8 @@ function commandsCmd(root) {
10902
10968
  { cmd: 'encoding check [path]', desc: '인코딩 검증', descEn: 'encoding verification' },
10903
10969
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)', descEn: 'detect lazy/incomplete work (1.9.101)' },
10904
10970
  { cmd: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<명령>"] [--strict-claims] [--require-evidence]', cmdEn: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<command>"] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --all: 모든 done 주장 일괄 검증(CI·스케일, 1.33.2) · --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287) · --test-cmd: 비-JS 테스트 명령 (1.17.2)', descEn: 'verify a claim (1.9.18~26) — --all: verify every done claim at once (CI/scale, 1.33.2) · --require-evidence: require file + test evidence on done claims (1.9.287) · --test-cmd: non-JS test command (1.17.2)' },
10971
+ { cmd: 'verify-code [path] [--build] [--bench] [--strict]', desc: '프로젝트 테스트·린트·타입검사 자동 감지 실행 + 검증 증거 기록', descEn: 'auto-detect and run project tests/lint/typecheck, then record verification evidence' },
10972
+ { cmd: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]', desc: '명세의 함수·필드 선언과 구현 export 정적 대조', descEn: 'statically compare functions and fields declared in a spec with implementation exports' },
10905
10973
  { cmd: `lens [${_lensDomainList()}] [--json]`, desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3 · database/contract/recovery/observability 는 심화, axes 는 8축 경량 점검)', descEn: 'per-domain self-question quality lenses + cross-domain causality (1.18.3 · database/contract/recovery/observability are deep dives, axes is a light 8-axis check)' },
10906
10974
  // 1.36.101 (명령 표면 감사): 1.36.98 에서 디스패처에만 붙이고 commands/--help 에 등재하지 않아 '있는데 안 보이는' 명령이었다.
10907
10975
  { cmd: 'library [show|page] [path] [--json] [--ai]', desc: '재사용 인벤토리 — 컴포넌트·디자인 토큰을 저장소에서 추출. page: 오프라인 단일 HTML · --ai: 에이전트용 압축 JSON (1.36.98 P-0013)', descEn: 'reuse inventory — extracts components and design tokens from the repo. page: offline single HTML · --ai: compact JSON for agents (1.36.98 P-0013)' },
@@ -14450,16 +14518,126 @@ function taskAdd(root, text) {
14450
14518
  // 1.17.5 (UR-0048, 5축 실증 P2): 모르는 옵션 조용히 무시 차단 — `task update --next-action "x"` 처럼 오타/미존재 플래그가
14451
14519
  // "✓ task updated" 와 함께 값을 버려, 쓴 에이전트는 기록됐다고 믿고 다음 에이전트는 placeholder 를 받는(인수인계 유실) 최악의 실패 양식.
14452
14520
  // prefix 기반 did-you-mean(--next-action → --next) + exit 1. 전역 플래그(--path/--json/--force/--lenient)는 항상 허용.
14453
- function _rejectUnknownFlags(allowed, usageHint) {
14454
- const all = new Set([...allowed, '--path', '--json', '--force', '--lenient']);
14455
- const seen = process.argv.slice(2).filter(a => a.startsWith('--')).map(a => a.split('=')[0]);
14521
+ // T-0144: 명령별 검증은 options.globals 로 그 명령이 실제 지원하는 공통 플래그만 지정한다.
14522
+ // 기본값은 기존 task update 계약을 보존하고, exact 명령만 좁혀 과거의 묵시적 무시를 차단한다.
14523
+ function _rejectUnknownFlags(allowed, usageHint, options = {}) {
14524
+ const globals = Array.isArray(options.globals) ? options.globals : ['--path', '--json', '--force', '--lenient'];
14525
+ const all = new Set([...allowed, ...globals]);
14526
+ const seen = _argvLongFlagNames();
14456
14527
  const unknown = [...new Set(seen.filter(f => !all.has(f)))];
14457
14528
  if (!unknown.length) return true;
14458
14529
  const dym = (u) => { const c = [...all].find(k => u.startsWith(k) || k.startsWith(u)); return c ? ` — 혹시 ${c}?` : ''; };
14459
- failJson(has('--json'), 'unknown_flag', `알 수 없는 옵션: ${unknown.map(u => u + dym(u)).join(', ')} (지원: ${[...allowed].join(' ')}${usageHint ? ' · ' + usageHint : ''}) — 값이 조용히 버려지는 것을 방지하기 위해 거부`);
14530
+ const detail = unknown.map(u => u + dym(u)).join(', ');
14531
+ const supported = [...all].join(' ');
14532
+ const en = _uiLang(process.cwd()) === 'en';
14533
+ failJson(has('--json'), 'unknown_flag', en
14534
+ ? `Unsupported option for this command: ${detail} (supported: ${supported}${usageHint ? ' · ' + usageHint : ''}) — rejected instead of silently discarding its value`
14535
+ : `이 명령에서 지원하지 않는 옵션: ${detail} (지원: ${supported}${usageHint ? ' · ' + usageHint : ''}) — 값이 조용히 버려지는 것을 방지하기 위해 거부`);
14460
14536
  return false;
14461
14537
  }
14462
14538
 
14539
+ function _argvLongFlagNames() {
14540
+ return [...new Set(process.argv.slice(2)
14541
+ .filter(a => typeof a === 'string' && a.startsWith('--') && a !== '--')
14542
+ .map(a => a.split('=')[0]))];
14543
+ }
14544
+
14545
+ // T-0144: 모든 명령에서 이름 자체가 없는 플래그는 migration/usage/write 이전에 fail-closed.
14546
+ function _rejectUnregisteredFlags() {
14547
+ const unknown = _argvLongFlagNames().filter(name => !_VALUE_FLAGS.has(name) && !_BOOL_FLAGS.has(name));
14548
+ if (!unknown.length) return true;
14549
+ const en = _uiLang(process.cwd()) === 'en';
14550
+ const human = en
14551
+ ? `Unknown flag: ${unknown.join(' ')} — no such flag in leerness. Its value could otherwise be absorbed into text or a path. Check: leerness --help`
14552
+ : `알 수 없는 플래그: ${unknown.join(' ')} — leerness 에 없는 이름입니다. 값이 본문이나 경로 인자에 섞이지 않도록 거부했습니다. 확인: leerness --help`;
14553
+ if (has('--json')) {
14554
+ // JSON 본문 자체는 stdout 의 단일 문서다. 예전 경고 문구를 그대로 넣으면 소비자가 이를
14555
+ // "JSON 앞에 섞인 warning" 으로 오인하므로 code를 단일출처로 두고 메시지는 구조화 용어를 쓴다.
14556
+ failJson(true, 'unknown_flag', en
14557
+ ? `Unregistered option: ${unknown.join(' ')} — rejected before its value could alter text or path arguments`
14558
+ : `등록되지 않은 옵션: ${unknown.join(' ')} — 값이 본문이나 경로 인자에 섞이기 전에 거부했습니다`);
14559
+ } else {
14560
+ // 사람용 계약은 기존 stderr 가시성을 보존하되, T-0144부터 exit 1로 fail-closed 한다.
14561
+ console.error('⚠ ' + human);
14562
+ process.exitCode = 1;
14563
+ }
14564
+ return false;
14565
+ }
14566
+
14567
+ // 알려진 플래그도 형태가 틀리면 소비자가 기본값으로 조용히 폴백할 수 있다.
14568
+ // `--strict=true`는 has('--strict')가 false이고, `--language --json`은 language 값이 비어도 둘 다 성공했다.
14569
+ function _rejectMalformedFlagForms(cmd, args) {
14570
+ const raw = process.argv.slice(2);
14571
+ const errors = [];
14572
+ const valueChoices = { '--language': new Set(['auto', 'ko', 'en']) };
14573
+ const commandBooleans = _commandBooleanValueFlags(cmd, args && args[1]);
14574
+ for (let i = 0; i < raw.length; i++) {
14575
+ const token = raw[i];
14576
+ if (typeof token !== 'string' || !token.startsWith('--') || token === '--') continue;
14577
+ const eqAt = token.indexOf('=');
14578
+ const name = eqAt >= 0 ? token.slice(0, eqAt) : token;
14579
+ const booleanHere = _BOOL_FLAGS.has(name) || commandBooleans.has(name);
14580
+ if (booleanHere && eqAt >= 0) {
14581
+ errors.push({ kind: 'boolean_value', name, token });
14582
+ continue;
14583
+ }
14584
+ if (_VALUE_FLAGS.has(name) && !commandBooleans.has(name)) {
14585
+ const missingEqualsValue = eqAt >= 0 && token.slice(eqAt + 1) === '';
14586
+ const missingNextValue = eqAt < 0 && (raw[i + 1] == null || String(raw[i + 1]).startsWith('--'));
14587
+ if (missingEqualsValue || missingNextValue) {
14588
+ // gate 는 누락 referee 를 자체 check 배열(referee_missing_value)로 진단한다. 그 구조를
14589
+ // migration 전에 보존하는 early read-only 경로가 main 에 있으므로 여기서 평탄화하지 않는다.
14590
+ if (cmd === 'gate' && name === '--require-referee') continue;
14591
+ errors.push({ kind: 'missing_value', name, token });
14592
+ }
14593
+ else if (valueChoices[name]) {
14594
+ const value = eqAt >= 0 ? token.slice(eqAt + 1) : String(raw[i + 1]);
14595
+ if (!valueChoices[name].has(value.toLowerCase())) errors.push({ kind: 'invalid_value', name, token, value, choices: [...valueChoices[name]] });
14596
+ }
14597
+ }
14598
+ }
14599
+ if (!errors.length) return true;
14600
+ const onlyMissing = errors.every(e => e.kind === 'missing_value');
14601
+ const onlyInvalidValue = errors.every(e => e.kind === 'invalid_value');
14602
+ const en = _uiLang(process.cwd()) === 'en';
14603
+ const detail = errors.map(e => {
14604
+ if (e.kind === 'missing_value') {
14605
+ if (e.name === '--path') return en
14606
+ ? '--path was given without a value (see command usage)'
14607
+ : '--path 에 값이 없습니다 (명령 사용법 확인)';
14608
+ return `${e.name} (${en ? 'value required; see command usage' : '값 필요; 명령 사용법 확인'})`;
14609
+ }
14610
+ if (e.kind === 'invalid_value') return `${e.name}=${e.value} (${en ? 'choose' : '허용값'}: ${e.choices.join('|')})`;
14611
+ return `${e.token} (${en ? `boolean flag; use ${e.name}` : `부울 플래그 — ${e.name} 형태로 사용`})`;
14612
+ }).join(', ');
14613
+ const code = onlyMissing ? 'missing_flag_value' : (onlyInvalidValue ? 'invalid_flag_value' : 'invalid_flag_syntax');
14614
+ failJson(has('--json'), code, en
14615
+ ? `Invalid flag syntax: ${detail}`
14616
+ : `잘못된 플래그 형식: ${detail}`);
14617
+ return false;
14618
+ }
14619
+
14620
+ // 전역 registry는 "값을 갖는가"만 답한다. 아래 audit 완료 route는 현재 명령이 그 플래그를
14621
+ // 실제 소비하는지도 검사한다. 확인하지 않은 97개 표면을 추측 allowlist로 막지 않고 검증된 route부터 확장한다.
14622
+ // --path 는 기존 공용 호출 래퍼가 모든 명령에 붙이는 호환 플래그다. 명령이 별도 positional 파일을
14623
+ // 쓰더라도 이를 거부하면 정상 호출을 false-BLOCK 하므로 공통으로 보존한다.
14624
+ const _COMMON_COMMAND_FLAGS = ['--language', '--no-stale-check', '--path'];
14625
+ const _STRICT_COMMAND_FLAGS = {
14626
+ commands: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'commands [--json] [--language en|ko] [--path <path>]' },
14627
+ lens: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lens [domain] [--json]' },
14628
+ about: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'about [--json]' },
14629
+ identity: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'identity [--json]' },
14630
+ gate: { allowed: ['--claims', '--require-referee'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'gate [path] [--claims] [--require-referee <id>] [--json]' },
14631
+ 'verify-code': { allowed: ['--build', '--bench', '--strict'], globals: [..._COMMON_COMMAND_FLAGS], usage: 'verify-code [path] [--build] [--bench] [--strict]' },
14632
+ 'contract verify': { allowed: ['--allow-empty'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]' },
14633
+ };
14634
+ function _validateCommandFlags(cmd, args) {
14635
+ const route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
14636
+ const cfg = _STRICT_COMMAND_FLAGS[route];
14637
+ if (!cfg) return true;
14638
+ return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
14639
+ }
14640
+
14463
14641
  function taskUpdate(root, id) {
14464
14642
  if (!_requireInit(root, 'task update')) return; // 1.9.311 (UR-0047): init 가드
14465
14643
  if (!_rejectUnknownFlags(['--status', '--evidence', '--next', '--note'], 'task update T-0001 --status done --evidence "..." --next "..."')) { process.exitCode = 1; return; } // 1.17.5 (UR-0048)
@@ -15539,6 +15717,38 @@ function encodingCheck(root, opts = {}) {
15539
15717
  }
15540
15718
  }
15541
15719
 
15720
+ 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;
15721
+
15722
+ function _hasSuccessfulTestRunEvidence(text) {
15723
+ // 실제 verify-code는 ISO `YYYY-MM-DDTHH:mm`, 정책 예시는 공백형
15724
+ // `YYYY-MM-DD HH:mm`을 쓴다. 둘 다 독립 블록으로 나눠 최신 결과만 본다.
15725
+ const blocks = String(text || '').split(/\n(?=##\s+\d{4}-\d{2}-\d{2}(?:T|\s))/);
15726
+ const relevant = blocks.filter(block => _TEST_RUN_EVIDENCE_RE.test(block));
15727
+ if (!relevant.length) return false;
15728
+ const latest = relevant[relevant.length - 1];
15729
+ // verify-code Tail은 테스트의 정상적인 negative-path 로그도 담는다. 그 안의
15730
+ // "expected child exit=1" 같은 문자열을 실행 결과로 오인하지 않고, 도구가 쓴
15731
+ // summary 행만 신뢰한다. 수동 증거의 Exit: 행은 아래 별도 경로에서 처리한다.
15732
+ const summary = latest.split(/^Tail:\s*$/im)[0];
15733
+ const exits = Array.from(summary.matchAll(/^\s*-\s+.+?:\s*exit\s*=\s*(null|-?\d+)\b/gim), match => {
15734
+ return match[1].toLowerCase() === 'null' ? null : Number(match[1]);
15735
+ });
15736
+ if (exits.length) {
15737
+ // 127 is verify-code's explicit optional-runner skip. A block still needs at
15738
+ // least one actual success, and permission denial (126) is never evidence.
15739
+ return exits.some(code => code === 0) && exits.every(code => code === 0 || code === 127);
15740
+ }
15741
+ const explicitFailure = /(?:^|\n)\s*(?:✗|FAIL\b)/im.test(latest)
15742
+ || /\b(?:test|lint|typecheck|build|bench)\s+failed\b/i.test(latest)
15743
+ || /\b[1-9]\d*\s+failed\b/i.test(latest);
15744
+ if (explicitFailure) return false;
15745
+ const manualExits = Array.from(latest.matchAll(/^\s*Exit\s*:\s*(null|-?\d+)\b/gim), match => {
15746
+ return match[1].toLowerCase() === 'null' ? null : Number(match[1]);
15747
+ });
15748
+ if (manualExits.length) return manualExits.some(code => code === 0) && manualExits.every(code => code === 0);
15749
+ return true;
15750
+ }
15751
+
15542
15752
  // 1.9.101: lazy detect 결과를 JSON으로 노출 (외부 AI/MCP 통합용). opts.json=true 시 verbose 출력 억제 후 JSON만.
15543
15753
  function lazyDetect(root, opts = {}) {
15544
15754
  root = absRoot(root);
@@ -15573,8 +15783,11 @@ function lazyDetect(root, opts = {}) {
15573
15783
  { kind: 'handoff_empty', severity: 'warn' });
15574
15784
  }
15575
15785
  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);
15786
+ // 1.35.16 (lazy 헌트 FP): JS+pytest 중심 목록에 비-JS 러너 추가 — Go/Rust/Ruby/PHP/.NET/Java/Elixir/Swift/Dart/C 등이 실제 테스트를 돌려도 no_test_run 오탐(active 프로젝트에선 blocking → gate 거짓 실패)하던 것 차단.
15787
+ // T-0143: 러너 이름만 찾으면 verify-code 가 exit=126/1 을 기록한 직후에도 lazy/gate 가 초록이 됐다.
15788
+ // 가장 최근 관련 증거 블록에 구조화 exit 가 있으면 성공(0)과 optional skip(127)만 허용하고,
15789
+ // 구조화되지 않은 기존 수동 증거는 명시적 실패 신호가 없을 때만 이전 호환 동작을 유지한다.
15790
+ const hasTestRun = _hasSuccessfulTestRunEvidence(ev);
15578
15791
  if (!hasTestRun) { issues++; _warn('review-evidence.md has no recorded test/typecheck/lint run',
15579
15792
  { kind: 'no_test_run', severity: 'warn' }); }
15580
15793
  // 1.9.7 C: TODO 자동 추적 강화 — 위치+텍스트 캡처, known-todos 비교, --auto-track 등록
@@ -23227,7 +23440,9 @@ function verifyCodeCmd(root) {
23227
23440
  try { pkg = JSON.parse(read(pkgFile)); } catch (e) { return fail('package.json 파싱 실패: ' + e.message); }
23228
23441
  const scripts = pkg.scripts || {};
23229
23442
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
23230
- if (scripts.test) tasks.push({ name: 'test', cmd: 'npm test', runtime: 'node' });
23443
+ // npm init 기본 placeholder는 실제 검증이 아니며 Python 테스트를 가리면 안 된다.
23444
+ const nodeTest = typeof scripts.test === 'string' && !/no test specified/i.test(scripts.test);
23445
+ if (nodeTest) tasks.push({ name: 'test', cmd: 'npm test', runtime: 'node' });
23231
23446
  else if (scripts['test:smoke']) tasks.push({ name: 'test', cmd: 'npm run test:smoke', runtime: 'node' });
23232
23447
  // 1.9.148: 명시 script 없어도 인기 러너 의존성 발견 시 시도
23233
23448
  else if (deps.vitest) tasks.push({ name: 'test', cmd: 'npx --yes vitest run', runtime: 'node' });
@@ -23240,9 +23455,19 @@ function verifyCodeCmd(root) {
23240
23455
  if (has('--build') && scripts.build) tasks.push({ name: 'build', cmd: 'npm run build', runtime: 'node' });
23241
23456
  if (has('--bench') && scripts.bench) tasks.push({ name: 'bench', cmd: 'npm run bench', runtime: 'node', optional: true });
23242
23457
  }
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 });
23458
+ // (2) Python: Python 표식이 있으면 Node test와 독립적으로 pytest를 실행한다.
23459
+ // `tests/` 이름만으로는 JS 프로젝트를 오인하므로 그 경로는 실제 .py 파일이 있을 때만 표식이다.
23460
+ const testsDir = path.join(root, 'tests');
23461
+ let hasPythonTests = false;
23462
+ if (exists(testsDir)) {
23463
+ try {
23464
+ if (fs.statSync(testsDir).isDirectory()) {
23465
+ for (const file of walk(testsDir)) { if (/\.py$/i.test(file)) { hasPythonTests = true; break; } }
23466
+ }
23467
+ } catch {}
23468
+ }
23469
+ if (exists(path.join(root, 'pyproject.toml')) || exists(path.join(root, 'setup.py')) || hasPythonTests) {
23470
+ tasks.push({ name: 'test:python', cmd: 'pytest -q', runtime: 'python', optional: true });
23246
23471
  }
23247
23472
  // (3) Go: go.mod 존재 시 go test ./...
23248
23473
  if (exists(path.join(root, 'go.mod'))) {
@@ -23268,7 +23493,7 @@ function verifyCodeCmd(root) {
23268
23493
  const start = Date.now();
23269
23494
  // 1.9.150: runCommandSafe — cwd jail + env scrub + observability 자동 (shell:true 유지 — npm/pytest 호환)
23270
23495
  // 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}` });
23496
+ 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
23497
  const dur = Date.now() - start;
23273
23498
  if (r.status === 0) ok(`${t.name} passed (${dur}ms)`);
23274
23499
  else if (t.optional && r.status === 127) warn(`${t.name} 스킵 (${t.cmd} 없음)`);
@@ -26025,6 +26250,9 @@ function _isCwdSafe(root, cwd) {
26025
26250
  }
26026
26251
  // basic 모드에서도 항상 허용하는 핵심 도구 (release/install 흐름 유지)
26027
26252
  const RUN_CORE_ALLOW = ['git', 'npm', 'npx', 'node', 'pnpm', 'yarn'];
26253
+ // verify-code가 파일 표식에서 직접 고정 생성하는 비-JS 러너만 호출 단위로 허용한다.
26254
+ // 전역 core allow에 넣지 않아 다른 명령 표면의 basic 권한은 넓어지지 않는다.
26255
+ const VERIFY_CODE_RUNNER_ALLOW = ['pytest', 'go', 'cargo'];
26028
26256
  // 1.18.1 (재실증 신규 P1): 명령 실행 권한 결정 — 순수 함수(테스트 가능). cwd jail 은 별도(여기서 판단 안 함).
26029
26257
  // userAuthorized: 사용자가 명시적으로 입력한 명령(예: verify-claim --test-cmd "<명령>" 또는 config testCommand)
26030
26258
  // → basic 모드 allowList 우회(명시 권한). 이전엔 coreAllow(JS 도구)만 허용해 --test-cmd "python ..." 가
@@ -26036,12 +26264,13 @@ function _isCommandPermitted(perms, cmdStr, opts) {
26036
26264
  const exec = perms.shell && perms.shell.exec !== false;
26037
26265
  if (exec) return true; // extended/full 모드 (또는 exec:true)
26038
26266
  const allow = (perms.shell && perms.shell.allowList) || [];
26267
+ const scopedAllow = Array.isArray(opts.commandAllowList) ? opts.commandAllowList : [];
26039
26268
  const first = String(cmdStr || '').trim().split(/\s+/)[0];
26040
- return RUN_CORE_ALLOW.includes(first) || allow.includes('*') || allow.includes(first);
26269
+ return RUN_CORE_ALLOW.includes(first) || scopedAllow.includes(first) || allow.includes('*') || allow.includes(first);
26041
26270
  }
26042
26271
 
26043
26272
  function runCommandSafe(cmd, args, opts) {
26044
- // opts: { cwd, root, timeout, env, stdio, kind, label, allowShell, encoding, input, allowOutsideCwd, userAuthorized }
26273
+ // opts: { cwd, root, timeout, env, stdio, kind, label, allowShell, encoding, input, allowOutsideCwd, userAuthorized, commandAllowList }
26045
26274
  opts = opts || {};
26046
26275
  const root = opts.root || opts.cwd || process.cwd();
26047
26276
  const cwd = opts.cwd || root;
@@ -29598,35 +29827,13 @@ async function main() {
29598
29827
  return log(VERSION);
29599
29828
  }
29600
29829
  if (has('--help') || has('-h')) return help();
29601
- if (cmd !== 'migrate-workspace-dir' && !_guardLinkedWorkspaceTargets(args)) return;
29602
- if (!_autoMigrateLegacyWorkspace(args, cmd)) return;
29603
- // 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
29604
- // 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
29605
- if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
29606
- // 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
29607
- // 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
29608
- // 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
29609
- // 파싱 기본값을 뒤집으면 미분류 부울 리터럴 쪽에서 positional 을 삼키는 새 회귀가 나므로, 동작 대신
29610
- // 가시성을 준다 — stderr 로만 알리고 stdout/--json/exit code 는 건드리지 않는다(기계 소비자 무영향).
29611
- {
29612
- const _unknownFlags = [];
29613
- for (const _tok of process.argv.slice(2)) {
29614
- if (typeof _tok !== 'string' || !_tok.startsWith('--') || _tok === '--') continue;
29615
- const _name = _tok.split('=')[0];
29616
- if (_VALUE_FLAGS.has(_name) || _BOOL_FLAGS.has(_name)) continue;
29617
- if (!_unknownFlags.includes(_name)) _unknownFlags.push(_name);
29618
- }
29619
- if (_unknownFlags.length) {
29620
- const _L = _uiLang(process.cwd());
29621
- console.error(_L === 'en'
29622
- ? `⚠ Unknown flag: ${_unknownFlags.join(' ')} — no such flag in leerness. Its value can be absorbed into the text or path argument. Check: leerness --help`
29623
- : `⚠ 알 수 없는 플래그: ${_unknownFlags.join(' ')} — leerness 에 없는 이름입니다. 그 값이 본문이나 경로 인자에 섞일 수 있습니다. 확인: leerness --help`);
29624
- }
29625
- }
29626
- // 1.36.103 (codex 검수 #9): --keep 은 소비처가 둘인데 한쪽(release cleanup)은 거부하고 다른 쪽(아카이브 retention)은
29627
- // 조용히 기본값 10 이 됐다 — `init . --keep abc --json` 이 exit 0 + {ok:true} 로 끝났다.
29628
- // "준 값을 조용히 바꾸지 않는다" 를 소비처마다 반복하지 않고 진입점 한 곳에서 강제한다.
29629
- {
29830
+ // 입력 오류는 자동 legacy migration·usage 기록보다 먼저 거부해야 실패 호출이 상태를 바꾸지 않는다.
29831
+ if (!_rejectUnregisteredFlags()) return;
29832
+ if (!_rejectMalformedFlagForms(cmd, args)) return;
29833
+ if (!_validateCommandFlags(cmd, args)) return;
29834
+ // 값이 존재하지만 의미가 잘못된 --keep 도 legacy migration/write 전에 거부한다.
29835
+ // preview serve 의 --keep 은 route별 boolean 계약이므로 숫자 검증 대상이 아니다.
29836
+ if (!_commandBooleanValueFlags(cmd, args[1]).has('--keep')) {
29630
29837
  const _k = arg('--keep', null);
29631
29838
  if (_k !== null) {
29632
29839
  const _kn = Number(_k);
@@ -29639,6 +29846,14 @@ async function main() {
29639
29846
  }
29640
29847
  }
29641
29848
  }
29849
+ if (cmd !== 'migrate-workspace-dir' && !_guardLinkedWorkspaceTargets(args)) return;
29850
+ // gate 의 누락 referee 는 기존의 진단 가능한 checks[] 계약을 유지하되, 잘못된 호출이
29851
+ // legacy workspace migration을 일으키기 전에 읽기전용으로 종료한다.
29852
+ if (cmd === 'gate' && _argAllStrict('--require-referee').missing) return gate(arg('--path', args[1] || process.cwd()));
29853
+ if (!_autoMigrateLegacyWorkspace(args, cmd)) return;
29854
+ // 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
29855
+ // 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
29856
+ if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
29642
29857
  // 1.36.101 (명령 표면 감사): '없는 경로에 초록 보고' 클래스를 디스패처 한 곳에서 막는다.
29643
29858
  // 실측 — 32개 조사 대상 중 16개가 존재하지 않는 디렉토리에 exit 0 과 정상 보고를 냈다
29644
29859
  // (pulse/round-history/milestones/session-resume 는 "✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중" 까지 찍었다 — 오타가 안심 보고가 된다).
@@ -30374,12 +30589,9 @@ async function main() {
30374
30589
  // 1.36.51 (사용자 요청 UR-0061): 모호성 질문 + 미리보기 승인 워크플로
30375
30590
  if (cmd === 'clarify') return _clar.clarifyCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')).join(' '), { has });
30376
30591
  if (cmd === 'preview') {
30377
- // 미지 플래그(--design/--features/--note)의 값이 positional 로 새는 것 차단 — 원시 argv 에서 플래그+값 스킵 (1.36.49 release note 패턴)
30378
- const _pvRaw = process.argv.slice(2); const _pvI = _pvRaw.indexOf('preview'); const _pvToks = [];
30379
- for (let i = _pvI + 1; i < _pvRaw.length && _pvI >= 0; i++) {
30380
- if (_pvRaw[i].startsWith('--')) { if (!_pvRaw[i].includes('=') && _VALUE_FLAGS.has(_pvRaw[i]) && _pvRaw[i + 1]) i++; continue; } // 1.36.75 (검수 #2): --mockup 값이 제목에 흡수되던 것. 1.36.103: 인라인 목록 → _VALUE_FLAGS (--select 등이 제목에 붙던 것) + 등호형은 다음 토큰을 먹지 않음(codex 검수 #5 재현)
30381
- _pvToks.push(_pvRaw[i]);
30382
- }
30592
+ // 전역 2-pass 파서가 command별 arity까지 반영한 positional 단일출처다.
30593
+ // preview 전용 raw 재파싱은 --keep 을 다시 value로 오인해 뒤의 P-ID를 삼켰다(T-0144 교차검토).
30594
+ const _pvToks = args.slice(1);
30383
30595
  // 1.36.80 (UR-0067): 라이브 미리보기 — serve(로컬 서버) / mode(self|project) 는 별도 모듈로 위임
30384
30596
  const _pvRoot = arg('--path', process.cwd());
30385
30597
  if (_pvToks[0] === 'serve') return _pvsrv.previewServeCmd(_pvRoot, _pvToks[1], { has, arg, previews: _clar._loadPreviews(_pvRoot) });
@@ -30786,7 +30998,7 @@ module.exports = {
30786
30998
  // 1.9.289: shell-safe 인용 (Codex #3) — 단위 테스트
30787
30999
  _shellQuoteArg,
30788
31000
  // 1.18.1: 명령 실행 권한 결정 (재실증 신규 P1: --test-cmd 비-JS 인터프리터 거짓차단) — 단위 테스트
30789
- _isCommandPermitted, RUN_CORE_ALLOW, _uiLang, _tx,
31001
+ _isCommandPermitted, RUN_CORE_ALLOW, VERIFY_CODE_RUNNER_ALLOW, _hasSuccessfulTestRunEvidence, _uiLang, _tx,
30790
31002
  _loadEnvFile, _dotenvKeyAllowed, _scrubEnv,
30791
31003
  // 1.18.2: verify-claim 위장 스텁(빈 export 껍데기) 판정 — 단위 테스트
30792
31004
  _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.166",
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,9 +48,9 @@
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",
53
- "test:commands": "node ./scripts/e2e-command-surface.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
+ "test:commands": "node ./scripts/command-flags-probe.js && 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",
56
56
  "test:fast": "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 ./scripts/smoke.js",
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const crypto = require('crypto');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
11
+ const env = {
12
+ ...process.env,
13
+ LEERNESS_OFFLINE: '1',
14
+ LEERNESS_NO_PROMPT: '1',
15
+ LEERNESS_NO_AUTOCHCP: '1',
16
+ LEERNESS_NO_AUTO_ROADMAP: '1',
17
+ LEERNESS_INTERNAL: '1',
18
+ LEERNESS_NO_STALE_CHECK: '1',
19
+ };
20
+
21
+ function run(args) {
22
+ return spawnSync(process.execPath, [CLI, ...args], {
23
+ cwd: path.resolve(__dirname, '..'),
24
+ encoding: 'utf8',
25
+ timeout: 30000,
26
+ env,
27
+ });
28
+ }
29
+
30
+ function json(result) {
31
+ try { return JSON.parse(result.stdout || ''); } catch { return null; }
32
+ }
33
+
34
+ function treeSnapshot(root) {
35
+ const rows = [];
36
+ function walk(dir, relative = '') {
37
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
38
+ const rel = path.join(relative, entry.name).replace(/\\/g, '/');
39
+ const absolute = path.join(dir, entry.name);
40
+ const stat = fs.lstatSync(absolute, { bigint: true });
41
+ const metadata = [Number(stat.mode), stat.size.toString(), stat.mtimeNs.toString()];
42
+ if (entry.isDirectory()) {
43
+ rows.push([rel, 'directory', ...metadata]);
44
+ walk(absolute, rel);
45
+ } else if (entry.isFile()) {
46
+ rows.push([rel, 'file', ...metadata, crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')]);
47
+ } else if (entry.isSymbolicLink()) {
48
+ rows.push([rel, 'symlink', ...metadata, fs.readlinkSync(absolute)]);
49
+ } else {
50
+ rows.push([rel, 'other', ...metadata]);
51
+ }
52
+ }
53
+ }
54
+ walk(root);
55
+ return JSON.stringify(rows);
56
+ }
57
+
58
+ const failures = [];
59
+ function check(label, condition, result) {
60
+ if (condition) {
61
+ console.log(`✓ ${label}`);
62
+ return;
63
+ }
64
+ failures.push(label);
65
+ const detail = result
66
+ ? `exit=${result.status}\nstdout=${String(result.stdout || '').slice(0, 300)}\nstderr=${String(result.stderr || '').slice(0, 300)}`
67
+ : '';
68
+ console.log(`✗ ${label}${detail ? `\n${detail}` : ''}`);
69
+ }
70
+
71
+ const catalog = run(['commands', '--json']);
72
+ const catalogJson = json(catalog);
73
+ const catalogRows = catalogJson?.categories ? Object.values(catalogJson.categories).flat() : [];
74
+ check('commands --json returns the machine-readable catalog',
75
+ catalog.status === 0 && catalogJson?.totalCommands === 99 && catalogJson?.categories && !catalog.stderr,
76
+ catalog);
77
+ check('commands totalCommands equals the category sum',
78
+ catalogJson?.totalCommands === catalogRows.length,
79
+ catalog);
80
+ check('commands catalog includes verify-code and contract verify',
81
+ catalogRows.some(row => /^verify-code\b/.test(row.cmd))
82
+ && catalogRows.some(row => /^contract verify\b/.test(row.cmd)),
83
+ catalog);
84
+
85
+ const englishCatalog = run(['commands', '--language', 'en', '--json']);
86
+ check('commands accepts --language and returns an English catalog',
87
+ englishCatalog.status === 0 && json(englishCatalog)?.lang === 'en' && !englishCatalog.stderr,
88
+ englishCatalog);
89
+
90
+ const englishCatalogEquals = run(['commands', '--language=en', '--json']);
91
+ check('commands accepts the documented equals form for value flags',
92
+ englishCatalogEquals.status === 0 && json(englishCatalogEquals)?.lang === 'en' && !englishCatalogEquals.stderr,
93
+ englishCatalogEquals);
94
+
95
+ const missingLanguage = run(['commands', '--language', '--json']);
96
+ check('a value flag without a value fails instead of falling back',
97
+ missingLanguage.status === 1 && json(missingLanguage)?.code === 'missing_flag_value' && !missingLanguage.stderr,
98
+ missingLanguage);
99
+
100
+ const invalidLanguage = run(['commands', '--language', 'xx', '--json', '--no-stale-check']);
101
+ check('an invalid language value fails instead of silently falling back',
102
+ invalidLanguage.status === 1 && json(invalidLanguage)?.code === 'invalid_flag_value' && !invalidLanguage.stderr,
103
+ invalidLanguage);
104
+
105
+ const known = run(['commands', '--path', process.cwd(), '--json']);
106
+ check('commands accepts its documented global flags',
107
+ known.status === 0 && json(known)?.categories && !known.stderr,
108
+ known);
109
+
110
+ const nonexistent = run(['commands', '--definitely-unknown', '--json']);
111
+ const nonexistentJson = json(nonexistent);
112
+ check('a nonexistent flag fails closed with one JSON error',
113
+ nonexistent.status === 1 && nonexistentJson?.ok === false
114
+ && nonexistentJson?.code === 'unknown_flag'
115
+ && String(nonexistentJson?.error || '').includes('--definitely-unknown')
116
+ && !nonexistent.stderr,
117
+ nonexistent);
118
+
119
+ const nonexistentEquals = run(['commands', '--definitely-unknown=value', '--json']);
120
+ check('an equals-form nonexistent flag also fails closed',
121
+ nonexistentEquals.status === 1 && json(nonexistentEquals)?.code === 'unknown_flag' && !nonexistentEquals.stderr,
122
+ nonexistentEquals);
123
+
124
+ const nonexistentHuman = run(['commands', '--definitely-unknown']);
125
+ check('human-mode unknown flags stay visible on stderr while failing closed',
126
+ nonexistentHuman.status === 1 && /알 수 없는 플래그|Unknown flag/.test(nonexistentHuman.stderr || '')
127
+ && !(nonexistentHuman.stdout || '').trim(),
128
+ nonexistentHuman);
129
+
130
+ const booleanEquals = run(['commands', '--json=true']);
131
+ check('boolean flags reject equals-form values that their consumers would ignore',
132
+ booleanEquals.status === 1 && /--json=true/.test((booleanEquals.stdout || '') + (booleanEquals.stderr || '')),
133
+ booleanEquals);
134
+
135
+ const missingMeasure = run(['benchmark', '--measure']);
136
+ check('missing values retain a user-facing command-usage hint',
137
+ missingMeasure.status === 1 && /사용법|usage|task/i.test((missingMeasure.stdout || '') + (missingMeasure.stderr || '')),
138
+ missingMeasure);
139
+
140
+ const missingPath = run(['status', '--path']);
141
+ check('missing --path retains its specific no-value diagnostic',
142
+ missingPath.status === 1 && /--path 에 값이 없습니다|--path was given without a value/.test((missingPath.stdout || '') + (missingPath.stderr || '')),
143
+ missingPath);
144
+
145
+ // --cmd exists elsewhere in leerness, so the global registry alone cannot catch
146
+ // it. The commands command must reject a foreign, command-specific flag too.
147
+ const foreign = run(['commands', '--cmd', 'ignored-value', '--json']);
148
+ const foreignJson = json(foreign);
149
+ check('commands rejects a known-but-foreign command flag',
150
+ foreign.status === 1 && foreignJson?.ok === false
151
+ && foreignJson?.code === 'unknown_flag'
152
+ && String(foreignJson?.error || '').includes('--cmd')
153
+ && !foreign.stderr,
154
+ foreign);
155
+
156
+ const foreignTestCommand = run(['commands', '--test-cmd', 'ignored-command', '--json']);
157
+ check('commands rejects a value flag owned by another command',
158
+ foreignTestCommand.status === 1 && json(foreignTestCommand)?.code === 'unknown_flag' && !foreignTestCommand.stderr,
159
+ foreignTestCommand);
160
+
161
+ const lensUnknown = run(['lens', '--type', 'database', '--json']);
162
+ check('lens rejects an unknown value flag before its value becomes a domain',
163
+ lensUnknown.status === 1 && json(lensUnknown)?.code === 'unknown_flag' && !lensUnknown.stderr,
164
+ lensUnknown);
165
+
166
+ const lensForeign = run(['lens', '--test-cmd', 'database', '--json']);
167
+ check('lens rejects a known-but-foreign value flag',
168
+ lensForeign.status === 1 && json(lensForeign)?.code === 'unknown_flag' && !lensForeign.stderr,
169
+ lensForeign);
170
+
171
+ const contractUnknown = run(['contract', 'verify', 'missing-spec.md', '--type', 'missing-impl.md', '--json']);
172
+ check('contract verify rejects an unknown flag before positional error meaning changes',
173
+ contractUnknown.status === 1 && json(contractUnknown)?.code === 'unknown_flag'
174
+ && json(contractUnknown)?.code !== 'spec_not_found' && !contractUnknown.stderr,
175
+ contractUnknown);
176
+
177
+ const aboutForeign = run(['about', '--test-cmd', 'ignored-command', '--json']);
178
+ check('about rejects a known-but-foreign value flag',
179
+ aboutForeign.status === 1 && json(aboutForeign)?.code === 'unknown_flag' && !aboutForeign.stderr,
180
+ aboutForeign);
181
+
182
+ // The external evaluation's concrete case was verify-code --cmd/--test-cmd:
183
+ // both are valid names elsewhere, but this command does not consume them.
184
+ const emptyProject = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-command-flags-'));
185
+ try {
186
+ const initialized = run(['init', emptyProject, '--yes', '--json']);
187
+ check('route-arity fixture initializes successfully',
188
+ initialized.status === 0 && json(initialized)?.ok === true,
189
+ initialized);
190
+
191
+ for (const mutationFlag of ['--fix', '--baseline', '--auto-track']) {
192
+ const beforeInvalidGate = treeSnapshot(emptyProject);
193
+ const invalidGate = run(['gate', emptyProject, '--require-referee', mutationFlag, '--json']);
194
+ check(`gate rejects foreign ${mutationFlag} before any sub-check can write`,
195
+ invalidGate.status === 1 && json(invalidGate)?.code === 'unknown_flag'
196
+ && String(json(invalidGate)?.error || '').includes(mutationFlag)
197
+ && treeSnapshot(emptyProject) === beforeInvalidGate,
198
+ invalidGate);
199
+ }
200
+
201
+ const updateCheck = run(['update', emptyProject, '--check', '--json']);
202
+ check('update keeps --check as a boolean flag',
203
+ updateCheck.status === 0 && json(updateCheck)?.code !== 'missing_flag_value'
204
+ && typeof json(updateCheck)?.verdict === 'string',
205
+ updateCheck);
206
+
207
+ const updateCheckEquals = run(['update', emptyProject, '--check=true', '--json']);
208
+ check('update rejects a value attached to its boolean --check',
209
+ updateCheckEquals.status === 1 && json(updateCheckEquals)?.code === 'invalid_flag_syntax',
210
+ updateCheckEquals);
211
+
212
+ const previewKeep = run(['preview', 'serve', 'P-NOT-FOUND', '--keep', '--path', emptyProject, '--json']);
213
+ check('preview serve keeps --keep as a boolean flag',
214
+ previewKeep.status === 1 && json(previewKeep)?.code === 'not_found',
215
+ previewKeep);
216
+
217
+ const previewKeepBeforeId = run(['preview', 'serve', '--keep', 'P-NOT-FOUND', '--path', emptyProject, '--json']);
218
+ check('preview serve does not let --keep swallow a following preview id',
219
+ previewKeepBeforeId.status === 1 && json(previewKeepBeforeId)?.code === 'not_found'
220
+ && String(json(previewKeepBeforeId)?.error || '').includes('P-NOT-FOUND'),
221
+ previewKeepBeforeId);
222
+
223
+ const previewKeepBeforeSubcommand = run(['preview', '--keep', 'serve', 'P-NOT-FOUND', '--path', emptyProject, '--json']);
224
+ check('preview serve resolves its boolean --keep even before the subcommand',
225
+ previewKeepBeforeSubcommand.status === 1 && json(previewKeepBeforeSubcommand)?.code === 'not_found'
226
+ && String(json(previewKeepBeforeSubcommand)?.error || '').includes('P-NOT-FOUND'),
227
+ previewKeepBeforeSubcommand);
228
+
229
+ const refereeMissingCheck = run(['referee', 'add', 'route-arity-missing', '--check', '--good', 'node -e "process.exit(0)"', '--bad', 'node -e "process.exit(1)"', '--path', emptyProject, '--json']);
230
+ check('referee add still requires a value for --check',
231
+ refereeMissingCheck.status === 1 && json(refereeMissingCheck)?.code === 'missing_flag_value',
232
+ refereeMissingCheck);
233
+
234
+ const cleanupMissingKeep = run(['release', 'cleanup', '--keep', '--path', emptyProject, '--json']);
235
+ check('release cleanup still requires a numeric value for --keep',
236
+ cleanupMissingKeep.status === 1 && json(cleanupMissingKeep)?.code === 'missing_flag_value',
237
+ cleanupMissingKeep);
238
+
239
+ const gateMissingReferee = run(['gate', emptyProject, '--require-referee', '--json']);
240
+ check('gate retains its structured missing-referee check before dispatch',
241
+ gateMissingReferee.status === 1 && Array.isArray(json(gateMissingReferee)?.checks)
242
+ && json(gateMissingReferee).checks.some(row => row?.code === 'referee_missing_value'),
243
+ gateMissingReferee);
244
+
245
+ const spec = path.join(emptyProject, 'contract.md');
246
+ const impl = path.join(emptyProject, 'contract.js');
247
+ fs.writeFileSync(spec, '- charge()\n', 'utf8');
248
+ fs.writeFileSync(impl, 'function charge() {}\nmodule.exports = { charge };\n', 'utf8');
249
+ const contractWithGlobalPath = run(['contract', 'verify', spec, impl, '--path', emptyProject, '--json']);
250
+ check('contract verify retains the global --path compatibility flag',
251
+ contractWithGlobalPath.status === 0 && json(contractWithGlobalPath)?.ok === true && !contractWithGlobalPath.stderr,
252
+ contractWithGlobalPath);
253
+
254
+ const verifyForeign = run(['verify-code', emptyProject, '--cmd', 'ignored-command']);
255
+ check('verify-code rejects flags it does not implement',
256
+ verifyForeign.status === 1 && /--cmd/.test((verifyForeign.stdout || '') + (verifyForeign.stderr || '')),
257
+ verifyForeign);
258
+
259
+ const verifyTestCommand = run(['verify-code', emptyProject, '--test-cmd', 'ignored-command']);
260
+ check('verify-code rejects --test-cmd instead of recording unrelated auto-detected evidence',
261
+ verifyTestCommand.status === 1 && /--test-cmd/.test((verifyTestCommand.stdout || '') + (verifyTestCommand.stderr || '')),
262
+ verifyTestCommand);
263
+
264
+ const strictEquals = run(['verify-code', emptyProject, '--strict=true']);
265
+ check('verify-code rejects a boolean value form instead of losing strict mode',
266
+ strictEquals.status === 1 && /--strict=true/.test((strictEquals.stdout || '') + (strictEquals.stderr || '')),
267
+ strictEquals);
268
+
269
+ const claimOwnsTestCommand = run(['verify-claim', 'T-NOT-FOUND', '--run-tests', '--test-cmd', 'node -e "process.exit(0)"', '--path', emptyProject, '--json']);
270
+ check('verify-claim retains its supported --test-cmd contract',
271
+ claimOwnsTestCommand.status === 1 && json(claimOwnsTestCommand)?.code !== 'unknown_flag',
272
+ claimOwnsTestCommand);
273
+ } finally {
274
+ fs.rmSync(emptyProject, { recursive: true, force: true });
275
+ }
276
+
277
+ // Invalid input must not trigger the legacy workspace auto-migration that is
278
+ // normally allowed for a valid command.
279
+ const legacyProject = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-command-flags-legacy-'));
280
+ try {
281
+ const legacyWorkspaceName = ['.', 'harness'].join('');
282
+ const legacyDir = path.join(legacyProject, legacyWorkspaceName);
283
+ fs.mkdirSync(legacyDir);
284
+ fs.writeFileSync(path.join(legacyDir, 'HARNESS_VERSION'), '1.36.161\n', 'utf8');
285
+ const before = fs.readFileSync(path.join(legacyDir, 'HARNESS_VERSION'), 'utf8');
286
+ for (const mutationFlag of ['--fix', '--baseline', '--auto-track']) {
287
+ const beforeInvalidGate = treeSnapshot(legacyProject);
288
+ const invalidGate = run(['gate', '--require-referee', mutationFlag, '--path', legacyProject, '--json']);
289
+ check(`gate ${mutationFlag} rejection cannot migrate or mutate a legacy workspace`,
290
+ invalidGate.status === 1 && json(invalidGate)?.code === 'unknown_flag'
291
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness'))
292
+ && treeSnapshot(legacyProject) === beforeInvalidGate,
293
+ invalidGate);
294
+ }
295
+ const invalidLegacy = run(['commands', '--unknown-value', 'database', '--path', legacyProject, '--json']);
296
+ check('unknown flags fail before legacy workspace migration or writes',
297
+ invalidLegacy.status === 1 && json(invalidLegacy)?.code === 'unknown_flag'
298
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness'))
299
+ && fs.readFileSync(path.join(legacyDir, 'HARNESS_VERSION'), 'utf8') === before,
300
+ invalidLegacy);
301
+
302
+ const invalidKeepLegacy = run(['init', '--keep', 'abc', '--path', legacyProject, '--json']);
303
+ check('invalid value semantics fail before legacy workspace migration or writes',
304
+ invalidKeepLegacy.status === 1 && json(invalidKeepLegacy)?.code === 'invalid_keep'
305
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness'))
306
+ && fs.readFileSync(path.join(legacyDir, 'HARNESS_VERSION'), 'utf8') === before,
307
+ invalidKeepLegacy);
308
+
309
+ const missingRefereeLegacy = run(['gate', '--require-referee', '--path', legacyProject, '--json']);
310
+ check('command-specific malformed diagnostics also run before legacy migration',
311
+ missingRefereeLegacy.status === 1 && Array.isArray(json(missingRefereeLegacy)?.checks)
312
+ && json(missingRefereeLegacy).checks.some(row => row?.code === 'referee_missing_value')
313
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness')),
314
+ missingRefereeLegacy);
315
+ } finally {
316
+ fs.rmSync(legacyProject, { recursive: true, force: true });
317
+ }
318
+
319
+ if (failures.length) {
320
+ console.log(`UNKNOWN_FLAGS_ACCEPTED: ${failures.join('; ')}`);
321
+ process.exitCode = 1;
322
+ } else {
323
+ console.log('command flags probe passed');
324
+ }
@@ -104,9 +104,9 @@ async function race(name, config) {
104
104
  }));
105
105
  results.push(await race('memory-restore-decisions', {
106
106
  stallTarget: 'decisions.json',
107
- seed: d => { run(d, ['decision', 'add', 'archived', '--why', 'test']); run(d, ['decision', 'drop', 'archived']); },
107
+ seed: d => { run(d, ['decision', 'add', 'archived', '--reason', 'test']); run(d, ['decision', 'drop', 'archived']); },
108
108
  first: ['memory', 'restore', 'decisions', 'archived'],
109
- second: ['decision', 'add', 'survivor', '--why', 'test'],
109
+ second: ['decision', 'add', 'survivor', '--reason', 'test'],
110
110
  inspect: d => {
111
111
  const titles = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'decisions.json'), 'utf8')).map(item => item.title);
112
112
  return { titles, preserved: titles.some(title => title.includes('archived')) && titles.some(title => title.includes('survivor')) };
package/scripts/e2e.js CHANGED
@@ -10630,7 +10630,7 @@ total++;
10630
10630
  { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV0 });
10631
10631
  const CASES = [
10632
10632
  ['task add', ['task', 'add', '락프로브']],
10633
- ['decision add', ['decision', 'add', '락프로브', '--why', 'x']],
10633
+ ['decision add', ['decision', 'add', '락프로브', '--reason', 'x']],
10634
10634
  ['lesson save', ['lesson', 'save', '락프로브']],
10635
10635
  ['rule add', ['rule', 'add', '락프로브', '--trigger', 'every-session']],
10636
10636
  ['rule pause', ['rule', 'pause', 'R-0001'], ['rule', 'add', '씨앗룰', '--trigger', 'every-session']],
@@ -10640,7 +10640,7 @@ total++;
10640
10640
  ['creds refresh', ['creds', 'refresh', '락프로브'], d => SEED(d, ['creds', 'register', '락프로브', '--env-var', 'LOCK_PROBE'])],
10641
10641
  // team ID는 ASCII slug만 허용한다. 표시 라벨과 달리 seed/삭제 대상은 실제 유효 ID를 쓴다.
10642
10642
  ['team remove', ['team', 'remove', 'lock-probe'], d => SEED(d, ['team', 'add', 'lock-probe', '--name', 'lock'])],
10643
- ['memory restore decisions', ['memory', 'restore', 'decisions', '락프로브'], d => { SEED(d, ['decision', 'add', '락프로브', '--why', 'x']); SEED(d, ['decision', 'drop', '락프로브']); }],
10643
+ ['memory restore decisions', ['memory', 'restore', 'decisions', '락프로브'], d => { SEED(d, ['decision', 'add', '락프로브', '--reason', 'x']); SEED(d, ['decision', 'drop', '락프로브']); }],
10644
10644
  ['memory restore lessons', ['memory', 'restore', 'lessons', '락프로브'], d => { SEED(d, ['lesson', 'save', '락프로브']); SEED(d, ['lesson', 'drop', '락프로브']); }],
10645
10645
  ['memory restore plan', ['memory', 'restore', 'plan', '락프로브'], d => { SEED(d, ['plan', 'add', '락프로브']); SEED(d, ['plan', 'remove', '락프로브']); }],
10646
10646
  ['plan drop', ['plan', 'drop', '락프로브']],
@@ -10940,7 +10940,7 @@ total++;
10940
10940
  const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10941
10941
  // 상태를 조금 만든다 — 빈 프로젝트는 출력이 짧아 누출을 과소평가한다. 입력은 전부 영어.
10942
10942
  R(['task', 'add', 'Implement the parser']);
10943
- R(['decision', 'add', 'Use JSON storage', '--why', 'simplest']);
10943
+ R(['decision', 'add', 'Use JSON storage', '--reason', 'simplest']);
10944
10944
  R(['plan', 'add', 'Ship v1']);
10945
10945
  const HANGUL = /[가-힣ㄱ-ㆎ]/;
10946
10946
  // 측정 대상은 **도구가 말하는 표면**이다. 사용자 데이터(프로젝트명·과거 기록)는 한국어여도 정상이므로
@@ -11255,7 +11255,7 @@ total++;
11255
11255
  const SURFACES = [
11256
11256
  ['requests', (d, t) => R(d, ['requests', 'add', t]), (d) => [R(d, ['requests', 'list']), R(d, ['requests', 'audit'])]],
11257
11257
  ['task', (d, t) => R(d, ['task', 'add', t]), (d) => [R(d, ['task', 'list'])]],
11258
- ['decision', (d, t) => R(d, ['decision', 'add', t, '--why', 'r']), (d) => [R(d, ['decision', 'list'])]],
11258
+ ['decision', (d, t) => R(d, ['decision', 'add', t, '--reason', 'r']), (d) => [R(d, ['decision', 'list'])]],
11259
11259
  ['lesson', (d, t) => R(d, ['lesson', 'save', t]), (d) => [R(d, ['lesson', 'list'])]],
11260
11260
  ['rule', (d, t) => R(d, ['rule', 'add', t, '--trigger', 'every-round']), (d) => [R(d, ['rule', 'list'])]],
11261
11261
  ['plan', (d, t) => R(d, ['plan', 'add', t]), (d) => [R(d, ['plan', 'list'])]],
@@ -11464,11 +11464,11 @@ total++;
11464
11464
  const SEED = [['requests', 'add', 'seed'], ['preview', 'add', 'seed'], ['toggle', 'set', 'gate', 'off'],
11465
11465
  ['creds', 'register', 'seed', '--env-var', 'K'], ['permissions', 'set', 'extended'], ['wakeup-interval', 'set', '900'],
11466
11466
  ['next-action', 'add', 'seed'], ['constraints', 'add', 'seed', '--constraint', 'k:v'],
11467
- ['decision', 'add', 'seed', '--why', 'r'], ['lesson', 'save', 'seed']];
11467
+ ['decision', 'add', 'seed', '--reason', 'r'], ['lesson', 'save', 'seed']];
11468
11468
  const MUT = [['requests', 'add', 'x2'], ['preview', 'add', 'x2'], ['toggle', 'set', 'lens', 'off'],
11469
11469
  ['creds', 'register', 'x2', '--env-var', 'K2'], ['permissions', 'set', 'basic'], ['wakeup-interval', 'set', '1200'],
11470
11470
  ['next-action', 'add', 'x2'], ['constraints', 'add', 'x2', '--constraint', 'k:v'],
11471
- ['decision', 'add', 'x2', '--why', 'r'], ['lesson', 'save', 'x2'], ['task', 'add', 'x2'], ['plan', 'add', 'x2']];
11471
+ ['decision', 'add', 'x2', '--reason', 'r'], ['lesson', 'save', 'x2'], ['task', 'add', 'x2'], ['plan', 'add', 'x2']];
11472
11472
  const DERIVED = new Set(['manifest.json', 'skills-lock.json', 'leerness-config.json', 'environment.json']);
11473
11473
  const seeded = () => { const d = mk(); for (const a of SEED) R(d, a); return d; };
11474
11474
  const base = seeded();
@@ -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
+ }