leerness 1.36.304 → 1.36.305

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.305 - 2026-10-16
4
+
5
+ - Fixed: on Windows with Node 23.x, 24.0.0-24.21.x, 25.x or 26.0.0-26.8.x, a removal that failed (a file or folder another process holds: an editor, antivirus, a shell's working directory) could end leerness without any error when the path had a character outside the system code page, and on other non-ASCII paths the error named a garbled path (T-0365). This is a Node bug: when fs.rmSync fails, Node builds the error from the absolute path given, converted with the ANSI code page, which ends the process for a character the code page cannot hold (exit code 0xC0000409; no try/catch sees it). Node fixed it in 24.22.0 and 26.9.0; 22.x and older are not affected. Measured on this PC (Node 26.3.0, code page 949): the process ended for e-acute, u-umlaut, n-tilde, Thai, Arabic, an emoji and a CJK Extension B character, and not for ASCII, Hangul, hanja, kana, Cyrillic, Greek, the euro sign or the other characters code page 949 holds; also for a single held file; read-only files are removed normally.
6
+ - `lib/safe-remove.js` `removeSync` is fs.rmSync with the same arguments, except on those Windows Node versions for a path with a character above U+007F (Node converts only the path given, and every code page holds ASCII). There it checks the options with fs.rmSync itself first (on a path that does not exist) and reads them as Node does (one copy of their own enumerable properties, so a getter runs once; only recursive, force, maxRetries and retryDelay are used), then the target as given, as Node does (an empty path is ENOENT; a string, a Buffer or a URL goes to the file calls unchanged); unlinks a file or a link (a symbolic link or a junction) as given, which reports its error with the path and never ends the process; and for a folder makes its first change on the folder as given (an empty one is removed there), so a write guard (`--dry-run`, the runtime write boundary) decides on the same path as for fs.rmSync's one call. A full folder is removed by fs.rmSync itself in a child process, with the absolute path this process resolved (a copied environment loses Windows' per-drive current directories), the same options and working directory; if that process ends, as fs.rmSync makes it on this bug, the caller gets an EPERM error that names the folder. A folder without recursive is ERR_FS_EISDIR, as in fs.rmSync.
7
+ - It is used where leerness removes user data or its own files: `skill remove`, archive pruning, the npm publish temp folder (its credential file is emptied first, as before), the atomic write's hard-link rollback and detach (`lib/io.js`), the `.harness` to `.leerness` migration rollback, preview cleanup, memory output and recovery bundle cleanup (with the file functions captured before the write guard, as before), the claims baseline and machine cache temp files, and the test runner's fixture; also `scripts/temp-cleanup.js` `removeTree`, which the tests use. selftest's own fixtures keep fs.rmSync.
8
+ - On an affected Node and a non-ASCII path, a file is removed with unlink, not fs.rmSync: the atomic write's fallback after a failed unlink (fs.rmSync of the same file) is that unlink again there.
9
+ - `leerness env summary` (and `env summary --json`, as `node.rmSyncPathBug`) and `leerness selftest` say when this Node has the bug and to update it to 24.22.0 or later, or 26.9.0 or later, in the language the user chose. selftest now prints its heading, the note on the settings it ignores while it runs, and this advisory before the cases run, in the same order as before.
10
+ - The full test run on this code (`ci-shard.js npm test i/3`, three shards in parallel, each with its own new temp directory; Windows, Node v26.3.0): 69/69 leaves in each shard (207, one more than 1.36.304 for the new probe), e2e 469/469 with 432 folders recorded; the runner removed 38, 217 and 47 temp folders. Three `leerness-*` folders were left, the same kinds as in 1.36.304: one that an e2e case replaces after it was recorded (`orph116`) and two memory recovery bundles from processes that do not record.
11
+ - Tests: `scripts/safe-remove-probe.js` (new, in `npm run test:temp-cleanup`), twenty-nine cases: the version bounds on Windows, Linux and macOS; which paths are isolated (ASCII paths are not; relative, Buffer and Hangul paths); without the isolate option an ASCII path is exactly one fs.rmSync call and a non-ASCII one is isolated only with the bug; where it is off, removeSync is exactly one fs.rmSync call with the same arguments; a full folder goes to the child as the absolute path this process resolved, also for a drive-relative `D:rel`, with the four option values it uses; bad options are rejected before anything is removed, with fs.rmSync's error; options are read as Node reads them (an inherited or non-enumerable `recursive` does not count; a BigInt extra, a cycle or a `toJSON` changes nothing; each option is read once, as by fs.rmSync, getters included); the child removes a relative path from this working directory and runs without NODE_OPTIONS; an empty path removes nothing, as fs.rmSync; under `--dry-run` the removal is refused at the folder, also when it is spelled with a trailing separator or given as a URL with a search part, and nothing inside goes, and where the guard allows the folder (a `*.lock` folder) all of it goes, as with fs.rmSync (where fs.rmSync is native and asks the guard once; on Node 22 and older it asks for each entry, and the case says so and is skipped); a nested tree with a read-only file and non-ASCII names is removed; links are removed as links and their targets kept; the same results as fs.rmSync for a missing path (with and without force), a folder without recursive, a file, an empty folder and a full folder; this process never calls fs.rmSync on the target of an isolated removal; a failure names the target with its code, and a child ended by the bug is an EPERM error; a held file is waited out with maxRetries; the removal sites use removeSync; on Windows, `leerness skill remove` with a held folder under a path with an emoji ends with an error, the npm temp cleanup reports the folder as not cleaned, and `removeTree` fails with the code and waits out a short hold, instead of the process ending; the env flag and the advisory in English and Korean; selftest prints the advisory in the language chosen with LEERNESS_LANG; the selftest case. On this PC the probe's first version passed 0 of 15 cases on the 1.36.304 code, and with lib/ switched but bin/ and scripts/ not, the three Windows cases ended the process (0xC0000409). selftest has one more case. `scripts/test-node-runner-probe.js` injects its fixture hold into rmdirSync as well; `scripts/temp-cleanup-probe.js` skips its direct fs.rmSync report on an affected Node with a non-ASCII temp path.
12
+ - 41 deliberate breaks of `lib/safe-remove.js` (each check, and the child's path, options, working directory, environment and answer), the switched call sites, the advisory and the selftest change were run against this probe, with the unchanged copy as the control (28/28): 40 made a case fail on Windows, each through a failed check (the probe ran to its end every time); the one that passes moves the child's working directory, which no longer matters since the child gets an absolute path. After the last change (each option read once) its four options mutants, one new, and the control (29/29) were run again on the final code: all four fail a case.
13
+ - Review: cross-model review (codex gpt-6.1-sol), four rounds. The first version removed a folder by walking it in JavaScript; round 1 found that a drive-relative `C:` removed names at the drive root (P1), that selftest chose the advisory's language after dropping LEERNESS_LANG (P2), and that a test forced the mode it should check (P2); round 2 that an empty path removed the working directory's contents (P1) and that a write guard checked every entry, so under `--dry-run` an exempt `*.lock` entry went before the next was refused (P1); round 3 that the guard saw the resolved spelling, so a trailing separator made a folder exempt (P1), and that entries vanishing during the walk and retries without a rescan differed from fs.rmSync (P2). After round 3 the owner chose to have fs.rmSync itself remove a full folder, in a child process. Round 4's first run was stopped by the review service before a report; its notes named three cases, which were fixed (a URL target's search part changed what the guard saw; options fs.rmSync rejects were not checked first; the child lost the per-drive current directory), and round 4 ran again; it found that the options were read with inherited and non-enumerable properties and sent to the child as a whole (P2, P2), which were fixed; the owner chose a short confirmation of that fix, which found that a getter ran twice (once in the options check, once in the copy; P2); by the owner's decision that was fixed, with a test that fails on the reviewed code, and shipped without another round.
14
+
3
15
  ## 1.36.304 - 2026-10-16
4
16
 
5
17
  - Fixed (tests): probes and e2e cases left their temp folders behind (T-0357). On 2026-10-14 this PC's temp directory held 8,434 `leerness-*` folders under 332 name prefixes; 5,142 of them came from e2e.js case sandboxes and 1,243 from the memory probes' arena (T-0354 fixed that one). The test runner now removes them: the test process records the folders it makes, and `scripts/ci-shard.js` removes them after the test ended with exit status 0.
package/README.md CHANGED
@@ -195,7 +195,7 @@ MIT
195
195
  <!-- leerness:project-readme:start -->
196
196
  ## Leerness Project Harness
197
197
 
198
- 이 프로젝트는 Leerness v1.36.304 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
198
+ 이 프로젝트는 Leerness v1.36.305 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
199
199
 
200
200
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
201
201
 
@@ -249,7 +249,7 @@ leerness memory restore decision <date|title>
249
249
 
250
250
  ### MCP server (외부 AI 통합)
251
251
 
252
- Leerness v1.36.304는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
252
+ Leerness v1.36.305는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
253
253
 
