leerness 1.36.303 → 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 +34 -0
- package/CONTRIBUTING.md +2 -0
- package/README.md +4 -4
- package/bin/leerness.js +45 -19
- package/lib/claims-baseline.js +2 -1
- package/lib/io.js +6 -5
- package/lib/machine-cache.js +2 -1
- package/lib/memory-output.js +2 -1
- package/lib/migrate.js +2 -1
- package/lib/preview-serve.js +3 -2
- package/lib/runtime-writes.js +7 -1
- package/lib/safe-remove.js +132 -0
- package/lib/test-node-runner.js +2 -1
- package/lib/workspace-dir.js +3 -2
- package/package.json +2 -2
- package/scripts/agents-bench-output-probe.js +1 -0
- package/scripts/api-skill-match-probe.js +1 -0
- package/scripts/audit-env-text-probe.js +1 -0
- package/scripts/audit-readme-text-probe.js +1 -0
- package/scripts/audit-text-probe.js +1 -0
- package/scripts/authoring-language-probe.js +1 -0
- package/scripts/auto-update-hooks-probe.js +1 -0
- package/scripts/brief-text-probe.js +1 -0
- package/scripts/bugfix-areas-probe.js +1 -0
- package/scripts/ci-shard-probe.js +99 -0
- package/scripts/ci-shard.js +35 -3
- package/scripts/claim-runtime-cache-probe.js +1 -0
- package/scripts/claim-sweep-git-probe.js +1 -0
- package/scripts/claims-baseline-concurrency-probe.js +1 -0
- package/scripts/claims-baseline-probe.js +1 -0
- package/scripts/command-flags-probe.js +1 -0
- package/scripts/context-current-probe.js +1 -0
- package/scripts/context-reading-probe.js +1 -0
- package/scripts/copy-tree-probe.js +1 -0
- package/scripts/crud-check-probe.js +1 -0
- package/scripts/dead-flags-probe.js +1 -0
- package/scripts/done-status-probe.js +1 -0
- package/scripts/dry-run-refusal-probe.js +1 -0
- package/scripts/e2e-child-diagnostics-probe.js +1 -0
- package/scripts/e2e-command-surface.js +1 -0
- package/scripts/e2e-concurrency.js +1 -0
- package/scripts/e2e-core.js +1 -0
- package/scripts/e2e-lived-in-harness.js +1 -0
- package/scripts/e2e-repo-guard.js +1 -0
- package/scripts/e2e-temp-scope-probe.js +1 -0
- package/scripts/e2e.js +5 -0
- package/scripts/encoding-diagnostics-probe.js +1 -0
- package/scripts/encoding-selftest-probe.js +1 -0
- package/scripts/env-snapshot-text-probe.js +1 -0
- package/scripts/env-sync-text-probe.js +1 -0
- package/scripts/execution-ledger-sync-probe.js +1 -0
- package/scripts/false-claim-probe.js +1 -0
- package/scripts/feature-graph-text-probe.js +1 -0
- package/scripts/file-lease-probe.js +1 -0
- package/scripts/fix-evidence-language-probe.js +1 -0
- package/scripts/free-text-args-probe.js +1 -0
- package/scripts/generation-text-probe.js +1 -0
- package/scripts/git-resolution-cache-probe.js +1 -0
- package/scripts/github-login-probe.js +1 -0
- package/scripts/handoff-history-text-probe.js +1 -0
- package/scripts/handoff-i18n-probe.js +1 -0
- package/scripts/handoff-preservation-probe.js +1 -0
- package/scripts/handoff-readonly-probe.js +1 -0
- package/scripts/handoff-text-probe.js +1 -0
- package/scripts/harness-text-probe.js +1 -0
- package/scripts/history-retention-probe.js +1 -0
- package/scripts/history-retention-write-probe.js +1 -0
- package/scripts/i18n-next-cluster-probe.js +1 -0
- package/scripts/i18n-priority-surface-probe.js +1 -0
- package/scripts/install-text-probe.js +1 -0
- package/scripts/installed-cleanroom-probe.js +1 -0
- package/scripts/installed-handoff-lifecycle-probe.js +1 -0
- package/scripts/lazy-auto-track-probe.js +1 -0
- package/scripts/legacy-runtime-negative-probe.js +1 -0
- package/scripts/lock-heartbeat-probe.js +1 -0
- package/scripts/lock-probe.js +1 -0
- package/scripts/managed-text-probe.js +1 -0
- package/scripts/mcp-presence-probe.js +1 -0
- package/scripts/memory-archive-check-probe.js +1 -0
- package/scripts/memory-archive-retention-probe.js +1 -0
- package/scripts/memory-arena-cleanup-probe.js +1 -0
- package/scripts/memory-checked-input-probe.js +1 -0
- package/scripts/memory-guard-order-probe.js +1 -0
- package/scripts/memory-input-preflight-probe.js +1 -0
- package/scripts/memory-input-unit-probe.js +1 -0
- package/scripts/memory-language-probe.js +1 -0
- package/scripts/memory-legacy-retention-probe.js +1 -0
- package/scripts/memory-migration-concurrency-probe.js +1 -0
- package/scripts/memory-migration-test-support.js +11 -28
- package/scripts/memory-migration-transport-probe.js +1 -0
- package/scripts/memory-output-parent-race-probe.js +1 -0
- package/scripts/memory-output-posix-probe.js +1 -0
- package/scripts/memory-output-publication-probe.js +1 -0
- package/scripts/memory-output-unit-probe.js +1 -0
- package/scripts/memory-projection-retention-probe.js +1 -0
- package/scripts/memory-recovery-external-probe.js +1 -0
- package/scripts/memory-recovery-receipt-probe.js +1 -0
- package/scripts/memory-restore-canonical-probe.js +1 -0
- package/scripts/memory-utf8-admission-probe.js +1 -0
- package/scripts/memory-write-budget-meter.js +1 -0
- package/scripts/migration-compat.js +1 -0
- package/scripts/mode-text-probe.js +1 -0
- package/scripts/mutation-integrity-probe.js +1 -0
- package/scripts/next-action-suggestion-probe.js +1 -0
- package/scripts/openai-response-retrieval-probe.js +1 -0
- package/scripts/openai-ui-review-dispatch-probe.js +1 -0
- package/scripts/openai-ui-review-observation-probe.js +1 -0
- package/scripts/openai-ui-review-receipt-probe.js +1 -0
- package/scripts/parent-detect-selftest-probe.js +1 -0
- package/scripts/plain-language-reporting-probe.js +1 -0
- package/scripts/plan-text-probe.js +1 -0
- package/scripts/platform-smoke.js +1 -0
- package/scripts/provider-capacity-probe.js +1 -0
- package/scripts/race-overlap-probe.js +1 -0
- package/scripts/release-bump-probe.js +1 -0
- package/scripts/release-runtime-probe.js +1 -0
- package/scripts/reminder-text-probe.js +1 -0
- package/scripts/review-checkpoint-transport-probe.js +1 -0
- package/scripts/review-control-probe.js +1 -0
- package/scripts/review-feedback-probe.js +1 -0
- package/scripts/review-request-labels-probe.js +1 -0
- package/scripts/review-request-signals-probe.js +1 -0
- package/scripts/review-snapshot-transport-probe.js +1 -0
- package/scripts/role-agent-schema-probe.js +1 -0
- package/scripts/role-fallback-probe.js +1 -0
- package/scripts/role-file-identity-probe.js +1 -0
- package/scripts/role-store-loader-probe.js +1 -0
- package/scripts/rule-applicability-probe.js +1 -0
- package/scripts/rule-text-probe.js +1 -0
- package/scripts/rule-verification-probe.js +1 -0
- package/scripts/runtime-admission-probe.js +1 -0
- package/scripts/runtime-cost-probe.js +1 -0
- package/scripts/runtime-descriptor-identity-probe.js +1 -0
- package/scripts/runtime-layout-probe.js +1 -0
- package/scripts/runtime-repl-probe.js +1 -0
- package/scripts/runtime-replacement-probe.js +1 -0
- package/scripts/runtime-write-probe.js +1 -0
- package/scripts/safe-remove-probe.js +573 -0
- package/scripts/secret-scan-scope-probe.js +1 -0
- package/scripts/selftest-cleanup-probe.js +1 -0
- package/scripts/session-close-text-probe.js +1 -0
- package/scripts/shared-state-lock-probe.js +1 -0
- package/scripts/skill-archive-probe.js +1 -0
- package/scripts/smoke.js +1 -0
- package/scripts/state-inspect-cli-probe.js +1 -0
- package/scripts/state-scopes-probe.js +1 -0
- package/scripts/store-diagnostic-identity-probe.js +1 -0
- package/scripts/store-diagnostic-probe.js +1 -0
- package/scripts/store-values-probe.js +1 -0
- package/scripts/task-sync-roundtrip-probe.js +1 -0
- package/scripts/teams-text-probe.js +1 -0
- package/scripts/temp-cleanup-probe.js +437 -1
- package/scripts/temp-cleanup.js +186 -2
- package/scripts/test-execution-plan-probe.js +1 -0
- package/scripts/test-firestore-probe.js +1 -0
- package/scripts/test-http-probe.js +1 -0
- package/scripts/test-node-runner-probe.js +9 -4
- package/scripts/test-npm-script-probe.js +1 -0
- package/scripts/test-npm-script-runner-probe.js +1 -0
- package/scripts/test-prerequisite-probe.js +1 -0
- package/scripts/test-suite-list-runner-probe.js +1 -0
- package/scripts/title-args-probe.js +1 -0
- package/scripts/tracker-write-language-probe.js +1 -0
- package/scripts/ui-boundary-browser-probe.js +1 -0
- package/scripts/ui-boundary-contracts-probe.js +1 -0
- package/scripts/ui-boundary-dashboard-probe.js +1 -0
- package/scripts/ui-copy-boundary-probe.js +1 -0
- package/scripts/ui-review-attempt-probe.js +1 -0
- package/scripts/ui-review-authority-floor-probe.js +1 -0
- package/scripts/ui-review-authority-host-probe.js +1 -0
- package/scripts/ui-review-authorization-probe.js +1 -0
- package/scripts/ui-review-ledger-probe.js +1 -0
- package/scripts/ui-review-observation-probe.js +1 -0
- package/scripts/ui-review-proof-use-probe.js +1 -0
- package/scripts/ui-review-publication-probe.js +1 -0
- package/scripts/ui-review-recovery-probe.js +1 -0
- package/scripts/ui-review-result-probe.js +1 -0
- package/scripts/ui-review-run-probe.js +1 -0
- package/scripts/ui-review-target-probe.js +1 -0
- package/scripts/ui-review-transport-probe.js +1 -0
- package/scripts/verify-code-cross-runtime-probe.js +1 -0
- package/scripts/which-shim-probe.js +1 -0
- package/scripts/workspace-dir-lock-order-probe.js +1 -0
- package/scripts/workspace-dir-migration-probe.js +1 -0
- package/scripts/workspace-migration-heartbeat-probe.js +1 -0
- package/scripts/workspace-selection-probe.js +1 -0
- package/scripts/write-path-language-probe.js +1 -0
- package/scripts/write-primitive-language-probe.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
15
|
+
## 1.36.304 - 2026-10-16
|
|
16
|
+
|
|
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.
|
|
18
|
+
- `scripts/temp-cleanup.js` `trackTemp(module)` records only when the environment carries `LEERNESS_TEMP_RECORD`, a directory the runner makes for each leaf; without it nothing is wrapped and the script behaves as before. It wraps `fs.mkdtempSync` and appends one line per folder to its own file in that directory in the same synchronous step that made the folder. The callback and promise forms are not wrapped: they complete later, when another folder may have been put in place (review round 1); their folders are left, and no script in `scripts/` uses them. It removes nothing and installs no exit, emit or exception hook. Child processes that inherit the variable record into the same directory.
|
|
19
|
+
- What is recorded: only a call whose prefix is an absolute path without `.` or `..` segments; a `Buffer` prefix or result only if it is UTF-8 that decodes and encodes back to the same bytes; a result only if it is the prefix followed by the six characters mkdtemp adds; a folder only if it is strictly below the temp directory of the moment of the call and empty when the call returns (a wrapper installed before the tracker could otherwise put an existing folder in its place; review round 2). Others are left, as before.
|
|
20
|
+
- `runNpm` gives each leaf its own record directory and, after the leaf ended with status 0, no signal and no spawn error (the verdict it uses for pass/fail), removes the recorded folders newest first with `removeRecorded`: each must still be the folder recorded (same file identity and real path, not a link) and strictly below both the temp directory recorded with it and the runner's own; links inside are removed as links and never followed, and each folder must still resolve inside the tree before it is listed and before a link in it is removed (the checks T-0354 wrote for the memory arena, shared as `ownTree` and `removeOwnedTree`). A failed leaf keeps its folders. The runner checks its record root and each leaf's directory right after making them (an empty directory, not a link; a wrapper of `fs.mkdtempSync` or `fs.mkdirSync` installed before the runner could put an existing folder there, review round 3); a leaf whose directory fails gets no variable and records nothing. It removes only the leaf directories it checked, the root only when it is empty, and prints one line with the counts.
|
|
21
|
+
- Each of the 171 scripts in `scripts/` that name `mkdtemp` has the tracker as its first statement, before it loads anything from `lib/` or `bin/`; the tracker records only when given a module whose chain reaches the main module, in the main thread (a script loaded with `--require` into another process, anything it loads, and a call without a module record nothing).
|
|
22
|
+
- Runs that do not go through the runner (`node scripts/x.js`, a plain `npm test`, and CI's e2e and core jobs, which run on throw-away machines) record nothing and leave their folders as before; CONTRIBUTING.md names `node ./scripts/ci-shard.js npm test 1/1` for a local run that cleans up.
|
|
23
|
+
- Not covered: folders made by processes that do not inherit the variable or do not run the tracker (such as the CLI a probe starts); a test that writes into the record directory, replaces the mkdtemp functions again, or patches the module loader; a folder a process renames or replaces after recording it (left).
|
|
24
|
+
- Measured with each tree's own runner (the 30 probes of the 2026-10-14 prefix map, one leaf each, a dedicated temp directory per tree, side by side): 29 `leerness-*` folders left with 1.36.303 and 1 now (a memory recovery bundle that `memory-recovery-external-probe.js` makes while it points the temp directory elsewhere); every leaf passed. The full test run (`ci-shard.js npm test i/3`, three shards in parallel, each with its own new temp directory; Windows, Node v26.3.0; on the round-4 code, before the later changes, which touch only `temp-cleanup-probe.js` and `ci-shard-probe.js`): 68/68, 69/69 and 69/69 leaves passed, e2e 469/469 with 432 folders recorded; the runner removed 37, 116 and 150 folders. Five `leerness-*` folders were left: one that an e2e case replaces after it was recorded (`orph116`); two memory recovery bundles from processes that do not record (the one above, and one made by the CLI that `memory-migration-boundary-probe.js` starts); and two that the record-root test of `ci-shard-probe.js` left, the folder it swaps in and the root it moves aside (that test now removes them; run alone and as a leaf it leaves nothing). The first full run of this version left 10 memory recovery bundles: `lib/runtime-writes.js` intercepts fs writes, and the record went through `fs.appendFileSync`, which calls the intercepted `fs.openSync`. Records are now written, and folders read, with the fs functions of the moment the tracker starts.
|
|
25
|
+
- Why the runner: 1.36.302 and 1.36.303 decided inside the test process at exit. In eight review rounds a later piece of JavaScript (another exit listener, a microtask, an exception handler, an interrupted `process.exit()`) kept changing the verdict after the decision; the owner moved the change out of both releases. The exit status the runner reads cannot change afterwards. The first review round of this version found two problems (an asynchronous mkdtemp whose folder was swapped before its completion was recorded; a call without a module skipped the check against preloads) and the second one more (a wrapper preloaded before the tracker swapped an existing folder in); the third found that the runner's own record root was removed without that check, and that the lossy-`Buffer` test did not distinguish on POSIX; the owner chose one more round, and the fourth found that the encoded-result test did not distinguish its check (a test was added, which fails on that mutant; the owner chose a short confirmation of it, which found that the new test failed when the temp path holds a non-ASCII letter: it is skipped there; a second confirmation found that the skip also gives up a setup where a link of the mangled name leads back into the temp path, and the owner chose to document that and ship). Each code fix has a test that fails on the reviewed code.
|
|
26
|
+
- Tests: `scripts/temp-cleanup-probe.js` has twenty-seven cases (four before) and `scripts/ci-shard-probe.js` three more (seventeen):
|
|
27
|
+
- without `LEERNESS_TEMP_RECORD` the tracker changes nothing (the mkdtemp functions stay the same objects, nothing is recorded or removed); with it, `fs.mkdtempSync` records its folder with path, identity, real path and temp directory in the process's own file, and the process removes nothing; `fs.mkdtemp`, `fs.promises.mkdtemp`, `require('fs/promises').mkdtemp` and `util.promisify(fs.mkdtemp)` stay unwrapped and record nothing, also when the folder is swapped for another before the completion (review round 1); a record is written when the call returns, so a run that then fails, throws or calls `process.exit(3)` keeps it; a record is written and the folder read with the fs functions of the start, also after the test replaced `fs.openSync`, `fs.writeSync`, `fs.appendFileSync`, `fs.lstatSync` and `fs.realpathSync.native`;
|
|
28
|
+
- a folder that is not empty when `fs.mkdtempSync` returns is not recorded: a wrapper preloaded before the tracker that swaps in a folder holding a file gets nothing recorded, and that folder survives the runner's removal (an empty folder swapped in is recorded, as named in the stop rule);
|
|
29
|
+
- not recorded: a `Buffer` prefix that is not UTF-8, also when a wrapper preloaded before the tracker planted the sibling its lossy decoding names (POSIX; Windows refuses such a prefix, so there it is a control); a result in another encoding that is not the prefix plus six characters (`hex`, and `latin1` with a non-ASCII letter, also when a wrapper preloaded before the tracker made the folder the mangled result names; review round 4; skipped when the temp path itself holds a non-ASCII letter, which `latin1` mangles too, also where a link of the mangled name would lead back into it); a relative prefix, a dotted prefix, and a folder outside the temp directory; recorded: a `Buffer` result that is UTF-8 and a `file:` URL prefix; each case has a control that must be recorded in the same process;
|
|
30
|
+
- with recording on, `process.reallyExit`, `process.exit`, `process.emit` and the `exit`, `beforeExit`, `uncaughtExceptionMonitor` and `newListener` listener counts are unchanged;
|
|
31
|
+
- nothing is recorded by `trackTemp()` without a module (in a main script, a `--require` preload, or `node -e`; review round 1), by a script loaded with `--require` (directly, through `NODE_OPTIONS`, a module such a script loads, 60 requires deep, or through `createRequire()`), or in a worker thread started from a script file; the same script, and the same 60-deep chain, run as the main script record, and so does a main script after `temp-cleanup.js` itself was preloaded; one tracker per process;
|
|
32
|
+
- `removeRecorded` removes the recorded folders newest first, nested ones too, and leaves a link's target alone; it leaves a folder replaced by a link (and the link) or by another folder, skips one already gone, leaves a folder outside the runner's temp directory or outside the temp directory of its own record, skips lines it cannot use, and removes nothing for an empty or missing directory;
|
|
33
|
+
- `scripts/ci-shard-probe.js`: `runNpm` removes the folders a passing leaf and its tracked child recorded and keeps those of a failing leaf and of a killed one; each leaf gets its own record directory, the runner leaves no record directory behind, prints the count line, and does not change the caller's environment; a wrapper that swaps an existing folder in for the record root or a leaf's record directory gets nothing removed and the leaf no variable (the test then removes those folders itself; the full run found them left); a folder a leaf moves into the record root is left with the root;
|
|
34
|
+
- every script in `scripts/` that names `mkdtemp` in any form has the tracker as its first statement; the check refuses a tracker in a block comment, a line comment (ended by any JavaScript line terminator: CR, LF, U+2028, U+2029), a string, or after other code.
|
|
35
|
+
- 35 deliberate breaks of `temp-cleanup.js` and `ci-shard.js` (each check removed or weakened in turn, including reverting each review fix) were run against this probe, `ci-shard-probe.js` and `memory-arena-cleanup-probe.js`, with the unchanged files as the control (27/27, 17/17 and 5/5): 27 made a case fail on Windows (one of them, the encoded-result check, only through the round-4 test). Of the eight that pass on Windows, two concern POSIX only (a lossily decoded `Buffer` prefix, which Windows refuses; a leaf ended by a signal, which on Windows ends with exit code 1; CI's Linux jobs run the same tests); one is the identity check when the runner removes a leaf's record directory, which matters only if the leaf replaces that directory (a test writing into the record directory, named in the stop rule); the other five do not change what is removed, because another check stops the same case (a link in place of a folder has another identity; a real path that moved fails the inside check of `removeOwnedTree`; the identity check repeated inside `removeOwnedTree` and the inside check before listing guard only against changes during the removal; an inherited variable of the same name is replaced for the leaf).
|
|
36
|
+
|
|
3
37
|
## 1.36.303 - 2026-10-15
|
|
4
38
|
|
|
5
39
|
- Fixed (tests): `scripts/ci-shard.js` passed its own `LEERNESS_CI_SHARD_TIMES` to the leaves it runs, so `scripts/ci-shard-probe.js`, which runs the runner itself, overwrote the outer run's times file in the middle of a local full run (found in a full run for this release; the outer runner rewrote the file at its end, so the stored times were right). Leaves no longer see the variable, the way `e2eShard` already hides its own. CI does not set it. A new probe case fails on the 1.36.302 runner (13/14) and passes now (14/14) (T-0364).
|
package/CONTRIBUTING.md
CHANGED
|
@@ -37,6 +37,8 @@ When you add a guard, construct the **discriminating case**: an input where the
|
|
|
37
37
|
|
|
38
38
|
Each top-level case in `scripts/e2e.js` starts with `if (e2eShard.take('<unique key>')) {` and counts itself with `total++;` inside the block. CI runs that file, `test:commands` and `test:fast` in parallel shards (`scripts/ci-shard.js`): every shard computes the same split from `scripts/ci-shard-weights.json`, so the shards of one job run each case and leaf exactly once. A top-level block without the guard, a repeated key, or a guard that is not reached exactly once fails the e2e run. `node ./scripts/ci-shard.js plan e2e 4` shows the split; a run with `LEERNESS_E2E_TIMES=<file>` (e2e) or `LEERNESS_CI_SHARD_TIMES=<file>` (npm leaves) writes measured seconds, and `node ./scripts/ci-shard.js weights e2e <file>` stores them.
|
|
39
39
|
|
|
40
|
+
A script in `scripts/` that makes temp folders with `fs.mkdtempSync`, `fs.mkdtemp` or `fs.promises.mkdtemp` starts with `require('./temp-cleanup').trackTemp(module);` as its first statement, before it loads anything from `lib/` or `bin/`. Under the test runner (`node ./scripts/ci-shard.js npm <script> <i>/<k>`, as CI runs `test:commands` and `test:fast`, or `node ./scripts/ci-shard.js npm test 1/1` locally), the script records the folders it makes below the temp directory, and the runner removes them after the script ended with exit status 0 and keeps them after a failure; links inside are removed as links, never followed. Child processes that inherit the runner's variable record too. Run directly (`node scripts/x.js`, a plain `npm test`), nothing is recorded or removed. Only code that the main script loaded records; a script loaded with `--require` into another process does not. `scripts/temp-cleanup-probe.js` fails when a script that names `mkdtemp` lacks the line.
|
|
41
|
+
|
|
40
42
|
## Release
|
|
41
43
|
|
|
42
44
|
`npm test` (gate) → bump `VERSION` in `bin/leerness.js` **and** `version` in `package.json` (selftest asserts they match) → CHANGELOG entry → commit → publish → push.
|
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.
|
|
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.
|
|
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.303는 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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
2498
|
+
_removeSync(dir, { recursive: true, force: true });
|
|
2498
2499
|
ok(`local meta removed for catalog skill: ${id} (catalog 자체는 패키지에 내장)`);
|
|
2499
2500
|
} else {
|
|
2500
|
-
|
|
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
|
-
|
|
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,
|
package/lib/claims-baseline.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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)
|
|
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 {
|
|
681
|
+
try { removeSync(alias, { force: true }); } catch {}
|
|
681
682
|
}
|
|
682
683
|
}
|
|
683
684
|
let stat;
|
package/lib/machine-cache.js
CHANGED
|
@@ -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 {
|
|
74
|
+
try { removeSync(temp, { force: true }); } catch {}
|
|
74
75
|
return false;
|
|
75
76
|
}
|
|
76
77
|
}
|
package/lib/memory-output.js
CHANGED
|
@@ -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))
|
|
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 {
|
|
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) — 임시폴더 설치 후 비교 · 읽기 전용(프로젝트 미변경)`);
|
package/lib/preview-serve.js
CHANGED
|
@@ -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)) {
|
|
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)) {
|
|
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
|
}
|
package/lib/runtime-writes.js
CHANGED
|
@@ -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)
|
|
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 };
|
package/lib/test-node-runner.js
CHANGED
|
@@ -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 {
|
|
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));
|