254
254
  ```jsonc
255
255
  // 카테고리별
@@ -270,7 +270,7 @@ Leerness v1.36.304는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
270
270
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
271
271
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
272
272
 
273
- 현재 사용 중인 하네스 버전: **1.36.304**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
273
+ 현재 사용 중인 하네스 버전: **1.36.305**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
274
274
 
275
275
  ### 성능 가이드
276
276
 
@@ -308,7 +308,7 @@ leerness release pack --close --auto-main-push
308
308
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
309
309
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
310
310
 
311
- Last synced by Leerness v1.36.304: 2026-09-30
311
+ Last synced by Leerness v1.36.305: 2026-09-30
312
312
  <!-- leerness:project-readme:end -->
313
313
  GitHub 로그인 계정 확인용 서버 어댑터: [설정·연결·승인과의 구분](docs/github-login.md). 실제 앱 등록/브라우저 세션 연결과 대상별 승인은 후속입니다.
314
314
 
package/bin/leerness.js CHANGED
@@ -62,6 +62,7 @@ const path = require('path');
62
62
  const cp = require('child_process');
63
63
  const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
64
64
  const { resolveNpmCliPath, spawnNpmSync } = require('../lib/npm-process');
65
+ const { removeSync: _removeSync, rmSyncPathBug: _rmSyncPathBug } = require('../lib/safe-remove'); // T-0365
65
66
  const { resolvePortableLaunch, spawnPortable, spawnPortableSync } = require('../lib/portable-process');
66
67
  const { scanShellScriptsEncoding, applyShellScriptUtf8Bom } = require('../lib/shell-encoding');
67
68
  const { captureMemoryInput, assertMemoryInputCurrent, captureCheckedFileInput, assertCheckedFileCurrent } = require('../lib/memory-input');
@@ -115,7 +116,7 @@ const {
115
116
  migrateLegacyWorkspace,
116
117
  } = require('../lib/workspace-dir');
117
118
 
118
- const VERSION = '1.36.304';
119
+ const VERSION = '1.36.305';
119
120
 
120
121
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
121
122
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -1375,7 +1376,7 @@ function _pruneArchives(root, keep = 10) {
1375
1376
  const withTime = names.map(name => { let t = 0; try { t = fs.statSync(path.join(adir, name)).mtimeMs; } catch {} return { name, t }; });
1376
1377
  withTime.sort((a, b) => b.t - a.t); // 최신 우선 (이름의 버전 문자열은 zero-pad 안 돼 사전순 부정확 → mtime 사용)
1377
1378
  let pruned = 0;
1378
- for (const e of withTime.slice(keep)) { try { fs.rmSync(path.join(adir, e.name), { recursive: true, force: true }); pruned++; } catch {} }
1379
+ for (const e of withTime.slice(keep)) { try { _removeSync(path.join(adir, e.name), { recursive: true, force: true }); pruned++; } catch {} }
1379
1380
  return pruned;
1380
1381
  }
1381
1382
  function createBackup(root, reason, files, dry = false) {
@@ -2494,10 +2495,10 @@ function skillRemove(root, id) {
2494
2495
  try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
2495
2496
  if (_skillCatalog()[id]) {
2496
2497
  // catalog 스킬은 로컬 메타만 제거 (카탈로그는 패키지 내장이라 영구 제거 불가)
2497
- fs.rmSync(dir, { recursive: true, force: true });
2498
+ _removeSync(dir, { recursive: true, force: true });
2498
2499
  ok(`local meta removed for catalog skill: ${id} (catalog 자체는 패키지에 내장)`);
2499
2500
  } else {
2500
- fs.rmSync(dir, { recursive: true, force: true });
2501
+ _removeSync(dir, { recursive: true, force: true });
2501
2502
  ok(`user skill removed: ${id}`);
2502
2503
  }
2503
2504
  }
@@ -3712,7 +3713,7 @@ function _collectRuntimeEnv(opts = {}) {
3712
3713
  function _collectRuntimeEnvBase() {
3713
3714
  const env = {
3714
3715
  os: { platform: os.platform(), release: os.release(), arch: os.arch() },
3715
- node: { version: process.version, exe: process.execPath },
3716
+ node: { version: process.version, exe: process.execPath, rmSyncPathBug: _rmSyncPathBug() },
3716
3717
  locale: {
3717
3718
  LANG: process.env.LANG || null,
3718
3719
  LC_ALL: process.env.LC_ALL || null,
@@ -3853,6 +3854,14 @@ function _scanShellScriptsEncoding(root) {
3853
3854
  return scanShellScriptsEncoding(root, { classifyCJK: _classifyCJK, riskLabel: _riskLabel });
3854
3855
  }
3855
3856
 
3857
+ // T-0365: Windows Node versions whose failed fs.rmSync ends the process (lib/safe-remove.js). leerness removes its own
3858
+ // files around it; the advisory is for the user, since other programs on the same Node are not.
3859
+ function _rmSyncAdvisory(lang, version = process.versions.node, platform = process.platform) {
3860
+ if (!_rmSyncPathBug(version, platform)) return null;
3861
+ return lang === 'en'
3862
+ ? `Node ${version} on Windows: when fs.rmSync fails on a path with a character outside the system code page, the process ends without an error (fixed in Node 24.22.0 and 26.9.0). leerness removes files without it on this version. Update Node to 24.22.0 or later, or 26.9.0 or later.`
3863
+ : `Windows 의 Node ${version}: 시스템 코드 페이지에 없는 문자가 든 경로에서 fs.rmSync 가 실패하면 프로세스가 오류 없이 종료됩니다(Node 24.22.0·26.9.0 에서 수정). 이 버전에서 leerness 는 fs.rmSync 를 쓰지 않고 삭제합니다. Node 를 24.22.0 이상 또는 26.9.0 이상으로 올리세요.`;
3864
+ }
3856
3865
  function envCmd(root, sub) {
3857
3866
  root = absRoot(root);
3858
3867
  const isTty = process.stdout && process.stdout.isTTY;
@@ -3914,6 +3923,7 @@ function envCmd(root, sub) {
3914
3923
  log('');
3915
3924
  log(` 💻 OS: ${gr(env.os.platform)} ${env.os.release} (${env.os.arch})`);
3916
3925
  log(` 📦 Node: ${gr(env.node.version)}`);
3926
+ { const advisory = _rmSyncAdvisory(_uiLang(root)); if (advisory) log(yl(` ⚠ ${advisory}`)); }
3917
3927
  log(` 🌐 Locale: LANG=${env.locale.LANG || 'unset'} · CP=${env.locale.codepage || 'unknown'}${env.locale.isKoreanWindows ? yl(' (한국어 Windows)') : ''}`);
3918
3928
  // 1.9.252 (UR-0018 마무리): 인라인 Windows/POSIX 인코딩 분기를 _terminalEncodingNotice() 헬퍼로 통합 (DRY).
3919
3929
  // 이전 (1.9.249/250) 에는 env summary / handoff / install 에 거의 동일한 분기가 중복 → 단일 소스로 일원화.
@@ -4216,6 +4226,11 @@ function _selfTestCases() {
4216
4226
  { name: '_isSecretKey: NPM_TOKEN 차단', run: () => _isSecretKey('NPM_TOKEN') === true },
4217
4227
  { name: '_isSecretKey: API_KEY/SECRET/PASSWORD 차단', run: () => _isSecretKey('OPENAI_API_KEY') && _isSecretKey('MY_SECRET') && _isSecretKey('db_PASSWORD') },
4218
4228
  { name: '_isSecretKey: 비시크릿 LEERNESS_* 통과', run: () => _isSecretKey('LEERNESS_ENABLE_AGY') === false && _isSecretKey('LANG') === false },
4229
+ { name: 'T-0365: Windows fs.rmSync 경로 변환 버그 판정 (24.22.0·26.9.0 에서 수정) + 안내 문구', run: () => {
4230
+ const bug = [['22.23.3', false], ['23.0.0', true], ['24.21.0', true], ['24.22.0', false], ['25.9.0', true], ['26.8.2', true], ['26.9.0', false], ['27.0.0', false]];
4231
+ return bug.every(([v, want]) => _rmSyncPathBug(v, 'win32') === want && _rmSyncPathBug(v, 'linux') === false)
4232
+ && _rmSyncAdvisory('en', '26.3.0', 'win32') !== null && _rmSyncAdvisory('en', '26.9.0', 'win32') === null && _rmSyncAdvisory('ko', '24.0.0', 'linux') === null;
4233
+ } },
4219
4234
  { name: 'compareVer: 대소 비교', run: () => compareVer('1.9.258', '1.9.257') === 1 && compareVer('1.9.0', '1.10.0') === -1 && compareVer('2.0.0', '2.0.0') === 0 },
4220
4235
  { name: 'compareVer: 누락 파트/null 안전', run: () => compareVer('1.9', '1.9.0') === 0 && compareVer(null, '0.0.0') === 0 },
4221
4236
  { name: 'parseHarnessVersion: exact canonical/legacy formats only', run: () => parseHarnessVersion('1.9.0').base === '1.9.0' && parseHarnessVersion('leerness@1.8.0+plus@1.0.1').plus === '1.0.1' && parseHarnessVersion('leerness@1.8.0').base === '1.8.0' && parseHarnessVersion('garbage leerness@1.8.0 trailing').base === null && parseHarnessVersion('leerness@1.8.0.1').base === null && parseHarnessVersion('plus@1.0.1 junk').plus === null },
@@ -8086,15 +8101,35 @@ function _controlSelftestEnv() {
8086
8101
  return Object.assign(dropped, { restore });
8087
8102
  }
8088
8103
  function selfTestCmd(opts = {}) {
8104
+ // T-0365: the advisory's language is read before LEERNESS_LANG and the other controlled variables are dropped
8105
+ // (1.36.305 review round 1: read after, English mode printed it in Korean).
8106
+ const lang = _uiLang(process.cwd());
8089
8107
  const droppedEnv = _controlSelftestEnv();
8090
- try { return _selfTestCmdInner(opts, droppedEnv); } finally { droppedEnv.restore(); }
8108
+ try { return _selfTestCmdInner(opts, droppedEnv, lang); } finally { droppedEnv.restore(); }
8091
8109
  }
8092
- function _selfTestCmdInner(opts, droppedEnv) {
8110
+ function _selfTestCmdInner(opts, droppedEnv, lang) {
8093
8111
  const isTty = process.stdout && process.stdout.isTTY;
8094
8112
  const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
8095
8113
  const rd = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
8096
8114
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
8097
8115
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
8116
+ // The heading, the controlled-environment note and the advisory come before the cases run (several minutes on
8117
+ // Windows), in the same order as before.
8118
+ if (!opts.json) {
8119
+ log(cy(`# leerness selftest (1.9.258) — 코어 함수 무결성 검증 (v${VERSION})`));
8120
+ log('');
8121
+ // 무시한 설정은 반드시 보인다 — 사용자가 켜 둔 것이 안 먹었다는 사실을 숨기면 그 자체가 거짓 보고다(1.36.125).
8122
+ if (droppedEnv.length) {
8123
+ log(dm(` ⓘ 측정 통제: 검증 중에만 ${droppedEnv.length}개 환경설정을 무시합니다 — ${droppedEnv.join(', ')}`));
8124
+ // ⚠ 1.36.129: 여기에 다른 가드의 **실패 표지 문자열**을 그대로 적어 두었더니, 그 가드가 이 안내를
8125
+ // 실패로 읽었다(안내 문구가 곧 표면이다 — 같은 클래스를 이 저장소에서 세 번째로 만났다).
8126
+ // 뜻은 유지하고 표지 문자열만 피한다.
8127
+ log(dm(` (이 설정들이 켜져 있으면 selftest 가 정상 설치를 실패로 거짓 보고했습니다 — 실제 제품 동작은 그대로입니다)`));
8128
+ log('');
8129
+ }
8130
+ const advisory = _rmSyncAdvisory(lang);
8131
+ if (advisory) { log(` ⚠ ${advisory}`); log(''); }
8132
+ }
8098
8133
  const cases = _selfTestCases();
8099
8134
  const results = cases.map(c => {
8100
8135
  let ok = false, err = null;
@@ -8108,17 +8143,6 @@ function _selfTestCmdInner(opts, droppedEnv) {
8108
8143
  if (fail > 0) process.exitCode = 1;
8109
8144
  return;
8110
8145
  }
8111
- log(cy(`# leerness selftest (1.9.258) — 코어 함수 무결성 검증 (v${VERSION})`));
8112
- log('');
8113
- // 무시한 설정은 반드시 보인다 — 사용자가 켜 둔 것이 안 먹었다는 사실을 숨기면 그 자체가 거짓 보고다(1.36.125).
8114
- if (droppedEnv.length) {
8115
- log(dm(` ⓘ 측정 통제: 검증 중에만 ${droppedEnv.length}개 환경설정을 무시합니다 — ${droppedEnv.join(', ')}`));
8116
- // ⚠ 1.36.129: 여기에 다른 가드의 **실패 표지 문자열**을 그대로 적어 두었더니, 그 가드가 이 안내를
8117
- // 실패로 읽었다(안내 문구가 곧 표면이다 — 같은 클래스를 이 저장소에서 세 번째로 만났다).
8118
- // 뜻은 유지하고 표지 문자열만 피한다.
8119
- log(dm(` (이 설정들이 켜져 있으면 selftest 가 정상 설치를 실패로 거짓 보고했습니다 — 실제 제품 동작은 그대로입니다)`));
8120
- log('');
8121
- }
8122
8146
  results.forEach(r => {
8123
8147
  if (r.ok) log(` ${gr('✓')} ${r.name}`);
8124
8148
  else log(` ${rd('✗')} ${r.name}${r.err ? dm(' — ' + r.err) : ''}`);
@@ -25081,7 +25105,7 @@ function _cleanupNpmTempDir(tmpDir, sensitiveFile = null) {
25081
25105
  try { fs.chmodSync(sensitiveFile, 0o600); } catch {}
25082
25106
  fs.writeFileSync(sensitiveFile, '', { mode: 0o600 });
25083
25107
  }
25084
- fs.rmSync(tmpDir, { recursive: true, force: true });
25108
+ _removeSync(tmpDir, { recursive: true, force: true });
25085
25109
  cleaned = !exists(tmpDir);
25086
25110
  } catch {}
25087
25111
  }
@@ -34174,6 +34198,8 @@ module.exports = {
34174
34198
  _shellFailuresPath, _loadShellFailures, _recordShellFailure: projectWriter(_recordShellFailure), _shellEnvDrift,
34175
34199
  // 1.36.287 (T-0304): 환경 수집의 프로세스당 1회·도구 조회 분리 — 자식 프로세스 수로 검증
34176
34200
  _collectRuntimeEnv,
34201
+ // T-0365: the rmSync advisory and the npm temp cleanup, for scripts/safe-remove-probe.js
34202
+ _rmSyncAdvisory, _cleanupNpmTempDir,
34177
34203
  // 1.9.265: CLI 에이전트 슬래시 명령어 레지스트리 (UR-0021 1단계) — 단위 테스트
34178
34204
  AGENT_SLASH_COMMANDS, _agentSlashFile, _loadAgentSlashCommands, _recordAgentSlashCommands, _agentSlashHint, slashCommandsCmd,
34179
34205
  _parseSlashFromHelp, _probeAgentSlash, _refreshAgentSlashCommands,
@@ -8,6 +8,7 @@
8
8
 
9
9
  const crypto = require('crypto');
10
10
  const fs = require('fs');
11
+ const { removeSync } = require('./safe-remove');
11
12
  const path = require('path');
12
13
  const { absRoot, exists, read, mkdirp, writeUtf8 } = require('./io');
13
14
  const { _isDoneStatus } = require('./pure-utils');
@@ -163,7 +164,7 @@ function saveBaseline(root, doc, withLock) {
163
164
  writeUtf8(temp, JSON.stringify(doc, null, 2) + '\n');
164
165
  try { fs.renameSync(temp, file); }
165
166
  catch (error) {
166
- try { fs.rmSync(temp, { force: true }); } catch {}
167
+ try { removeSync(temp, { force: true }); } catch {}
167
168
  throw error;
168
169
  }
169
170
  };
package/lib/io.js CHANGED
@@ -11,6 +11,7 @@ const { errorLanguage } = require('./error-language');
11
11
  const _english = () => errorLanguage() === 'en';
12
12
  const _korean = () => errorLanguage() === 'ko';
13
13
  const fs = require('fs');
14
+ const { removeSync } = require('./safe-remove');
14
15
  const path = require('path');
15
16
  const os = require('os');
16
17
  const cp = require('child_process');
@@ -597,7 +598,7 @@ function _rollbackCommittedHardLink(alias, live, expected, maxReadBytes) {
597
598
  try { fs.unlinkSync(live); }
598
599
  catch (error) { removalError = error; }
599
600
  if (_lstatState(live).exists === true) {
600
- try { fs.rmSync(live, { force: true }); }
601
+ try { removeSync(live, { force: true }); }
601
602
  catch (error) { removalError = removalError || error; }
602
603
  }
603
604
  if (_lstatState(live).exists === true) {
@@ -606,7 +607,7 @@ function _rollbackCommittedHardLink(alias, live, expected, maxReadBytes) {
606
607
  fs.renameSync(live, quarantine);
607
608
  try { fs.unlinkSync(quarantine); }
608
609
  catch {
609
- try { fs.rmSync(quarantine, { force: true }); } catch {}
610
+ try { removeSync(quarantine, { force: true }); } catch {}
610
611
  }
611
612
  const postRename = _lstatState(live);
612
613
  return postRename.exists === false
@@ -643,7 +644,7 @@ function detachCommittedHardLink(aliasPath, livePath, expectedBytes, opts = {})
643
644
  firstCleanupError = error;
644
645
  // Some transient/platform wrappers fail unlink but permit the equivalent
645
646
  // exact-path removal. This is only the random prepared alias, never user data.
646
- try { fs.rmSync(alias, { force: true }); } catch {}
647
+ try { removeSync(alias, { force: true }); } catch {}
647
648
  if (fs.existsSync(alias)) {
648
649
  const detacher = `${alias}.detach-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
649
650
  let detacherExists = false;
@@ -655,7 +656,7 @@ function detachCommittedHardLink(aliasPath, livePath, expectedBytes, opts = {})
655
656
  fs.renameSync(detacher, alias);
656
657
  detacherExists = false;
657
658
  } catch (detachError) {
658
- try { if (detacherExists) fs.rmSync(detacher, { force: true }); } catch {}
659
+ try { if (detacherExists) removeSync(detacher, { force: true }); } catch {}
659
660
  const expected = Buffer.isBuffer(expectedBytes) ? expectedBytes : null;
660
661
  const rollback = _rollbackCommittedHardLink(alias, live, expected, maxReadBytes);
661
662
  // Another actor may have detached the alias between our failed rename
@@ -677,7 +678,7 @@ function detachCommittedHardLink(aliasPath, livePath, expectedBytes, opts = {})
677
678
  }
678
679
  // The path now names an unrelated empty inode. Failure to remove this
679
680
  // cleanup artifact is non-fatal and cannot poison the committed file.
680
- try { fs.rmSync(alias, { force: true }); } catch {}
681
+ try { removeSync(alias, { force: true }); } catch {}
681
682
  }
682
683
  }
683
684
  let stat;
@@ -6,6 +6,7 @@
6
6
  // read or parse error as a miss. Writes are best-effort and must never fail the calling command.
7
7
  // It never lives inside a project, so project read-only contracts are unaffected.
8
8
  const fs = require('fs');
9
+ const { removeSync } = require('./safe-remove');
9
10
  const os = require('os');
10
11
  const path = require('path');
11
12
  const crypto = require('crypto');
@@ -70,7 +71,7 @@ function writeCache(name, key, value) {
70
71
  fs.renameSync(temp, file);
71
72
  return true;
72
73
  } catch {
73
- try { fs.rmSync(temp, { force: true }); } catch {}
74
+ try { removeSync(temp, { force: true }); } catch {}
74
75
  return false;
75
76
  }
76
77
  }
@@ -9,6 +9,7 @@
9
9
  // cleanup also cannot retain writes that arrive later through an already-open
10
10
  // descriptor to the displaced inode; this boundary protects pathname races.
11
11
  const fs = require('fs');
12
+ const { removeSync } = require('./safe-remove');
12
13
  const { readPublicationBytes } = require('./memory-publication-read');
13
14
  const path = require('path');
14
15
  const crypto = require('crypto');
@@ -210,7 +211,7 @@ function cleanupFile(file) {
210
211
  }
211
212
  function cleanupDir(dir) {
212
213
  if (!dir) return null;
213
- try { if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true }); return null; }
214
+ try { if (fs.existsSync(dir)) removeSync(dir, { recursive: true }); return null; }
214
215
  catch (error) { return error; }
215
216
  }
216
217
  function markOutputError(error, details = {}) {
package/lib/migrate.js CHANGED
@@ -6,6 +6,7 @@
6
6
  'use strict';
7
7
  const path = require('path');
8
8
  const fs = require('fs');
9
+ const { removeSync } = require('./safe-remove');
9
10
  const os = require('os');
10
11
  const cp = require('child_process');
11
12
  const { absRoot, exists, read, log, ok, warn } = require('./io');
@@ -121,7 +122,7 @@ function migratePlanCmd(root, opts = {}, deps = {}) {
121
122
  for (const f of tmpFiles) if (!exists(path.join(root, f))) plan.missingFiles.push(f);
122
123
  }
123
124
  } catch {}
124
- finally { if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } }
125
+ finally { if (tmp) { try { removeSync(tmp, { recursive: true, force: true }); } catch {} } }
125
126
  plan.willChange = plan.missingFiles.length + plan.canonicalPending.length + (plan.versionDrift ? 1 : 0);
126
127
  if (opts.json) { log(JSON.stringify(plan, null, 2)); return; }
127
128
  log(`# leerness migrate plan (1.9.358, UR-0075 Phase D) — 임시폴더 설치 후 비교 · 읽기 전용(프로젝트 미변경)`);
@@ -7,6 +7,7 @@
7
7
  // leerness 는 사용자의 dev 서버를 임의로 실행하지 않는다(사용자 코드 실행은 명시 옵트인 영역).
8
8
  'use strict';
9
9
  const fs = require('fs');
10
+ const { removeSync } = require('./safe-remove');
10
11
  const http = require('http');
11
12
  const path = require('path');
12
13
  const { absRoot, exists, read, writeUtf8, mkdirp, log, ok, warn, failJson, now } = require('./io');
@@ -54,7 +55,7 @@ function cleanupServeDir(root, id) {
54
55
  const checked = _previewOutputPathCheck(root, dRel, { requireDirectory: exists(d) });
55
56
  // Never follow a swapped `.leerness/previews` or `.serve-*` link during
56
57
  // recursive cleanup. An unsafe path is left untouched for manual recovery.
57
- if (checked.ok && exists(d)) { fs.rmSync(d, { recursive: true, force: true }); cleaned = true; }
58
+ if (checked.ok && exists(d)) { removeSync(d, { recursive: true, force: true }); cleaned = true; }
58
59
  } catch {}
59
60
  // (검수 #8) project 모드로 프로젝트 정적 디렉토리에 배치한 사본도 함께 정리 — "확인 끝나면 정리한다"는 안내와 실제를 일치시킨다.
60
61
  try {
@@ -66,7 +67,7 @@ function cleanupServeDir(root, id) {
66
67
  const abs = path.resolve(absRoot(root), placed);
67
68
  const rel = path.relative(absRoot(root), abs).replace(/\\/g, '/');
68
69
  const checked = _previewOutputPathCheck(root, rel, { requireFile: exists(abs) });
69
- if (checked.ok && exists(abs)) { fs.rmSync(abs, { force: true }); cleaned = true; }
70
+ if (checked.ok && exists(abs)) { removeSync(abs, { force: true }); cleaned = true; }
70
71
  delete j.placed[id];
71
72
  writeUtf8(cfgF, JSON.stringify(j, null, 2) + '\n');
72
73
  }
@@ -3,6 +3,7 @@
3
3
  // 호환 릴리스의 관측 기반 쓰기 경계. 원자 fencing/activation 프로토콜이 아니다.
4
4
  // topology만 한 작업 동안 재사용하고 descriptor/indicator는 매 쓰기마다 다시 읽는다.
5
5
  const fs = require('fs');
6
+ const { removeSync } = require('./safe-remove');
6
7
  const path = require('path');
7
8
  const os = require('os');
8
9
  const { fileURLToPath } = require('url');
@@ -20,6 +21,11 @@ const recoveryFs = {
20
21
  closeSync: fs.closeSync.bind(fs),
21
22
  chmodSync: typeof fs.chmodSync === 'function' ? fs.chmodSync.bind(fs) : null,
22
23
  rmSync: typeof fs.rmSync === 'function' ? fs.rmSync.bind(fs) : null,
24
+ // T-0365: what lib/safe-remove.js removeSync checks and removes a target with on a Windows Node whose fs.rmSync can end
25
+ // the process (a full folder then goes to fs.rmSync in a child process).
26
+ lstatSync: fs.lstatSync.bind(fs),
27
+ unlinkSync: fs.unlinkSync.bind(fs),
28
+ rmdirSync: fs.rmdirSync.bind(fs),
23
29
  };
24
30
  let installed = false;
25
31
 
@@ -260,7 +266,7 @@ function writeExternalRecoveryBundle(root, files) {
260
266
  }
261
267
  return dir;
262
268
  } catch (error) {
263
- try { if (recoveryFs.rmSync) recoveryFs.rmSync(dir, { recursive: true, force: true }); } catch {}
269
+ try { if (recoveryFs.rmSync) removeSync(dir, { recursive: true, force: true }, { fsImpl: recoveryFs }); } catch {}
264
270
  throw error;
265
271
  }
266
272
  }
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ // T-0365: on Windows, Node 23.x, 24.0.0-24.21.x, 25.x and 26.0.0-26.8.x build the error of a fs.rmSync that fails (a
4
+ // file or folder another process holds) from std::filesystem::path::string() of the absolute path given, which
5
+ // converts with the ANSI code page. A character the code page has no mapping for ends the process (0xC0000409; no
6
+ // try/catch sees it), and any other non-ASCII path comes back garbled in error.path and error.message. Node fixed it
7
+ // in 24.22.0 and 26.9.0 (nodejs/node 37aeecc7b6, "fs: fix rmSync error messages for non-ASCII paths"); 22.x and older
8
+ // remove in JavaScript. Only that one path is converted, so an ASCII path is safe on every version.
9
+ // removeSync is fs.rmSync with the same arguments except for a non-ASCII path on those versions. There a file or a link
10
+ // is unlinked (libuv reports its errors with the path as given), and a folder is removed by fs.rmSync itself in a
11
+ // child process, so a failure ends only the child. 1.36.305 review rounds 1-3 found, each time, another place where a
12
+ // removal walked in JavaScript differed from fs.rmSync (a drive-relative path, an empty path, a write guard seeing
13
+ // every entry, a trailing separator, entries that vanish, retries that do not rescan); the owner chose to have
14
+ // fs.rmSync itself do the removal.
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const cp = require('child_process');
19
+ const crypto = require('crypto');
20
+ const { fileURLToPath } = require('url');
21
+ // fs.rmSync as it was when this module loaded (lib/io.js loads it before any write guard wraps fs), used only to have
22
+ // Node check a removal's options on a path that does not exist.
23
+ const RM_UNGUARDED = fs.rmSync.bind(fs);
24
+
25
+ function rmSyncPathBug(version = process.versions.node, platform = process.platform) {
26
+ if (platform !== 'win32') return false;
27
+ const [major, minor] = String(version).replace(/^v/, '').split('.').map(Number);
28
+ if (major === 23 || major === 25) return true;
29
+ if (major === 24) return minor < 22;
30
+ if (major === 26) return minor < 9;
31
+ return false;
32
+ }
33
+
34
+ const asPath = target => target instanceof URL ? fileURLToPath(target) : Buffer.isBuffer(target) ? target.toString() : target;
35
+ // Whether fs.rmSync could end the process or garble its error for this target: the bug, and a character above U+007F
36
+ // in the absolute path Node converts (any ANSI code page holds ASCII).
37
+ function needsIsolation(target, version, platform) {
38
+ if (!rmSyncPathBug(version, platform)) return false;
39
+ const p = asPath(target);
40
+ return typeof p === 'string' && /[^\x00-\x7f]/.test(path.resolve(p));
41
+ }
42
+
43
+ // The codes fs.rmSync retries when maxRetries is given.
44
+ const RETRY_CODES = new Set(['EBUSY', 'EMFILE', 'ENFILE', 'ENOTEMPTY', 'EPERM']);
45
+ const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
46
+
47
+ function eisdir(target) {
48
+ return Object.assign(new Error(`Path is a directory: rm returned EISDIR (is a directory) ${target}`),
49
+ { name: 'SystemError', code: 'ERR_FS_EISDIR', errno: 21, syscall: 'rm', path: target });
50
+ }
51
+
52
+ // Node checks fs.rmSync's options before it touches the path (1.36.305 review round 4: the isolated removal unlinked a
53
+ // file or removed an empty folder with options fs.rmSync rejects). The same options on a path that does not exist:
54
+ // Node returns for a missing path before the error that ends the process, so this removes nothing and cannot crash.
55
+ function checkOptions(options) {
56
+ const missing = path.join(os.tmpdir(), `leerness-rm-options-${process.pid}-${crypto.randomBytes(8).toString('hex')}`);
57
+ try { RM_UNGUARDED(missing, options); }
58
+ catch (error) { if (!(error && error.code === 'ENOENT')) throw error; }
59
+ }
60
+
61
+ // fs.rmSync(target, options) in a new Node process, from this working directory, without NODE_OPTIONS (a preload must
62
+ // not run there). It answers with one JSON line; a process that ended without one ended in fs.rmSync.
63
+ const CHILD = "const fs=require('fs');let r;try{const j=JSON.parse(fs.readFileSync(0,'utf8'));fs.rmSync(j.target,j.options);r={ok:true};}"
64
+ + "catch(e){r={ok:false,code:e&&e.code,errno:e&&e.errno};}process.stdout.write(JSON.stringify(r));";
65
+ function rmSyncInChild(target, options) {
66
+ const env = { ...process.env };
67
+ for (const k of Object.keys(env)) if (k.toUpperCase() === 'NODE_OPTIONS') delete env[k];
68
+ const r = cp.spawnSync(process.execPath, ['-e', CHILD], { input: JSON.stringify({ target, options: options || {} }), cwd: process.cwd(),
69
+ env, encoding: 'utf8', windowsHide: true, maxBuffer: 1 << 20 });
70
+ if (r.error) throw r.error;
71
+ let answer = null;
72
+ try { answer = JSON.parse(r.stdout); } catch {}
73
+ return { status: r.status, signal: r.signal, answer };
74
+ }
75
+
76
+ // fs.rmSync(target, options). fsImpl: the fs functions to use (lib/runtime-writes.js passes the ones it captured before
77
+ // installing its write guard); isolate: whether to isolate (the default: needsIsolation for this process); runChild:
78
+ // what removes a folder (tests pass their own).
79
+ function removeSync(target, options, { fsImpl = fs, isolate = needsIsolation(target), runChild = rmSyncInChild } = {}) {
80
+ if (!isolate) return fsImpl.rmSync(target, options);
81
+ // Node reads the options once, as a copy of their own enumerable properties; so does this, and only the four values
82
+ // it uses go anywhere, the child included (1.36.305 review round 4, second run: inherited or non-enumerable
83
+ // properties were read, and the whole object went to the child as JSON, where a BigInt or a cycle threw and a
84
+ // toJSON could change it). An object (not null, not an array) is copied once and the copy is checked and used, so
85
+ // a getter runs once (confirmation round: checking the object as given and then copying it ran getters twice);
86
+ // anything else is checked as given, which Node rejects before it reads a property.
87
+ const read = options !== null && typeof options === 'object' && !Array.isArray(options) ? { ...options } : options;
88
+ checkOptions(read);
89
+ const { recursive = false, force = false, maxRetries = 0, retryDelay = 100 } = { ...read };
90
+ const used = { recursive, force, maxRetries, retryDelay };
91
+ const step = (fn, settle = () => false) => {
92
+ for (let attempt = 0; ; attempt++) {
93
+ try { return fn(); }
94
+ catch (error) {
95
+ if (settle(error) || attempt >= maxRetries || !RETRY_CODES.has(error && error.code)) throw error;
96
+ sleep(retryDelay * (attempt + 1));
97
+ }
98
+ }
99
+ };
100
+ // As Node: the target as given is checked first (an empty path is ENOENT, not the working directory). The parent's
101
+ // fs calls get the target exactly as given (a string, a Buffer or a URL), as fs.rmSync's one call does, so a write
102
+ // guard (--dry-run, the runtime write boundary) sees the same value (1.36.305 review round 4: a URL's search part).
103
+ const given = asPath(target);
104
+ let st;
105
+ try { st = fsImpl.lstatSync(target); }
106
+ catch (error) { if (force && error && error.code === 'ENOENT') return; throw error; }
107
+ const gone = error => !!error && error.code === 'ENOENT';
108
+ // A file or a link (a symbolic link or, on Windows, a junction): one unlink.
109
+ if (!st.isDirectory()) {
110
+ try { return step(() => fsImpl.unlinkSync(target)); }
111
+ catch (error) { if (gone(error)) return; throw error; }
112
+ }
113
+ if (!recursive) throw eisdir(given);
114
+ // A folder: rmdir first, through fsImpl, so a guard decides before anything inside goes; an empty folder is removed
115
+ // there. A full one (ENOTEMPTY or EEXIST) is removed by fs.rmSync in a child process, given the absolute path this
116
+ // process resolved: Windows keeps each drive's current directory in environment entries ('=D:') that a copied
117
+ // environment loses, so a drive-relative 'D:x' could name another folder in the child (review round 4).
118
+ const full = error => !!error && (error.code === 'ENOTEMPTY' || error.code === 'EEXIST');
119
+ try { return step(() => fsImpl.rmdirSync(target), full); }
120
+ catch (error) { if (gone(error)) return; if (!full(error)) throw error; }
121
+ const { status, signal, answer } = runChild(path.resolve(given), used);
122
+ if (answer && answer.ok) return;
123
+ if (answer) {
124
+ throw Object.assign(new Error(`${answer.code || 'EPERM'}: could not remove ${given}`),
125
+ { code: answer.code || 'EPERM', errno: answer.errno, syscall: 'rm', path: given });
126
+ }
127
+ throw Object.assign(new Error(`EPERM: could not remove ${given}: the process removing it ended (status ${status}${signal ? `, ${signal}` : ''}); `
128
+ + 'on this Node, fs.rmSync ends the process when a removal fails on a path with a character outside the system code page'),
129
+ { code: 'EPERM', syscall: 'rm', path: given, removalProcessEnded: true });
130
+ }
131
+
132
+ module.exports = { rmSyncPathBug, needsIsolation, removeSync, RETRY_CODES };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const cp = require('node:child_process');
4
4
  const fs = require('node:fs');
5
+ const { removeSync } = require('./safe-remove');
5
6
  const os = require('node:os');
6
7
  const path = require('node:path');
7
8
  const { pathToFileURL } = require('node:url');
@@ -199,7 +200,7 @@ async function verifyFixture() {
199
200
  // The last child test process can still be exiting. Windows then refuses the delete, and Node 26
200
201
  // rmSync does not retry EPERM even with maxRetries, so wait here, bounded.
201
202
  for (const deadline = Date.now() + 5000; ;) {
202
- try { fs.rmSync(actual, { recursive: true, force: true }); break; }
203
+ try { removeSync(actual, { recursive: true, force: true }); break; }
203
204
  catch (error) {
204
205
  if (!['EBUSY', 'EMFILE', 'ENFILE', 'ENOTEMPTY', 'EPERM'].includes(error && error.code) || Date.now() >= deadline) throw error;
205
206
  await new Promise(resolve => setTimeout(resolve, 100));
@@ -4,6 +4,7 @@
4
4
  // migrated. Keeping the policy in this domain module prevents another
5
5
  // read-one-directory/write-another split brain.
6
6
  const fs = require('fs');
7
+ const { removeSync } = require('./safe-remove');
7
8
  const path = require('path');
8
9
  const crypto = require('crypto');
9
10
 
@@ -290,7 +291,7 @@ function _safeRemoveCanonicalDir(root, target) {
290
291
  if (resolvedTarget !== allowed || path.dirname(resolvedTarget) !== resolvedRoot || resolvedRoot === path.parse(resolvedRoot).root) {
291
292
  throw new WorkspaceDirectoryError('E_WORKSPACE_DIR_UNSAFE_REMOVE', `unsafe canonical rollback target: ${resolvedTarget}`);
292
293
  }
293
- fs.rmSync(resolvedTarget, { recursive: true, force: true });
294
+ removeSync(resolvedTarget, { recursive: true, force: true });
294
295
  }
295
296
 
296
297
  function _rewriteLegacyReference(file) {
@@ -915,7 +916,7 @@ function _migrateLegacyWorkspace(root, opts = {}) {
915
916
  legacyRenamed = false;
916
917
  }
917
918
  for (const item of overwritten.reverse()) { _beat(); _copyFile(item.backup, item.dst); }
918
- for (const file of created.reverse()) { _beat(); if (fs.existsSync(file)) fs.rmSync(file, { force: true }); }
919
+ for (const file of created.reverse()) { _beat(); if (fs.existsSync(file)) removeSync(file, { force: true }); }
919
920
  _beat();
920
921
  if (!canonicalExisted && fs.existsSync(state.canonical.abs)) _safeRemoveCanonicalDir(state.root, state.canonical.abs);
921
922
  } catch (rollbackError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.304",
3
+ "version": "1.36.305",
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",
@@ -143,7 +143,7 @@
143
143
  "test:ledger-sync": "node ./scripts/execution-ledger-sync-probe.js",
144
144
  "test:state-lock": "node ./scripts/shared-state-lock-probe.js",
145
145
  "test:lock-heartbeat": "node ./scripts/lock-heartbeat-probe.js",
146
- "test:temp-cleanup": "node ./scripts/temp-cleanup-probe.js && node ./scripts/copy-tree-probe.js",
146
+ "test:temp-cleanup": "node ./scripts/temp-cleanup-probe.js && node ./scripts/copy-tree-probe.js && node ./scripts/safe-remove-probe.js",
147
147
  "test:path-setup": "node ./scripts/path-setup-powershell-probe.js",
148
148
  "test:skill-archive": "node ./scripts/skill-archive-probe.js",
149
149
  "test:memory-arena-cleanup": "node ./scripts/memory-arena-cleanup-probe.js",
@@ -0,0 +1,573 @@
1
+ 'use strict';
2
+ require('./temp-cleanup').trackTemp(module); // T-0357: records this run's temp folders; the runner that started it removes them after a clean exit
3
+
4
+ // T-0365: on Windows, Node 23.x, 24.0.0-24.21.x, 25.x and 26.0.0-26.8.x end the process (0xC0000409) when a fs.rmSync
5
+ // that fails (a file or folder another process holds) has a path with a character outside the ANSI code page, and
6
+ // garble error.path otherwise. lib/safe-remove.js removeSync isolates such a removal there (a file or link is
7
+ // unlinked; a full folder is removed by fs.rmSync in a child process) and is fs.rmSync everywhere else. The cases that
8
+ // end a process run in a child; on a Windows Node without the bug they still run but cannot tell the fix apart, and
9
+ // say so.
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const cp = require('child_process');
14
+ const assert = require('assert');
15
+
16
+ const PKG = path.resolve(__dirname, '..');
17
+ const CLI = path.join(PKG, 'bin', 'leerness.js');
18
+ const SAFE = path.join(PKG, 'lib', 'safe-remove.js');
19
+ const isWin = process.platform === 'win32';
20
+ // The probe's own copy of the bounds (rmcrash/bounds-out.txt), so it does not trust the module it tests.
21
+ const [major, minor] = process.versions.node.split('.').map(Number);
22
+ const bugHere = isWin && (major === 23 || major === 25 || (major === 24 && minor < 22) || (major === 26 && minor < 9));
23
+ const CRASH = 3221226505; // 0xC0000409
24
+ const EMOJI = String.fromCodePoint(0x1f600); // in no ANSI code page
25
+ const HAN = String.fromCharCode(0xd55c, 0xae00); // in code page 949: no crash, a garbled error
26
+ const arena = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'leerness-safe-remove-'));
27
+ const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
28
+ const children = [];
29
+ let passed = 0, failed = 0, seq = 0;
30
+ function test(name, fn) {
31
+ try { fn(); passed++; console.log(`PASS ${name}`); }
32
+ catch (error) { failed++; console.error(`FAIL ${name}: ${error.stack}`); }
33
+ }
34
+ const safe = () => require(SAFE);
35
+ const fresh = name => { const d = path.join(arena, `${++seq}-${name}`); fs.mkdirSync(d, { recursive: true }); return d; };
36
+ // A child whose working directory is the folder holds it on Windows until it exits.
37
+ function hold(dir, ms) {
38
+ const child = cp.spawn(process.execPath, ['-e', `setTimeout(() => {}, ${ms})`], { cwd: dir, stdio: 'ignore', windowsHide: true });
39
+ children.push(child);
40
+ sleep(300);
41
+ return child;
42
+ }
43
+ // A file another process opened without delete sharing cannot be unlinked on Windows until that process closes it.
44
+ // Returns once the lock is in place (opening the file fails), not after a guessed delay.
45
+ function lockFile(file, ms) {
46
+ const exe = cp.spawnSync('pwsh', ['-NoProfile', '-Command', 'exit 0'], { stdio: 'ignore', windowsHide: true }).error ? 'powershell' : 'pwsh';
47
+ const ps = `$f=[System.IO.File]::Open('${file.replace(/'/g, "''")}','Open','Read','None'); Start-Sleep -Milliseconds ${ms}; $f.Close()`;
48
+ const child = cp.spawn(exe, ['-NoProfile', '-NonInteractive', '-Command', ps], { stdio: 'ignore', windowsHide: true });
49
+ children.push(child);
50
+ for (const deadline = Date.now() + 15000; Date.now() < deadline; sleep(100)) {
51
+ try { fs.closeSync(fs.openSync(file, 'r')); } catch (e) { if (e.code === 'EBUSY' || e.code === 'EPERM') return child; throw e; }
52
+ }
53
+ throw new Error('the file was not locked in time');
54
+ }
55
+ const runNode = (args, opts = {}) => cp.spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 120000, ...opts });
56
+ const note = () => { if (!bugHere) console.log(` (Node ${process.version} on ${process.platform} has no rmSync bug: this case runs but cannot tell the fix apart)`); };
57
+ const outcome = fn => { try { fn(); return 'ok'; } catch (e) { return e.code; } };
58
+
59
+ try {
60
+ test('rmSyncPathBug: the versions with the bug, Windows only', () => {
61
+ const { rmSyncPathBug } = safe();
62
+ const table = [['22.23.3', false], ['23.0.0', true], ['23.11.1', true], ['24.0.0', true], ['24.21.0', true], ['24.21.9', true],
63
+ ['24.22.0', false], ['24.30.1', false], ['25.0.0', true], ['25.9.0', true], ['26.0.0', true], ['26.3.0', true], ['26.8.2', true],
64
+ ['26.9.0', false], ['26.12.0', false], ['27.0.0', false], ['20.20.0', false], ['18.20.4', false]];
65
+ for (const [v, want] of table) {
66
+ assert.strictEqual(rmSyncPathBug(v, 'win32'), want, `win32 ${v}`);
67
+ assert.strictEqual(rmSyncPathBug(v, 'linux'), false, `linux ${v}`);
68
+ assert.strictEqual(rmSyncPathBug(v, 'darwin'), false, `darwin ${v}`);
69
+ }
70
+ assert.strictEqual(rmSyncPathBug(), bugHere, 'the defaults read this process');
71
+ });
72
+
73
+ test('needsIsolation: only a non-ASCII absolute path on a Node with the bug (Node converts only that path)', () => {
74
+ const { needsIsolation } = safe();
75
+ const e = String.fromCharCode(0xe9);
76
+ assert.strictEqual(needsIsolation('C:\\Users\\ascii\\x', '26.3.0', 'win32'), false, 'an ASCII path is isolated');
77
+ assert.strictEqual(needsIsolation('C:\\Users\\Jos' + e + '\\x', '26.3.0', 'win32'), true);
78
+ const back = process.cwd();
79
+ process.chdir(fresh('cwd-' + e));
80
+ try { assert.strictEqual(needsIsolation('rel', '26.3.0', 'win32'), true, 'a relative path below a non-ASCII working directory'); }
81
+ finally { process.chdir(back); }
82
+ assert.strictEqual(needsIsolation('C:\\Users\\' + String.fromCharCode(0xd55c) + '\\x', '24.21.0', 'win32'), true, 'Hangul (its errors come back garbled)');
83
+ assert.strictEqual(needsIsolation('C:\\Users\\Jos' + e + '\\x', '26.9.0', 'win32'), false, 'a fixed Node is isolated');
84
+ assert.strictEqual(needsIsolation('/home/jos' + e + '/x', '26.3.0', 'linux'), false, 'Linux is isolated');
85
+ assert.strictEqual(needsIsolation('rel', '26.3.0', 'win32'), /[^\x00-\x7f]/.test(process.cwd()), 'a relative path is judged with the working directory');
86
+ assert.strictEqual(needsIsolation(Buffer.from('C:\\Jos' + e), '26.3.0', 'win32'), true, 'a Buffer path');
87
+ });
88
+
89
+ // 1.36.305 review round 1: the pass-through case forced the mode, so a wrong default passed.
90
+ test('without the isolate option the choice is needsIsolation: an ASCII path is one fs.rmSync call; a non-ASCII one is isolated only with the bug', () => {
91
+ const calls = [];
92
+ const spy = { rmSync: () => calls.push('rmSync'), lstatSync: () => { calls.push('lstatSync'); return { isDirectory: () => false }; }, unlinkSync: () => calls.push('unlinkSync') };
93
+ const base = isWin ? 'C:\\leerness-sr\\' : '/tmp/leerness-sr/';
94
+ safe().removeSync(base + 'ascii.txt', { force: true }, { fsImpl: spy });
95
+ assert.deepStrictEqual(calls, ['rmSync'], 'an ASCII path');
96
+ calls.length = 0;
97
+ safe().removeSync(base + 'Jos' + String.fromCharCode(0xe9) + '.txt', { force: true }, { fsImpl: spy });
98
+ assert.deepStrictEqual(calls, bugHere ? ['lstatSync', 'unlinkSync'] : ['rmSync'], 'a non-ASCII path');
99
+ });
100
+
101
+ test('removeSync where the bug is absent is fs.rmSync with the same arguments and nothing else', () => {
102
+ const calls = [];
103
+ const spy = new Proxy({}, { get: (_, name) => (...args) => { calls.push([name, ...args]); return name === 'rmSync' ? 'result' : undefined; } });
104
+ const opts = { recursive: true, force: true, maxRetries: 3 };
105
+ assert.strictEqual(safe().removeSync('x', opts, { fsImpl: spy, isolate: false }), 'result');
106
+ assert.deepStrictEqual(calls.map(c => c[0]), ['rmSync']);
107
+ assert.strictEqual(calls[0][1], 'x'); assert.strictEqual(calls[0][2], opts, 'the options object was not passed as it is');
108
+ calls.length = 0;
109
+ safe().removeSync('y', undefined, { fsImpl: spy, isolate: false });
110
+ assert.deepStrictEqual(calls, [['rmSync', 'y', undefined]]);
111
+ });
112
+
113
+ // 1.36.305 review rounds 1 and 3: a JavaScript walk of the folder differed from fs.rmSync (a drive-relative path,
114
+ // entries that vanish, retries that do not rescan). A full folder now goes to fs.rmSync itself. Round 4: the child
115
+ // gets the absolute path the parent resolved, since Windows keeps each drive's current directory in environment
116
+ // entries ('=D:') that a copied environment loses, so 'D:rel' could name another folder there.
117
+ test('a full folder goes to the child as the absolute path this process resolved, with the four option values it uses', () => {
118
+ const got = [];
119
+ const fsImpl = { lstatSync: () => ({ isDirectory: () => true }), rmdirSync: () => { throw Object.assign(new Error('not empty'), { code: 'ENOTEMPTY' }); } };
120
+ const opts = { recursive: true, force: true, maxRetries: 4, retryDelay: 50 };
121
+ for (const target of [isWin ? process.cwd().slice(0, 2) : '.', isWin ? 'D:rel' : 'rel/x', 'rel' + path.sep + 'x', 'tail.lock' + path.sep]) {
122
+ got.length = 0;
123
+ safe().removeSync(target, opts, { fsImpl, isolate: true, runChild: (t, o) => { got.push([t, o]); return { status: 0, answer: { ok: true } }; } });
124
+ assert.strictEqual(got.length, 1, target);
125
+ assert.strictEqual(got[0][0], path.resolve(target), `the child did not get the absolute path for ${target}`);
126
+ assert.ok(path.isAbsolute(got[0][0]), got[0][0]);
127
+ assert.deepStrictEqual(got[0][1], { recursive: true, force: true, maxRetries: 4, retryDelay: 50 }, 'the options the child got');
128
+ }
129
+ });
130
+
131
+ // 1.36.305 review round 4: fs.rmSync rejects bad options before it removes anything; the isolated removal unlinked a
132
+ // file or removed an empty folder first.
133
+ test('bad options are rejected before anything is removed, with the error fs.rmSync gives', () => {
134
+ const { removeSync } = safe();
135
+ for (const [name, opts] of [['recursive not a boolean', { recursive: 'yes' }], ['maxRetries out of range', { recursive: true, maxRetries: -1 }], ['retryDelay not a number', { force: true, retryDelay: 'x' }]]) {
136
+ const d = fresh('bad-options');
137
+ const file = path.join(d, 'f.txt'), empty = path.join(d, 'empty');
138
+ fs.writeFileSync(file, 'f'); fs.mkdirSync(empty);
139
+ for (const target of [file, empty]) {
140
+ const want = outcome(() => fs.rmSync(target, opts));
141
+ assert.ok(want !== 'ok', `control: fs.rmSync accepted ${name}`);
142
+ const got = outcome(() => removeSync(target, opts, { isolate: true }));
143
+ assert.strictEqual(got, want, `${name}: ${target}`);
144
+ assert.strictEqual(fs.existsSync(target), true, `${name}: removed ${target}`);
145
+ }
146
+ }
147
+ });
148
+
149
+ // 1.36.305 review round 4, second run: Node reads fs.rmSync's options as a copy of their own enumerable properties;
150
+ // the isolated removal read inherited and non-enumerable ones too, and sent the object to the child as JSON (a BigInt
151
+ // or a cycle threw, toJSON could change it). It now reads them as Node does and sends only the four it uses.
152
+ test('options are read as Node reads them: own enumerable properties only, and only those it uses go to the child', () => {
153
+ const { removeSync } = safe();
154
+ const inherited = Object.create({ recursive: true });
155
+ const hidden = Object.defineProperty({}, 'recursive', { value: true, enumerable: false });
156
+ for (const [name, opts] of [['inherited recursive', inherited], ['non-enumerable recursive', hidden]]) {
157
+ const d = fresh('options-read');
158
+ const a = path.join(d, 'a'), b = path.join(d, 'b');
159
+ fs.mkdirSync(a); fs.mkdirSync(b);
160
+ const want = outcome(() => fs.rmSync(a, opts));
161
+ assert.strictEqual(want, 'ERR_FS_EISDIR', `control: fs.rmSync with ${name}`);
162
+ assert.strictEqual(outcome(() => removeSync(b, opts, { isolate: true })), want, name);
163
+ assert.strictEqual(fs.existsSync(b), true, `${name}: removed`);
164
+ }
165
+ const cycle = { recursive: true, force: true }; cycle.self = cycle;
166
+ for (const [name, opts] of [['a BigInt extra', { recursive: true, force: true, extra: 1n }], ['a cycle', cycle],
167
+ ['toJSON', { recursive: true, force: true, toJSON: () => ({ recursive: false }) }]]) {
168
+ const d = fresh('options-child-' + HAN);
169
+ const make = n => { const t = path.join(d, n); fs.mkdirSync(path.join(t, 's'), { recursive: true }); fs.writeFileSync(path.join(t, 's', 'f'), 'f'); return t; };
170
+ const a = make('a'), b = make('b');
171
+ assert.strictEqual(outcome(() => fs.rmSync(a, opts)), 'ok', `control: fs.rmSync with ${name}`);
172
+ assert.strictEqual(outcome(() => removeSync(b, opts, { isolate: true })), 'ok', name);
173
+ assert.strictEqual(fs.existsSync(b), false, `${name}: not removed`);
174
+ }
175
+ assert.strictEqual(outcome(() => fs.rmSync(path.join(arena, 'nope'), null)), outcome(() => removeSync(path.join(arena, 'nope'), null, { isolate: true })), 'null options');
176
+ });
177
+
178
+ // 1.36.305 confirmation round: Node reads each option once (its copy); checking the options as given and then copying
179
+ // them read a getter twice, so a getter that changes between reads got another outcome than fs.rmSync's.
180
+ test('each option is read once, as by fs.rmSync (getters included)', () => {
181
+ const { removeSync } = safe();
182
+ for (const [name, make, build] of [
183
+ ['a getter that fails on its second read', reads => ({ recursive: true, get extra() { if (++reads.n === 2) throw new Error('read twice'); return 1; } }), d => { const t = path.join(d, 'empty'); fs.mkdirSync(t); return t; }],
184
+ ['a recursive getter true only on its first read', reads => ({ get recursive() { return ++reads.n === 1; } }), d => { const t = path.join(d, 'full'); fs.mkdirSync(path.join(t, 's'), { recursive: true }); fs.writeFileSync(path.join(t, 's', 'f'), 'f'); return t; }],
185
+ ]) {
186
+ const d = fresh('getters-' + HAN);
187
+ const a = build(fs.mkdtempSync(path.join(d, 'a-'))), b = build(fs.mkdtempSync(path.join(d, 'b-')));
188
+ const ra = { n: 0 }, rb = { n: 0 };
189
+ const want = outcome(() => fs.rmSync(a, make(ra)));
190
+ const got = outcome(() => removeSync(b, make(rb), { isolate: true }));
191
+ assert.strictEqual(got, want, name);
192
+ assert.strictEqual(fs.existsSync(b), fs.existsSync(a), `${name}: what is left differs`);
193
+ assert.strictEqual(rb.n, ra.n, `${name}: read ${rb.n} times, fs.rmSync ${ra.n}`);
194
+ }
195
+ const arr = Object.assign([], { recursive: true });
196
+ assert.strictEqual(outcome(() => removeSync(path.join(arena, 'nope'), arr, { isolate: true })), outcome(() => fs.rmSync(path.join(arena, 'nope'), arr)), 'an array as options');
197
+ });
198
+
199
+ // 1.36.305 review round 4: the guard saw the path string where fs.rmSync's guard sees the URL it was given; a search
200
+ // part ('?x') makes String(url) end in something other than '.lock', so --dry-run refuses fs.rmSync(url), while the
201
+ // path the URL names ends in '.lock' and was exempt.
202
+ test('a write guard sees a URL target as it sees it for fs.rmSync', () => {
203
+ const io = require(path.join(PKG, 'lib', 'io.js'));
204
+ const { pathToFileURL } = require('url');
205
+ const d = fresh('dry-run-url');
206
+ const make = name => { const t = path.join(d, name); fs.mkdirSync(t); fs.writeFileSync(path.join(t, 'SKILL.md'), 's'); const u = pathToFileURL(t); u.search = '?x'; return [t, u]; };
207
+ const [isolatedPath, isolated] = make('isolated.lock'), [, control] = make('control.lock');
208
+ io.setDryRunGuard(true);
209
+ try {
210
+ assert.throws(() => fs.rmSync(control, { recursive: true, force: true }), e => e.code === 'E_DRY_RUN_WRITE', 'control: fs.rmSync is refused');
211
+ assert.throws(() => safe().removeSync(isolated, { recursive: true, force: true }, { isolate: true }), e => e.code === 'E_DRY_RUN_WRITE');
212
+ } finally { io.setDryRunGuard(false); }
213
+ assert.strictEqual(fs.existsSync(path.join(isolatedPath, 'SKILL.md')), true, 'removed under --dry-run');
214
+ });
215
+
216
+ test('the child runs without NODE_OPTIONS (a preload of this process does not run there)', () => {
217
+ const d = fresh('node-options');
218
+ const marker = path.join(d, 'preload-ran.txt'), preload = path.join(d, 'preload.js');
219
+ fs.writeFileSync(preload, `require('fs').writeFileSync(${JSON.stringify(marker)}, 'ran');\n`);
220
+ const t = path.join(d, 'full'); fs.mkdirSync(path.join(t, 's'), { recursive: true }); fs.writeFileSync(path.join(t, 's', 'f'), 'f');
221
+ const saved = process.env.NODE_OPTIONS;
222
+ process.env.NODE_OPTIONS = `--require ${JSON.stringify(preload)}`;
223
+ try { safe().removeSync(t, { recursive: true }, { isolate: true }); }
224
+ finally { if (saved === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = saved; }
225
+ assert.strictEqual(fs.existsSync(t), false);
226
+ assert.strictEqual(fs.existsSync(marker), false, 'the preload ran in the child');
227
+ });
228
+
229
+ test('the child removes a relative path from this working directory', () => {
230
+ const back = process.cwd();
231
+ const sandbox = fresh('relative-' + HAN);
232
+ fs.mkdirSync(path.join(sandbox, 'sub', 'deeper'), { recursive: true });
233
+ fs.writeFileSync(path.join(sandbox, 'sub', 'deeper', 'x.txt'), 'x');
234
+ fs.writeFileSync(path.join(sandbox, 'sentinel.txt'), 's');
235
+ process.chdir(sandbox);
236
+ try { safe().removeSync('sub', { recursive: true }, { isolate: true }); }
237
+ finally { process.chdir(back); }
238
+ assert.strictEqual(fs.existsSync(path.join(sandbox, 'sub')), false);
239
+ assert.strictEqual(fs.readFileSync(path.join(sandbox, 'sentinel.txt'), 'utf8'), 's');
240
+ });
241
+
242
+ // 1.36.305 review round 2: an empty path resolved to the working directory, whose contents were removed; Node checks
243
+ // the path as given (lstat ''), so fs.rmSync('') is ENOENT and removes nothing.
244
+ test('an empty path removes nothing, as fs.rmSync (not the working directory)', () => {
245
+ const { removeSync } = safe();
246
+ const back = process.cwd();
247
+ const sandbox = fresh('empty-path');
248
+ fs.writeFileSync(path.join(sandbox, 'sentinel.txt'), 's');
249
+ process.chdir(sandbox);
250
+ try {
251
+ for (const [target, opts] of [['', { recursive: true }], ['', { recursive: true, force: true }], [Buffer.alloc(0), { recursive: true, force: true }]]) {
252
+ const want = outcome(() => fs.rmSync(target, opts));
253
+ const got = outcome(() => removeSync(target, opts, { isolate: true }));
254
+ assert.strictEqual(got, want, `${JSON.stringify(String(target))} ${JSON.stringify(opts)}`);
255
+ assert.strictEqual(fs.readFileSync(path.join(sandbox, 'sentinel.txt'), 'utf8'), 's', 'the working directory lost a file');
256
+ }
257
+ } finally { process.chdir(back); }
258
+ });
259
+
260
+ // 1.36.305 review rounds 2 and 3: a write guard (--dry-run, the runtime write boundary) checks the path each fs call
261
+ // is given. fs.rmSync is one call on the target as given; the isolated removal's first change is on the target as
262
+ // given, through the guarded fs, and the rest happens where no guard runs (fs.rmSync in the child), as inside
263
+ // fs.rmSync.
264
+ test('a write guard sees an isolated removal as it sees fs.rmSync: refused at the target, nothing inside removed', () => {
265
+ const io = require(path.join(PKG, 'lib', 'io.js'));
266
+ const t = path.join(fresh('dry-run'), 'skill');
267
+ fs.mkdirSync(t);
268
+ fs.writeFileSync(path.join(t, 'Pipfile.lock'), 'l');
269
+ fs.writeFileSync(path.join(t, 'SKILL.md'), 's');
270
+ const before = fs.readdirSync(t).sort();
271
+ io.setDryRunGuard(true);
272
+ try {
273
+ assert.throws(() => safe().removeSync(t, { recursive: true, force: true }, { isolate: true }), e => e.code === 'E_DRY_RUN_WRITE');
274
+ assert.throws(() => fs.rmSync(t, { recursive: true, force: true }), e => e.code === 'E_DRY_RUN_WRITE', 'control: fs.rmSync is refused too');
275
+ } finally { io.setDryRunGuard(false); }
276
+ assert.deepStrictEqual(fs.readdirSync(t).sort(), before, 'an entry was removed under --dry-run');
277
+ });
278
+
279
+ test('a write guard that allows the target lets an isolated removal remove what is inside, as fs.rmSync', () => {
280
+ const io = require(path.join(PKG, 'lib', 'io.js'));
281
+ const d = fresh('dry-run-lock');
282
+ const make = name => { const t = path.join(d, name); fs.mkdirSync(path.join(t, 'sub'), { recursive: true }); fs.writeFileSync(path.join(t, 'owner.json'), '{}'); fs.writeFileSync(path.join(t, 'sub', 'x.txt'), 'x'); return t; };
283
+ const isolated = make('isolated.lock'), control = make('control.lock');
284
+ io.setDryRunGuard(true);
285
+ let controlCode = null;
286
+ try {
287
+ // Where fs.rmSync is written in JavaScript (Node 22 and older, as on CI's Node 20 jobs) it removes the entries
288
+ // through fs's own functions, which the guard wraps, so there it refuses inside. The removal is isolated only on
289
+ // Node versions whose fs.rmSync is native and asks the guard once; the comparison applies where the control
290
+ // shows that (1.36.305 CI: this case failed on Node 20 when it assumed it).
291
+ try { fs.rmSync(control, { recursive: true, force: true }); } catch (e) { controlCode = e.code; }
292
+ if (controlCode === null) safe().removeSync(isolated, { recursive: true, force: true }, { isolate: true });
293
+ } finally { io.setDryRunGuard(false); }
294
+ if (controlCode !== null) {
295
+ assert.strictEqual(controlCode, 'E_DRY_RUN_WRITE', 'control: fs.rmSync failed for another reason');
296
+ console.log(` skipped: fs.rmSync on Node ${process.version} asks the guard for each entry (the removal is not isolated on such a Node)`);
297
+ return;
298
+ }
299
+ assert.strictEqual(fs.existsSync(control), false, 'control: fs.rmSync removed the exempt folder');
300
+ assert.strictEqual(fs.existsSync(isolated), false, 'the isolated removal did not remove what fs.rmSync removes');
301
+ });
302
+
303
+ // 1.36.305 review round 3: the guard saw the resolved path; for 'x.lock\' that is 'x.lock', which --dry-run exempts,
304
+ // where fs.rmSync('x.lock\') is refused (the spelling given does not end in '.lock').
305
+ test('a write guard sees the target spelled as given (a trailing separator)', () => {
306
+ const io = require(path.join(PKG, 'lib', 'io.js'));
307
+ const d = fresh('dry-run-tail');
308
+ const make = name => { const t = path.join(d, name); fs.mkdirSync(t); fs.writeFileSync(path.join(t, 'SKILL.md'), 's'); return t + path.sep; };
309
+ const isolated = make('isolated.lock'), control = make('control.lock');
310
+ io.setDryRunGuard(true);
311
+ try {
312
+ assert.throws(() => fs.rmSync(control, { recursive: true, force: true }), e => e.code === 'E_DRY_RUN_WRITE', 'control: fs.rmSync is refused');
313
+ assert.throws(() => safe().removeSync(isolated, { recursive: true, force: true }, { isolate: true }), e => e.code === 'E_DRY_RUN_WRITE');
314
+ } finally { io.setDryRunGuard(false); }
315
+ assert.strictEqual(fs.existsSync(path.join(isolated, 'SKILL.md')), true, 'removed under --dry-run');
316
+ });
317
+
318
+ test('isolated removal: a nested tree with a read-only file and non-ASCII names is removed', () => {
319
+ const root = fresh('tree');
320
+ const t = path.join(root, 'tree-' + HAN + EMOJI);
321
+ fs.mkdirSync(path.join(t, 'a', 'b', 'c'), { recursive: true });
322
+ fs.writeFileSync(path.join(t, 'a', 'one.txt'), '1');
323
+ fs.writeFileSync(path.join(t, 'a', 'b', 'c', 'two.txt'), '2');
324
+ fs.writeFileSync(path.join(t, 'ro.txt'), 'r'); fs.chmodSync(path.join(t, 'ro.txt'), 0o444);
325
+ safe().removeSync(t, { recursive: true, force: true }, { isolate: true });
326
+ assert.strictEqual(fs.existsSync(t), false);
327
+ assert.deepStrictEqual(fs.readdirSync(root), []);
328
+ });
329
+
330
+ test('isolated removal: a link inside is removed as a link, and its target keeps its contents', () => {
331
+ const root = fresh('links');
332
+ const target = path.join(root, 'target'); fs.mkdirSync(target); fs.writeFileSync(path.join(target, 'sentinel.txt'), 's');
333
+ const tfile = path.join(root, 'tfile.txt'); fs.writeFileSync(tfile, 't');
334
+ const t = path.join(root, 'tree'); fs.mkdirSync(t);
335
+ const made = [];
336
+ try { fs.symlinkSync(target, path.join(t, 'junction'), 'junction'); made.push('junction'); } catch (e) { if (isWin) throw e; }
337
+ try { fs.symlinkSync(target, path.join(t, 'dirlink'), 'dir'); made.push('dir symlink'); } catch (e) { if (!isWin) throw e; }
338
+ try { fs.symlinkSync(tfile, path.join(t, 'filelink'), 'file'); made.push('file symlink'); } catch (e) { if (!isWin) throw e; }
339
+ assert.ok(made.length > 0, 'no link could be made');
340
+ console.log(` links made here: ${made.join(', ')}`);
341
+ safe().removeSync(t, { recursive: true, force: true }, { isolate: true });
342
+ assert.strictEqual(fs.existsSync(t), false);
343
+ assert.strictEqual(fs.readFileSync(path.join(target, 'sentinel.txt'), 'utf8'), 's', 'the removal went into a link');
344
+ assert.strictEqual(fs.readFileSync(tfile, 'utf8'), 't', 'a file link target was removed');
345
+ });
346
+
347
+ test('isolated removal: a target that is itself a link is removed as a link', () => {
348
+ const root = fresh('link-target');
349
+ const target = path.join(root, 'target'); fs.mkdirSync(target); fs.writeFileSync(path.join(target, 'sentinel.txt'), 's');
350
+ const link = path.join(root, 'link');
351
+ fs.symlinkSync(target, link, isWin ? 'junction' : 'dir');
352
+ safe().removeSync(link, { recursive: true, force: true }, { isolate: true });
353
+ assert.throws(() => fs.lstatSync(link), e => e.code === 'ENOENT');
354
+ assert.strictEqual(fs.readFileSync(path.join(target, 'sentinel.txt'), 'utf8'), 's');
355
+ });
356
+
357
+ test('isolated removal: force and recursive work as in fs.rmSync (same codes on the same inputs)', () => {
358
+ const { removeSync } = safe();
359
+ const full = fn => { try { fn(); return 'ok'; } catch (e) { return `${e.code}|${e.syscall}|${e.errno}|${e.path}`; } };
360
+ for (const [name, setup, opts] of [
361
+ ['missing, force', d => path.join(d, 'nope'), { force: true }],
362
+ ['missing, no force', d => path.join(d, 'nope'), {}],
363
+ ['missing, recursive', d => path.join(d, 'nope'), { recursive: true }],
364
+ ['directory, no recursive', d => { const x = path.join(d, 'dir'); fs.mkdirSync(x); fs.writeFileSync(path.join(x, 'f'), 'f'); return x; }, { force: true }],
365
+ ['file, recursive', d => { const x = path.join(d, 'file'); fs.writeFileSync(x, 'f'); return x; }, { recursive: true }],
366
+ ['file, no options', d => { const x = path.join(d, 'file'); fs.writeFileSync(x, 'f'); return x; }, undefined],
367
+ ['empty directory, recursive', d => { const x = path.join(d, 'empty'); fs.mkdirSync(x); return x; }, { recursive: true }],
368
+ ['full directory, recursive', d => { const x = path.join(d, 'full'); fs.mkdirSync(path.join(x, 's'), { recursive: true }); fs.writeFileSync(path.join(x, 's', 'f'), 'f'); return x; }, { recursive: true }],
369
+ ]) {
370
+ const a = fresh('parity-a'), b = fresh('parity-b');
371
+ const pa = setup(a), pb = setup(b);
372
+ const want = full(() => fs.rmSync(pa, opts)).split(a).join('<d>');
373
+ const got = full(() => removeSync(pb, opts, { isolate: true })).split(b).join('<d>');
374
+ assert.strictEqual(got, want, name);
375
+ assert.strictEqual(fs.existsSync(pb), fs.existsSync(pa), `${name}: what is left differs`);
376
+ }
377
+ const d = fresh('eisdir'); const x = path.join(d, 'dir'); fs.mkdirSync(x);
378
+ assert.throws(() => removeSync(x, {}, { isolate: true }), e => e.code === 'ERR_FS_EISDIR' && e.path === x && /is a directory/.test(e.message));
379
+ });
380
+
381
+ // (The options check calls fs.rmSync on a path that does not exist, through the copy captured at load, not fsImpl.)
382
+ test('in this process, an isolated removal never calls fs.rmSync on the target', () => {
383
+ const d = fresh('no-rmsync'); fs.mkdirSync(path.join(d, 't', 'sub'), { recursive: true }); fs.writeFileSync(path.join(d, 't', 'sub', 'f'), 'f');
384
+ fs.writeFileSync(path.join(d, 'file.txt'), 'f');
385
+ const fsImpl = new Proxy(fs, { get: (target, name) => name === 'rmSync' ? () => { throw new Error('rmSync was called'); } : target[name] });
386
+ safe().removeSync(path.join(d, 't'), { recursive: true, force: true }, { fsImpl, isolate: true });
387
+ safe().removeSync(path.join(d, 'file.txt'), { force: true }, { fsImpl, isolate: true });
388
+ assert.deepStrictEqual(fs.readdirSync(d), []);
389
+ });
390
+
391
+ test('isolated removal: a failure reports its code and the target as given; a removal process that ended is an error', () => {
392
+ const d = fresh('fail-path-' + HAN);
393
+ const t = path.join(d, 't'), sub = path.join(t, 'sub');
394
+ fs.mkdirSync(sub, { recursive: true });
395
+ if (isWin) {
396
+ // Whether fs.rmSync ends the child here depends on the whole path (this arena may sit below a temp directory
397
+ // whose name the code page cannot hold); either way the error names the target as given.
398
+ const child = hold(sub, 6000);
399
+ try {
400
+ assert.throws(() => safe().removeSync(t, { recursive: true, force: true }, { isolate: true }),
401
+ e => ['EBUSY', 'EPERM'].includes(e.code) && e.path === t);
402
+ } finally { child.kill(); }
403
+ // A held file as the target fails at unlink, with its path.
404
+ const locked = path.join(d, 'locked.txt');
405
+ fs.writeFileSync(locked, 'x');
406
+ const locker = lockFile(locked, 4000);
407
+ try {
408
+ assert.throws(() => safe().removeSync(locked, { force: true }, { isolate: true }), e => ['EBUSY', 'EPERM'].includes(e.code) && e.path === locked);
409
+ } finally { locker.kill(); }
410
+ if (bugHere) {
411
+ // Outside the code page fs.rmSync ends its process: here only the child's.
412
+ const t3 = path.join(fresh('ended-' + EMOJI), 't'), sub3 = path.join(t3, 'sub');
413
+ fs.mkdirSync(sub3, { recursive: true });
414
+ const child3 = hold(sub3, 6000);
415
+ try {
416
+ assert.throws(() => safe().removeSync(t3, { recursive: true, force: true }, { isolate: true }),
417
+ e => e.code === 'EPERM' && e.path === t3 && e.removalProcessEnded === true && /EPERM/.test(e.message));
418
+ } finally { child3.kill(); }
419
+ } else note();
420
+ } else {
421
+ if (process.getuid && process.getuid() === 0) { console.log(' skipped: running as root, permissions do not fail'); return; }
422
+ fs.writeFileSync(path.join(sub, 'f'), 'f'); fs.chmodSync(sub, 0o555);
423
+ try {
424
+ assert.throws(() => safe().removeSync(t, { recursive: true, force: true }, { isolate: true }),
425
+ e => ['EACCES', 'EPERM'].includes(e.code) && e.path === t);
426
+ } finally { fs.chmodSync(sub, 0o755); }
427
+ }
428
+ });
429
+
430
+ test('isolated removal of a file: maxRetries waits out a hold that ends, and without it the hold fails at once', () => {
431
+ if (!isWin) { console.log(' skipped: a held file does not block removal here'); return; }
432
+ const { removeSync } = safe();
433
+ const a = path.join(fresh('retry'), 'a.txt'); fs.writeFileSync(a, 'a');
434
+ const lockA = lockFile(a, 800);
435
+ removeSync(a, { force: true, maxRetries: 20, retryDelay: 100 }, { isolate: true });
436
+ assert.strictEqual(fs.existsSync(a), false);
437
+ lockA.kill();
438
+ const b = path.join(fresh('no-retry'), 'b.txt'); fs.writeFileSync(b, 'b');
439
+ const lockB = lockFile(b, 4000);
440
+ try { assert.throws(() => removeSync(b, { force: true }, { isolate: true }), e => ['EBUSY', 'EPERM'].includes(e.code)); }
441
+ finally { lockB.kill(); }
442
+ });
443
+
444
+ test('the removal sites use removeSync: lib, the three bin commands, the recovery cleanup and removeTree', () => {
445
+ for (const f of fs.readdirSync(path.join(PKG, 'lib')).filter(n => n.endsWith('.js') && n !== 'safe-remove.js')) {
446
+ const src = fs.readFileSync(path.join(PKG, 'lib', f), 'utf8');
447
+ const calls = src.split('\n').filter(l => /\.rmSync\(/.test(l));
448
+ assert.deepStrictEqual(calls, [], `lib/${f} still calls rmSync`);
449
+ }
450
+ const bin = fs.readFileSync(CLI, 'utf8');
451
+ for (const fn of ['skillRemove', '_pruneArchives', '_cleanupNpmTempDir']) {
452
+ const start = bin.indexOf(`function ${fn}(`);
453
+ assert.ok(start >= 0, fn);
454
+ const body = bin.slice(start, bin.indexOf('\nfunction ', start + 10));
455
+ assert.ok(!/fs\.rmSync\(/.test(body) && /_removeSync\(/.test(body), `${fn} does not use _removeSync`);
456
+ }
457
+ const rw = fs.readFileSync(path.join(PKG, 'lib', 'runtime-writes.js'), 'utf8');
458
+ for (const name of ['lstatSync', 'unlinkSync', 'rmdirSync']) assert.ok(new RegExp(`\\n ${name}: fs\\.${name}\\.bind\\(fs\\),`).test(rw), `recoveryFs does not capture ${name}`);
459
+ assert.ok(/removeSync\(dir, \{ recursive: true, force: true \}, \{ fsImpl: recoveryFs \}\)/.test(rw), 'the recovery cleanup does not pass the captured primitives');
460
+ const tc = fs.readFileSync(path.join(__dirname, 'temp-cleanup.js'), 'utf8');
461
+ const body = tc.slice(tc.indexOf('function removeTree('), tc.indexOf('\nfunction ownTree('));
462
+ assert.ok(!/fs\.rmSync\(/.test(body) && /removeSync\(/.test(body), 'removeTree does not use removeSync');
463
+ });
464
+
465
+ if (isWin) {
466
+ test('leerness skill remove with a held folder under a path with an emoji ends with an error, not a crash', () => {
467
+ note();
468
+ const proj = path.join(fresh('skill-' + EMOJI), 'proj');
469
+ fs.mkdirSync(proj);
470
+ const init = runNode([CLI, 'init', proj], { cwd: proj });
471
+ assert.strictEqual(init.status, 0, init.stdout + init.stderr);
472
+ const skill = path.join(proj, '.leerness', 'skills', 'held-skill');
473
+ fs.mkdirSync(path.join(skill, 'sub'), { recursive: true });
474
+ fs.writeFileSync(path.join(skill, 'SKILL.md'), '# held\n');
475
+ const child = hold(path.join(skill, 'sub'), 6000);
476
+ let r;
477
+ try { r = runNode([CLI, 'skill', 'remove', 'held-skill', '--path', proj], { cwd: proj }); }
478
+ finally { child.kill(); }
479
+ assert.notStrictEqual(r.status, CRASH, 'the process was ended (0xC0000409)');
480
+ assert.strictEqual(r.signal, null);
481
+ assert.ok(r.status === 0 || r.status === 1, `exit ${r.status}: ${r.stdout}${r.stderr}`);
482
+ if (r.status === 1) assert.ok(/EBUSY|EPERM/.test(r.stdout + r.stderr), r.stdout + r.stderr);
483
+ });
484
+
485
+ test('_cleanupNpmTempDir reports a held temp folder under an emoji path as not cleaned, not a crash', () => {
486
+ note();
487
+ const tmp = path.join(fresh('npm-' + EMOJI), 'leerness-npm-lifecycle-x');
488
+ fs.mkdirSync(path.join(tmp, 'sub'), { recursive: true });
489
+ fs.writeFileSync(path.join(tmp, '.npmrc'), 'token-shaped-but-fake');
490
+ const child = hold(path.join(tmp, 'sub'), 6000);
491
+ let r;
492
+ try {
493
+ r = runNode(['-e', `const L = require(${JSON.stringify(CLI)}); const ok = L._cleanupNpmTempDir(${JSON.stringify(tmp)}, ${JSON.stringify(path.join(tmp, '.npmrc'))}); console.log('CLEANED ' + ok);`]);
494
+ } finally { child.kill(); }
495
+ assert.notStrictEqual(r.status, CRASH, 'the process was ended (0xC0000409)');
496
+ assert.strictEqual(r.status, 0, r.stdout + r.stderr);
497
+ assert.ok(/CLEANED false/.test(r.stdout), r.stdout);
498
+ // fs.rmSync in the child may remove some entries before the held folder fails it; the credential file is emptied
499
+ // first, so it is gone or empty.
500
+ const npmrc = path.join(tmp, '.npmrc');
501
+ assert.ok(!fs.existsSync(npmrc) || fs.readFileSync(npmrc, 'utf8') === '', 'the credential file is left with its contents');
502
+ });
503
+
504
+ test('scripts/temp-cleanup.js removeTree on a held tree under an emoji path fails with the code, not a crash, and waits a short hold out', () => {
505
+ note();
506
+ const t = path.join(fresh('tree-' + EMOJI), 't');
507
+ fs.mkdirSync(path.join(t, 'sub'), { recursive: true });
508
+ const child = hold(path.join(t, 'sub'), 6000);
509
+ let r;
510
+ try {
511
+ r = runNode(['-e', `const T = require(${JSON.stringify(path.join(__dirname, 'temp-cleanup.js'))}); try { T.removeTree(${JSON.stringify(t)}, { recursive: true, force: true }, { timeoutMs: 400 }); console.log('REMOVED'); } catch (e) { console.log('CODE ' + e.code); }`]);
512
+ } finally { child.kill(); }
513
+ assert.notStrictEqual(r.status, CRASH, 'the process was ended (0xC0000409)');
514
+ assert.strictEqual(r.status, 0, r.stdout + r.stderr);
515
+ assert.ok(/CODE (EBUSY|EPERM)/.test(r.stdout), r.stdout);
516
+ const t2 = path.join(fresh('tree2-' + EMOJI), 't');
517
+ fs.mkdirSync(path.join(t2, 'sub'), { recursive: true });
518
+ hold(path.join(t2, 'sub'), 1000);
519
+ require('./temp-cleanup').removeTree(t2, { recursive: true, force: true }, { timeoutMs: 15000 });
520
+ assert.strictEqual(fs.existsSync(t2), false, 'removeTree did not wait out a hold of one second');
521
+ });
522
+ }
523
+
524
+ test('env summary --json says whether this Node has the bug, and the advisory is English in English mode', () => {
525
+ const L = require(CLI);
526
+ assert.strictEqual(typeof L._rmSyncAdvisory, 'function', '_rmSyncAdvisory is not exported');
527
+ const en = L._rmSyncAdvisory('en', '26.3.0', 'win32'), ko = L._rmSyncAdvisory('ko', '26.3.0', 'win32');
528
+ assert.ok(en && /26\.9\.0/.test(en) && /24\.22\.0/.test(en) && /26\.3\.0/.test(en), en);
529
+ assert.ok(!/[ㄱ-ㆎ가-힣]/.test(en), 'Korean text in the English advisory');
530
+ assert.ok(ko && /[가-힣]/.test(ko) && /26\.9\.0/.test(ko), ko);
531
+ assert.strictEqual(L._rmSyncAdvisory('en', '26.9.0', 'win32'), null);
532
+ assert.strictEqual(L._rmSyncAdvisory('en', '26.3.0', 'linux'), null);
533
+ const r = runNode([CLI, 'env', 'summary', '--json'], { cwd: arena });
534
+ assert.strictEqual(r.status, 0, r.stderr);
535
+ assert.strictEqual(JSON.parse(r.stdout).node.rmSyncPathBug, bugHere);
536
+ });
537
+
538
+ // 1.36.305 review round 1: selftest drops LEERNESS_LANG while it runs (a user's setting must not change the cases) and
539
+ // chose the advisory's language after that, so English mode printed it in Korean. The advisory is printed before
540
+ // the cases run (about four minutes on Windows), so this reads it from the output file and ends the run.
541
+ test('selftest prints the advisory in the language chosen before it controls its environment', () => {
542
+ if (!bugHere) { console.log(` skipped: Node ${process.version} on ${process.platform} prints no advisory`); return; }
543
+ for (const [lang, korean] of [['en', false], ['ko', true]]) {
544
+ const dir = fresh('selftest-' + lang), outFile = path.join(dir, 'out.txt'), temp = path.join(dir, 'temp');
545
+ fs.mkdirSync(temp);
546
+ const fd = fs.openSync(outFile, 'w');
547
+ // The run is ended mid-case, so the folders its cases make go to a temp directory inside this probe's arena.
548
+ const env = { ...process.env, LEERNESS_LANG: lang, TEMP: temp, TMP: temp, TMPDIR: temp };
549
+ const child = cp.spawn(process.execPath, [CLI, 'selftest'], { cwd: arena, env, stdio: ['ignore', fd, fd], windowsHide: true });
550
+ children.push(child);
551
+ let line = null;
552
+ for (const deadline = Date.now() + 60000; !line && Date.now() < deadline; sleep(200)) {
553
+ line = fs.readFileSync(outFile, 'utf8').split('\n').find(l => /⚠ .*26\.9\.0/.test(l)) || null;
554
+ }
555
+ child.kill(); fs.closeSync(fd);
556
+ assert.ok(line, `${lang}: no advisory within 60 s`);
557
+ assert.strictEqual(/[가-힣]/.test(line), korean, `${lang}: ${line}`);
558
+ }
559
+ });
560
+
561
+ test('selftest has a passing case for the rmSync version check', () => {
562
+ const L = require(CLI);
563
+ const c = L._selfTestCases().filter(x => /T-0365/.test(x.name));
564
+ assert.strictEqual(c.length, 1, 'no T-0365 selftest case');
565
+ assert.strictEqual(c[0].run(), true);
566
+ });
567
+ } finally {
568
+ for (const c of children) { try { c.kill(); } catch {} }
569
+ sleep(isWin ? 700 : 0);
570
+ try { require('./temp-cleanup').removeTree(arena, { recursive: true, force: true }); } catch (e) { console.error(`arena left: ${arena} (${e.code})`); }
571
+ }
572
+ console.log(`safe remove: ${passed}/${passed + failed} passed`);
573
+ if (failed) process.exitCode = 1;
@@ -35,7 +35,10 @@ try {
35
35
  try {
36
36
  // What the probes used before: on Windows this fails at once despite maxRetries (reported, not asserted).
37
37
  let before = 'removed';
38
- try { fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch (error) { before = error.code; }
38
+ // T-0365: on a Windows Node whose failed fs.rmSync ends the process, a temp path with a character outside the
39
+ // code page would end this probe here; the report is skipped on any non-ASCII temp path there.
40
+ if (require('../lib/safe-remove').rmSyncPathBug() && /[^\x00-\x7f]/.test(root)) before = 'not run (T-0365)';
41
+ else try { fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch (error) { before = error.code; }
39
42
  console.log(` rmSync with maxRetries on a held tree (${process.platform}, ${process.version}): ${before}`);
40
43
  if (fs.existsSync(root)) {
41
44
  const started = Date.now();
@@ -12,9 +12,12 @@ const path = require('path');
12
12
  const RETRY_CODES = new Set(['EBUSY', 'EMFILE', 'ENFILE', 'ENOTEMPTY', 'EPERM']);
13
13
 
14
14
  function removeTree(target, rmOptions = {}, { timeoutMs = 5000, intervalMs = 100 } = {}) {
15
+ // T-0365: removeSync is fs.rmSync except on the Windows Node versions whose failed fs.rmSync ends the process. It is
16
+ // loaded at first use, so requiring this file loads nothing from lib/ before trackTemp runs.
17
+ const { removeSync } = require('../lib/safe-remove');
15
18
  const deadline = Date.now() + timeoutMs;
16
19
  for (;;) {
17
- try { return fs.rmSync(target, rmOptions); }
20
+ try { return removeSync(target, rmOptions); }
18
21
  catch (error) {
19
22
  if (!RETRY_CODES.has(error && error.code) || Date.now() >= deadline) throw error;
20
23
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, intervalMs);
@@ -164,21 +164,25 @@ fixture('nested.test.cjs', "require('node:test')('beta',async t=>{await t.test('
164
164
  await test('a briefly held selector fixture is waited out, not reported as an unavailable selector', async () => {
165
165
  // Windows can refuse the fixture delete while the last child test process exits, and Node 26
166
166
  // rmSync does not retry EPERM. Two refusals must not turn into runtime-selector-unavailable.
167
- const original = fs.rmSync;
167
+ // T-0365: on a Windows Node whose failed fs.rmSync ends the process, lib/safe-remove.js removes the fixture
168
+ // with unlink and rmdir, so the refusals go to the fixture folder's rmdirSync as well.
169
+ const original = fs.rmSync, originalRmdir = fs.rmdirSync;
168
170
  let refused = 0;
169
- fs.rmSync = function (target, ...rest) {
171
+ const refuse = (impl, self, target, rest) => {
170
172
  if (refused < 2 && path.basename(String(target)).startsWith('leerness-node-selector-')) {
171
173
  refused++;
172
174
  throw Object.assign(new Error('injected hold'), { code: 'EPERM' });
173
175
  }
174
- return original.call(this, target, ...rest);
176
+ return impl.call(self, target, ...rest);
175
177
  };
178
+ fs.rmSync = function (target, ...rest) { return refuse(original, this, target, rest); };
179
+ fs.rmdirSync = function (target, ...rest) { return refuse(originalRmdir, this, target, rest); };
176
180
  try {
177
181
  const result = await runNodeSelection(request('cases.test.cjs', 'beta'), arena);
178
182
  assert.equal(refused, 2, `fixture cleanup stopped after ${refused} refusal(s) instead of waiting`);
179
183
  assert.equal(result.fixtureSelectionVerified, true, JSON.stringify(result));
180
184
  assert.equal(result.selectedTestPassed, true);
181
- } finally { fs.rmSync = original; }
185
+ } finally { fs.rmSync = original; fs.rmdirSync = originalRmdir; }
182
186
  });
183
187
  } finally {
184
188
  const actual = fs.realpathSync(arena);