Repository navigation
release: v3.22.0 - type the composed api, self, and slothlet() from each… - #488
Merged
Merged
Conversation
Re-sync release-merge.yml from the CLDMV/.github v4.29.2 template. The merge gate waits on every check-run on the release PR's head, but workflow_run only listed the CI workflow, so an approval given before CodeQL (or any other non-CI check) finished left the release PR stuck approved and green with nothing re-firing the merge check — exactly what happened to CLDMV/.github PR #322. The trigger now lists every workflow in the standard v4 set that puts a check on a release-PR head, and `branches: [next, hotfixes]` stops feature-branch runs from waking it. Refs CLDMV/.github#318
Contributor
Author
🔒 Dependency Review
|
Contributor
Author
|
| File | Raw | Δ Raw | Gzipped | Δ Gzipped |
|---|---|---|---|---|
| bin/slothlet.mjs | 7.2 kB | +439 B (+6.4%) |
2.6 kB | +130 B |
| devcheck.mjs | 4.3 kB | — | 1.7 kB | — |
| dist/lib/builders/api-assignment.mjs | 15.8 kB | — | 3.5 kB | — |
| dist/lib/builders/api_builder.mjs | 56.8 kB | +2.4 kB (+4.5%) |
11.0 kB | +423 B |
| dist/lib/builders/builder.mjs | 2.9 kB | — | 1.2 kB | — |
| dist/lib/builders/modes-processor.mjs | 47.3 kB | +674 B (+1.4%) |
7.9 kB | +142 B |
| dist/lib/errors.mjs | 4.4 kB | — | 1.7 kB | — |
| dist/lib/factories/component-base.mjs | 1.9 kB | — | 921 B | — |
| dist/lib/factories/context.mjs | 1010 B | — | 525 B | — |
| dist/lib/handlers/api-cache-manager.mjs | 3.4 kB | — | 1.4 kB | — |
| dist/lib/handlers/api-manager.mjs | 78.7 kB | +13.3 kB (+20.3%) |
16.1 kB | +2.9 kB |
| dist/lib/handlers/context-async.mjs | 5.2 kB | +965 B (+21.9%) |
1.7 kB | +279 B |
| dist/lib/handlers/context-live.mjs | 11.2 kB | +4.2 kB (+60.4%) |
3.5 kB | +1.2 kB |
| dist/lib/handlers/event-manager.mjs | 8.6 kB | +4.4 kB (+105.5%) |
2.7 kB | +1.2 kB |
| dist/lib/handlers/framework-internals.mjs | 1.1 kB | — | 611 B | — |
| dist/lib/handlers/hook-manager.mjs | 22.0 kB | +2.4 kB (+12.4%) |
5.4 kB | +487 B |
| dist/lib/handlers/lifecycle-token.mjs | 959 B | — | 540 B | — |
| dist/lib/handlers/lifecycle.mjs | 2.7 kB | — | 1.1 kB | — |
| dist/lib/handlers/materialize-manager.mjs | 1.2 kB | — | 672 B | — |
| dist/lib/handlers/metadata.mjs | 12.4 kB | +75 B (+0.6%) | 3.2 kB | +20 B |
| dist/lib/handlers/module-manager.mjs | 9.8 kB | — | 3.1 kB | — |
| dist/lib/handlers/ownership.mjs | 16.9 kB | +7.0 kB (+70.2%) |
4.6 kB | +1.7 kB |
| dist/lib/handlers/permission-manager.mjs | 32.4 kB | +2.8 kB (+9.4%) |
7.4 kB | +616 B |
| dist/lib/handlers/routine-manager.mjs | 19.9 kB | +703 B (+3.6%) |
5.1 kB | +199 B |
| dist/lib/handlers/trusted-root.mjs | 3.9 kB | +761 B (+23.8%) |
1.5 kB | +276 B |
| dist/lib/handlers/unified-wrapper.mjs | 94.4 kB | +13.1 kB (+16.1%) |
17.7 kB | +3.1 kB |
| dist/lib/handlers/version-manager.mjs | 14.4 kB | — | 3.7 kB | — |
| dist/lib/helpers/caller-pinning.mjs | 804 B | — | 496 B | — |
| dist/lib/helpers/cjs-export-names.mjs | 6.9 kB | +6.9 kB (+100.0%) |
2.7 kB | +2.7 kB |
| dist/lib/helpers/class-instance-wrapper.mjs | 2.7 kB | — | 1.1 kB | — |
| dist/lib/helpers/config.mjs | 22.7 kB | +1.5 kB (+6.9%) |
5.5 kB | +302 B |
| dist/lib/helpers/defaults.mjs | 1.3 kB | — | 785 B | — |
| dist/lib/helpers/eventemitter-context.mjs | 8.7 kB | −395 B (-4.3%) ✅ | 1.8 kB | −46 B |
| dist/lib/helpers/eventtarget-context.mjs | 3.3 kB | — | 1.2 kB | — |
| dist/lib/helpers/eventtarget-property-context.mjs | 2.8 kB | — | 1.1 kB | — |
| dist/lib/helpers/generate-manifest.mjs | 10.6 kB | — | 3.3 kB | — |
| dist/lib/helpers/hint-detector.mjs | 1.3 kB | — | 756 B | — |
| dist/lib/helpers/instance-imports.mjs | 10.9 kB | +10.9 kB (+100.0%) |
3.7 kB | +3.7 kB |
| dist/lib/helpers/manifest-resolver.mjs | 1004 B | — | 611 B | — |
| dist/lib/helpers/modes-utils.mjs | 1.2 kB | +36 B (+3.1%) | 726 B | +30 B |
| dist/lib/helpers/module-discovery.mjs | 7.5 kB | — | 2.5 kB | — |
| dist/lib/helpers/module-manifest-validator.mjs | 10.1 kB | — | 2.0 kB | — |
| dist/lib/helpers/module-sort.mjs | 1.0 kB | — | 593 B | — |
| dist/lib/helpers/observer-context.mjs | 2.1 kB | — | 1004 B | — |
| dist/lib/helpers/pattern-matcher.mjs | 3.0 kB | — | 1.4 kB | — |
| dist/lib/helpers/platform.mjs | 1.8 kB | — | 953 B | — |
| dist/lib/helpers/resolve-from-caller.mjs | 3.0 kB | — | 1.3 kB | — |
| dist/lib/helpers/sanitize.mjs | 7.8 kB | — | 2.3 kB | — |
| dist/lib/helpers/scheduler-context.mjs | 2.0 kB | — | 901 B | — |
| dist/lib/helpers/utilities.mjs | 1.7 kB | — | 917 B | — |
| dist/lib/i18n/translations.mjs | 5.0 kB | — | 1.8 kB | — |
| dist/lib/modes/eager.mjs | 2.1 kB | — | 1.0 kB | — |
| dist/lib/modes/lazy.mjs | 2.5 kB | — | 1.2 kB | — |
| dist/lib/processors/flatten.mjs | 11.6 kB | +1.0 kB (+9.4%) |
2.6 kB | +164 B |
| dist/lib/processors/loader.mjs | 15.5 kB | +2.4 kB (+18.6%) |
4.9 kB | +731 B |
| dist/lib/processors/type-generation-worker.mjs | 1.5 kB | +1.5 kB (+100.0%) |
841 B | +841 B |
| dist/lib/processors/type-generator.mjs | 6.5 kB | −170 B (-2.5%) ✅ | 2.5 kB | −61 B |
| dist/lib/processors/typescript.mjs | 14.3 kB | +1.5 kB (+12.1%) |
5.1 kB | +559 B |
| dist/lib/runtime/runtime-asynclocalstorage.mjs | 3.0 kB | — | 1.1 kB | — |
| dist/lib/runtime/runtime-livebindings.mjs | 3.1 kB | — | 1.1 kB | — |
| dist/lib/runtime/runtime.mjs | 2.3 kB | +33 B (+1.4%) | 815 B | +15 B |
| dist/lib/typegen/typegen.mjs | 2.5 kB | +387 B (+18.2%) |
1.1 kB | +104 B |
| dist/slothlet.mjs | 24.3 kB | +4.1 kB (+20.1%) |
6.4 kB | +938 B |
| index.cjs | 3.3 kB | — | 1.4 kB | — |
| index.mjs | 6.9 kB | +738 B (+11.6%) |
2.9 kB | +302 B |
| Total | 699.3 kB | +83.6 kB | 183.1 kB | +22.7 kB |
📊 Generated by bundle-size. Brotli sizes also measured but omitted from the table for brevity.
…ports api.slothlet.api.remove() aborted with "Cannot delete property" when the removed module exported a frozen/sealed object (e.g. a constant table), leaving stale leaves on the api and an invalidated function leaf that a re-add of the same moduleID did not restore. A wrapper over such an export now reports a non-deletable key the way the object itself does (the deleteProperty trap returns false), and deletePath() detaches with Reflect.deleteProperty so that answer no longer aborts the removal. A user `delete` through the wrapper keeps the object's own semantics: a strict-mode TypeError for a frozen key, a normal delete otherwise. Fixes #485
Both scanners walked the tree with hand-rolled readdir loops that never consulted .gitignore: analyze recursed into every folder (including nested node_modules fixtures) and filtered a hard-coded list afterwards, and i18n:check skipped only node_modules/.git. A gitignored file inside a scanned folder (a .slothlet-cache/ under api_tests, a "* copy.mjs") was reported as missing its file header. A shared tools/lib/gitignore.mjs builds a matcher from the root .gitignore with the `ignore` package (the matcher @cldmv/fix-headers already uses). Both walkers now skip ignored entries and prune ignored or excluded directories before descending into them. Output on the current tree is unchanged.
@rolldown/binding-linux-x64-gnu was declared in optionalDependencies as a workaround for npm's optional-dependency lockfile bug (npm/cli#4828), so every consumer installed a 20 MB Linux bundler binary slothlet never uses. The lockfile already records every platform's binding as rolldown's own optional dependency, and a clean `npm ci` still installs the Linux binding and loads rolldown for vitest without the direct entry. Moving it to devDependencies instead would break `npm install` on non-Linux dev machines (EBADPLATFORM), so the entry is removed outright, along with the Dependabot ignore rule that only existed to pin it. Fixes #461
dist_paths was dist/**, which counted 12 i18n language JSON files (11 of them excluded from the package) and missed the published entry points index.mjs, index.cjs, devcheck.mjs and bin/slothlet.mjs. Match exactly the 62 JS files npm pack publishes, with a leading * on every pattern because the v4.29.2 measure action matches nothing for a bare top-level file and double-counts files across differently-rooted patterns.
…its materialization is in flight (#547)
…t paths host-only With a permissions config, modules could reload or shut down the whole instance: nothing denied the framework's own lifecycle methods, and the root teardown entry points were not checked at all. - Built-in deny rules for slothlet.reload and slothlet.shutdown, the framework's fixed methods, regardless of the routines config. - Built-in deny rules for the root path of each default routine (initialize/startup, shutdown/shutdown), added per instance only while that default is in the effective routines config (matched on name and mode). A renamed, replaced or removed default leaves the path a plain routine governed by the host's own rules; destroy is not a default and gets no built-in rule. - The root api.shutdown(), api.destroy() and routine cascades check their path at entry, so ordinary rules on shutdown/destroy/<routine> apply. - Framework-internal teardown (destroy's own shutdown, the routines and hooks the dispose path runs) runs as the host, so a permitted caller is never refused halfway. - As with every built-in, the rules apply only when a permissions config is present; an exact-target instance rule re-opens each for a module. Fixes #529
…eload A reload of one module forced the shared namespace wrapper into "replace" mode and handed it only the reloaded module's fresh impl, so every child another module had added at the same api path was cleared along with it. The reload now splits each namespace's children by contributor, at every depth. Children only other modules contribute are set aside and reattached unchanged (same references, same key order; a lazy subtree is not materialized). A subfolder both sides contribute to is rebuilt the same way one level down, so the reloaded module's changes inside it apply and the other module's leaves stay. A leaf both export follows the ownership stack. Contributors are read from the ownership stacks as they stood before the rebuild, whose own registrations could otherwise move the reloaded module back over a path another module overrode; those stacks are restored afterwards, so a later remove still reverts to the right module. Rebuilt children are attributed to the reloaded module rather than to whichever module created the namespace, which kept repeated reloads from misclassifying them. A callable namespace keeps the function of the co-mounted module that exported it. Adding a lazy module whose folder collides with an existing namespace under merge or merge-replace now merges that folder's children in too; leaving it lazy dropped them (merge) or the existing side's (merge-replace). Fixes #525
…t paths host-only (#544) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes - fix(permissions): make reload, shutdown and the default routines' root paths host-only (#544) (7ec43e5) ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes _No other changes_ <details> <summary>👥 Contributors</summary> - @Shinrai </details>
…eload (#549) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes - fix(reload): keep co-mounted modules' contributions across a scoped reload (#549) (a8a6355) ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes _No other changes_ <details> <summary>👥 Contributors</summary> - @Shinrai </details>
…nd the same api reference
reload() deliberately keeps runtime state. restart() is the opposite: it
tears the instance down through the normal teardown path and builds a new
one from the original slothlet({...}) config, swapped in behind the same
api proxy.
- The config passed to slothlet() is snapshotted at creation (plain
objects/arrays copied and frozen; reference/context kept by identity;
a relative base stays resolved against the creating call).
- Nothing runtime is carried over: add()/remove() history, runtime hooks,
permission/event rules, principals, event and lifecycle subscriptions,
metadata, runtime assignments and module-scope state are dropped;
config-declared settings are re-applied.
- Held child references are re-pointed: wrappers record a restart epoch,
and once a newer tree is live their proxy traps (and lazy waiting
proxies) forward to the node at the same path, resolved on each use.
A path that no longer exists throws RESTART_REFERENCE_UNRESOLVED.
- Lifecycle: restart -> shutdown (old instance) -> init (new instance)
-> restarted. shutdown now fires on every teardown and init on the
cold start; config-declared lifecycle handlers receive all four,
runtime subscribers go with the old instance.
- Concurrent restart() calls share one restart; in-flight calls finish
on the old implementation. The teardown runs as the host, so a module
allowed to restart is not refused halfway. Gated by
api.mutations.reload.
- Permissions: the new instance has only the original config's rules and
is not sealed, even after control.seal(). With a permissions config, a
built-in rule denies slothlet.restart to modules; an exact-target
instance rule re-opens it. With no permissions config it is reachable.
Fixes #504
…nd the same api reference (#545) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features - feat(reload): add api.slothlet.restart() — a clean-slate rebuild behind the same api reference (#545) (0e11353) ### 🐛 Bug Fixes _No bug fixes_ ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes _No other changes_ <details> <summary>👥 Contributors</summary> - @Shinrai </details>
…ped reload
After add("launcher.session", dir, { moduleID: "shadow", forceOverwrite:
true }), reload("launcher") rebuilt launcher's whole mount, putting
launcher's members that the overwrite had shadowed back under
launcher.session while ownership still named shadow.
The reload now finds the endpoints other modules were added at under
"replace" (forceOverwrite included) and still own, by the ownership
record's owner there as it stood before the rebuild (by add order when
ownership tracking is off), and keeps those subtrees exactly as the add
left them: the reloaded module's content inside them stays shadowed. A
child of the reloaded module with another module mounted inside it is
rebuilt level by level, so that module's leaves stay too. The members
the overwrite shadowed are refreshed with the reloaded code, so removing
the overwriting module later restores the module's current code.
Fixes #530
…ped reload (#550) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes - fix(reload): keep a replace/forceOverwrite add's outcome across a scoped reload (f6b235a) ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes _No other changes_ <details> <summary>👥 Contributors</summary> - @Shinrai </details>
With autoRoutines: true, a module's root-level shutdown export is both a contribution to the default mode: "shutdown" routine and the root shutdown hook the dispose builtins capture in userHooks. api.shutdown() ran the routine (invoking it) and then called the hook again, and api.destroy() inherited that through its own api.shutdown() call. A root destroy export was invoked twice by api.destroy() the same way under a mode: "destroy" routine. The routine manager now records, per mode, what the most recent automatic mode run invoked (each contributor's raw function and leaf wrapper, whether or not it threw), and exposes ranInModeRun(mode, value). The root shutdown and destroy builtins (their runShutdownBody/destroyBody dispose paths) and restart()'s teardown of the old instance skip the user hook when that run already invoked it. With autoRoutines false nothing is recorded, so the hooks are called exactly as before. api.slothlet.shutdown() stays framework-only: it runs the shutdown-mode routines but never calls the root hooks itself, so with autoRoutines false it runs no module code. docs/LIFECYCLE.md now states each teardown entry point's behavior for root shutdown/destroy exports (restart() included), and the builtin's JSDoc and the public slothlet.shutdown typedef say the same. Fixes #542
…s it merges A lazy api.slothlet.api.add() into an existing namespace loads exactly the subfolders the collision merge has to compare: both sides of each subfolder the two modules share. Every subfolder only the added module contributes stays unloaded after the add, including once pending microtasks and timers have run, and loads only on first access, one at a time. Eager mode is the control. The background loading reported in #548 could not be reproduced on next across merge, merge-replace and replace, async and live runtimes, and hooks on and off. What does start a load is reading a lazy subfolder through its parent: the parent's get trap starts loading the child it hands out, with or without an add (unified-wrapper.mjs, cached-child branch of the get trap). A test that checks `api.plugins.kit.__materialized` therefore loads `kit` itself. This test reaches each subfolder through own-property descriptors so the check never triggers a load. Refs #548
…s it merges (#552) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes _No bug fixes_ ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes - test(lazy): guard that a lazy api.add loads only the shared subfolders it merges (18d5295) <details> <summary>👥 Contributors</summary> - @Shinrai </details>
Per-instance helper isolation (#518) left two combinations shared by every instance, because Node keys both on the plain file path: - an ES module leaf importing a relative .cjs helper: the CommonJS translator caches the module under fileURLToPath(url), dropping the instance query; - a CommonJS leaf require()ing an ES module: require(esm) compiles and caches the module under pathToFileURL(filename), so a query added by a resolve hook never reaches the ESM loader's cache. The instance-import hooks now cover both directions: - requireInInstance() holds the per-instance CommonJS cache, keyed by instance ID in process-wide state, and marks the scope active for the duration of its synchronous require. The loader's .cjs leaf path uses it; shutdown and a full reload release the old instance's scope. - A load hook serves an instance-marked CommonJS URL as an ES module wrapper that runs the file through that cache. Named exports come from a static scan modelled on Node's cjs-module-lexer (exports.x, module.exports literals, defineProperty, re-exports, Babel/TS star exports), biased to report extra names rather than miss one, plus the live keys of an already-loaded copy. - A relative require() from a file of the active scope that resolves to an ES module is answered with a CommonJS stub returning the module imported under the instance URL, through two virtual modules that reproduce Node's require(esm) value (the "module.exports" export, or the __esModule facade). - The vite plugin loads instance-marked CommonJS helpers as the same wrapper, so their own requires are per instance under vitest too. Fixes #534
…#551) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes - fix(loader): keep helpers per instance across ESM/CommonJS boundaries (#551) (f703cd6) ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes _No other changes_ <details> <summary>👥 Contributors</summary> - @Shinrai </details>
On an in-repo feature PR, the `pull_request` run skips the `required-check` job because the push run owns the status. A skipped job still posts a check run under its name, and GitHub treats a skipped required check as satisfied. The push run's mirror is only created once `ci` finishes, so for the whole test window the only `✅ Required PR Check` on the head SHA was the skipped one, and the PR could merge while tests were still running. Give the job a conditional name so the skipped path posts under a different name and the required check stays pending until the push run reports. Synced from CLDMV/.github#346.
…554) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes _No bug fixes_ ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes - ci: stop the skipped PR-run mirror from satisfying Required PR Check (53d46b1) <details> <summary>👥 Contributors</summary> - @Shinrai </details>
…553) ## 🚀 What's Changed ### 💥 Breaking Changes _No breaking changes_ ### ✨ Features _No new features_ ### 🐛 Bug Fixes - fix(routines): run a root shutdown/destroy export once per teardown (#553) (6f7bd70) ### 📦 Dependencies _No dependency updates_ ### 🔧 Other Changes _No other changes_ <details> <summary>👥 Contributors</summary> - @Shinrai </details>
…dule supplies its function A non-callable wrapper uses the wrapper object as its proxy target, and a Proxy's callability is fixed at creation, so a namespace that one module created as a plain namespace stayed non-callable after another module added a function at the same path (and a merge dropped that function entirely). - syncWrapper's merge / merge-replace branches now hand the incoming impl to ___adoptCallableImpl(), which resolves the namespace's function slot like any merged member: an empty slot always takes it, `merge` keeps an existing function, `merge-replace` takes the incoming one. Ownership entries that record the namespace wrapper itself are pinned to the impl their module supplied first, so a removal can revert to it. - When a function reaches a wrapper whose proxy is non-callable (merge, a replace/reload/rollback through _applyNewImpl, a lazy materialization), ___upgradeToCallableProxy() builds a proxy on a function target from the same handler, installs it in the parent, and re-points the old proxy's traps at it through the restart() forwarding traps. A held reference keeps reading, writing and enumerating the namespace but stays non-callable; api.<path> is callable. - remove(moduleID) resolves a rolled-back namespace's function again from the remaining contributors (first supplier, replaced only by one placed under merge-replace/replace; merge losers never), so removing the winning module reverts to the previous function and removing a loser or a children-only module keeps it. - The upgrade is one-way: with no function left the callable proxy is kept and a call throws INVALID_CONFIG_NOT_A_FUNCTION, as for a namespace that was callable from the start. A never-callable namespace stays typeof "object". The trap hot path is unchanged. Fixes #533
…dule supplies its function (#556)
The Required PR Check mirror job was skipped on the `pull_request` run of an in-repo feature PR, with a conditional name keeping the skipped check off `✅ Required PR Check`. GitHub never evaluates a skipped job's `name:`, so every in-repo PR showed the raw expression as a check name. The job now uses `if: always()` and never skips, so its name is always evaluated. On the in-repo PR path it lands on `⏭️ Required PR Check (reported by the push run)` and passes as a no-op; the push run still posts `✅ Required PR Check`. Every path that posts the required name keeps `needs: ci`, so that check still only exists once the full test matrix for the SHA has finished. Synced from CLDMV/.github#351 (CLDMV/.github#350).
…d a namespace is removed Removing a module prunes its routine-captured wrappers and invalidates them recursively, so a backgrounded materialization cannot re-capture removed content (#372). When the removed module created a namespace (for example its default export made `plugins` callable), its captured wrapper is that live namespace, and the recursive invalidation walked into every child other modules had merged into it: `plugins.gamma` then threw "plugins.gamma is invalidated" although its module was still mounted. RoutineManager#pruneModule now invalidates only captured wrappers that are no longer the live node at their apiPath, and returns the live ones. removeApiComponent invalidates those after its restore/delete passes, once it is known which of them the removal actually took off the tree: a removed leaf still becomes unusable through a held reference, while a namespace that other modules' children keep standing stays usable, along with those children (same references, the survivor's key order). Fixes #555
…d a namespace is removed (#558)
Shinrai
approved these changes
Oct 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slothlet v3.22.0 Changelog
Release Date: October 2026
Release Type: Minor
Branch:
release/3.22.0Overview
Version 3.22.0 is a large release. It adds
api.slothlet.restart(), a clean-slate rebuild of an instance from its original config behind the sameapireference, and anaroundhook type that wraps the whole call pipeline. It also lets a host take over event delivery (event.strategy/event.deliver), for example to deliver events only after a transaction commits. On the permission side it addspermissions.owner,slothlet.lockCaller.callerandpermissions.global.checkCall. Typegen now types the composedapi,selfandslothlet()from each node's real module origin, so an importedselfand an awaitedslothlet()are typed with no annotation.Every module a leaf imports through a relative or
file:specifier is now per instance, across.mjs,.ts,.cjsand CommonJS.jsleaves and across ESM/CommonJS boundaries. This uses the in-threadmodule.registerHooks()resolve hook, so the minimum Node.js version rises from22.12.0to22.15.0.The release also fixes caller attribution in the live runtime, makes the framework's lifecycle methods host-only under a permissions config, and fixes a long list of reload, remove and ownership bugs around
forceOverwriteand co-mounted modules. Read Requirement and behavior changes and the upgrade notes at the end before upgrading.Node.js
>=22.15.0is now required (#518)engines.noderises from>=22.12.0to>=22.15.0. Per-instance helper isolation (see Helper modules are per instance) is built onmodule.registerHooks(), the synchronous in-thread resolve hook added in Node 22.15 / 23.5. The off-threadmodule.register()was deliberately not used: it puts a cross-thread round trip on every import in the process and changes evaluation timing enough to break a leaf that starts a fire-and-forget runtime import. Changed in #537.Upgrade: move to Node 22.15 or later (any Node 24 or 26 release qualifies). On older Node versions npm reports
EBADENGINEand helper isolation cannot be installed.Live-runtime caller attribution is now correct (#512)
With
runtime: "live", the caller was resolved from the call stack whenever two or more module calls were suspended, and frames were matched only by the exact file a suspended call had entered through. V8's async stack traces also carry the frames of the outer suspended calls, so:lockCaller-pinned callback) whose own file was not a suspended call's was attributed to the outer suspended caller further down the stack;The caller is now resolved from what is actually in flight: the call whose function body is on the JS stack right now; otherwise the baseline caller when nothing is in flight; otherwise the single suspended call; and with two or more suspended calls, the stack frames matched by location against each candidate's entry file or module root folder. A frame that matches several candidates ambiguously fails closed. A call pinned to the host with
lockCaller.caller()now runs as the host on the running-call stack instead of being attributed to the module that invoked it. Fixed in #519; the rule and its limits are documented under Overlapping calls in docs/PERMISSIONS.md.Upgrade: hosts on the live runtime with
defaultPolicy: "deny"should re-check their permission grants. Calls that were previously misattributed, typically to an outer boot or host-chain caller with broader rights, are now attributed to the module that actually made them, so a grant that only worked because of the misattribution now denies. The async runtime is unaffected.New built-in denies when a permissions config is present (#529, #504, #477, #508)
Reloading, restarting or shutting down the instance is a host decision, so new built-in rules deny it to modules:
slothlet.reload,slothlet.shutdownslothlet.restartinitialize(rootapi.initialize()cascade),shutdown(rootapi.shutdown())initialize/startup,shutdown/shutdown) is in the instance's effectiveroutinesconfigslothlet.lockCaller.callerslothlet.permissions.global.checkCallThe root
api.shutdown(),api.destroy()and routine cascades now check their own path at entry, so ordinary rules onshutdown,destroyor a routine name apply to them. Framework-internal teardown runs as the host, so a permitted caller is never refused halfway. As with every built-in rule, these apply only when the instance has apermissionsconfig, and an instance rule on the same exact target re-opens one for a trusted module. The newevent.strategy/event.deliversurfaces are host-only through the existingslothlet.event.**deny. Changed in #544, #545, #516 and #513.Upgrade: a module that legitimately calls
self.slothlet.reload(),restart(),shutdown(), the rootshutdown()/initialize(),lockCaller.caller()orpermissions.global.checkCall()needs an exact-target allow rule from the host, for example{ caller: "admin.**", target: "slothlet.reload", effect: "allow" }.TypeScript 7: strict mode fails with a clear error (#510)
TypeScript 7's current npm release exposes no compiler API (it lives under unstable paths until 7.1). Strict mode crashed with
Cannot read properties of undefined (reading 'ES3'); it now throwsTYPESCRIPT_STRICT_REQUIRES_TS6, naming the installed version and pointing attypescript@6, before any strict-mode code runs. The peer range (^6.0.3 || ^7.0.0) is unchanged so TypeScript 7 projects that don't use strict mode still install cleanly. Fast mode (esbuild) andslothlet typegendon't use the compiler API and work on TypeScript 7. Fixed in #532.Upgrade: stay on
typescript@6if you usetypescript: { mode: "strict" }.Other behavior changes
context.run(), so the same condition behaved differently on the two rule types. Both now receive the user context, and principal key resolvers on event rules see it too. A condition that worked around the bug by readingctx.context.*must readctx.*. Fixed in #514.reference.typescript.sourcemapis not set, source maps are now on exactly during a coverage run; see TypeScript coverage works out of the box under Bug Fixes.argumentsandcaller, so amodule.exports = functionleaf reportedObject.keys(api.x)as["arguments", "caller"], and typegen emitted them asunknownmembers. Only a function's own enumerable properties are children now; the values stay readable. Fixed in #498.✨ Features
api.slothlet.restart()— a clean-slate rebuild behind the sameapi(#504)reload()deliberately keeps runtime state.restart()is the opposite: it tears the instance down through the normal teardown path and builds a new one from the originalslothlet({...})config, swapped in behind the sameapiproxy.slothlet()is snapshotted at creation. Plain objects and arrays are copied and frozen,referenceandcontextare kept by identity, and a relativebasestays resolved against the creating call.add()/remove()history, runtime hooks, permission and event rules, principals, event and lifecycle subscriptions, metadata, runtime assignments and module-scope state are dropped; config-declared settings are re-applied. The new instance is not sealed, even aftercontrol.seal().const conn = api.connforwards every operation to the node at the same path in the new instance. A path that no longer exists throwsRESTART_REFERENCE_UNRESOLVED.restart(old instance) →shutdown(old) →init(new) →restarted(new).shutdownnow fires on every teardown andiniton the cold start. Handlers in thelifecycleconfig option receive all four; runtime subscribers go with the old instance.restart()calls share one restart, and calls already in flight finish on the old implementation. Gated byapi.mutations.reload, and host-only under a permissions config.Implemented in #545. See docs/RELOAD.md →
api.slothlet.restart()and docs/LIFECYCLE.md → Instance Events.aroundhooks (#496)A fifth hook type wraps the rest of the call pipeline:
next(args?)runs the remaining (lower-priority) around hooks, then the before hooks, the function and the after hooks, and returns the result or throws. With no argument it forwards the current args. A handler that never callsnextshort-circuits, and its return value is the result. Callingnexttwice throwsHOOK_AROUND_NEXT_CALLED_TWICE.always/errorobservers → around hooks (highest priority outermost) →before→ function →after.errorhooks andsuppressErrorsapply only to what escapes the around chain; an error an around throws itself carries the newaroundsource type.entryistruewhen the call has no module caller in the active flow, andcalleris that caller's metadata.next()re-enters the intercepted call's own flow, so the target still sees its real caller.Implemented in #517. See docs/HOOKS.md.
Host-controlled event delivery —
event.strategy/event.deliver(#497)A host that wraps work in a transaction can deliver events emitted inside it only if the transaction commits, each listener as its own retried unit of work, without editing the modules that emit.
strategy(fn)is called per emit asfn(envelope, listeners, defaultDeliver). The frozen envelope carries the event, payload, emitter and each recipient's emit-time level.strategy(null)clears it; it survives a full reload.deliver(envelope, listenerId)delivers a held envelope to one listener, so a retry never re-runs listeners that already succeeded. It resolves{ delivered: true, level }or{ delivered: false, reason: "listener-gone" | "denied" }, and rejects with the listener's own error. Rules and levels are enforced at delivery time, inside the flow captured at emit.on()/once()now also return a stable listenerid(<owner moduleID>:<event>:<n>, or an explicit{ key }in place ofn), which survives reloads once the module re-registers.Both surfaces are host-only. Implemented in #535. See docs/EVENTS.md.
permissions.owner— a module reaches every leaf it owns (#509)Under
defaultPolicy: "deny", a module added with oneapi.add(path, folder, { moduleID })couldn't call or read its own leaves across subdirectories, because each subdirectory is its own permission module.permissions: { owner: true }(defaultfalse) adds an implicit allow that stands in for the default policy: when no rule decided, a caller may access a target whose current owner is the caller's own current owner. Matching is by owner, not path, so another module merged into the same namespace gets nothing; an explicit deny still wins; and ownership changes from add, remove and reload are followed live. The reserved roots (slothlet.*,shutdown,destroy) are never granted. Granted accesses emitpermission:owner-allowunderaudit: "verbose". Implemented in #528. See Owner Grant in docs/PERMISSIONS.md.slothlet.lockCaller.caller(fn)— pin your caller onto a callback (#477)lockCaller(fn)pins the leaf that calls it, so a service that accepts a callback on behalf of its caller (a scheduler'severy(), a registry) could only pin itself.self.slothlet.lockCaller.caller(fn)pins the current leaf's caller: the identitymetadata.caller()reports. When the leaf was called from outside any module, the callback runs as the host. Acting as your caller is a privilege, so the path has a built-in deny for every module; the host grants it, for example{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }. Implemented in #516. See docs/HOOKS.md.permissions.global.checkCall(caller, target, args)(#508)global.checkAccessis a silent query: it passes no call metadata to conditions, never resolves a stale principal, emits no audit event, and treats a caller with no source file as the host. A trusted boundary layer that forwards a remote call (such as@cldmv/slothlet-vine) needs the answer the call gate would give.api.slothlet.permissions.global.checkCall(callerPath, targetPath, args)forwards{ args, target }to function conditions, resolves stalerequiresprincipals (returning a Promise only on that path), treats the caller as a module (no self-call bypass,_-private targets denied), and emits the gate's audit events withvia: "checkCall". It is host-only by default. Implemented in #513. See docs/PERMISSIONS.md.Typed composed
api,selfandslothlet()via typegen (#484)Typegen used to guess each node's type by matching the last path segment against a file's ESM export syntax, so CommonJS leaves, default exports, flattened and hoisted members, object-leaf methods and non-function values came out as
anyor went missing. The builder now records each node's module origin (exportPathnext tofilePathin the ownership registry, readable throughownership.getOrigin(apiPath)), in eager mode, lazy materialization, reload andapi.add()mounts alike. The type generator emitstypeof import("<leaf>")[...]for every member, so each leaf is typed from its own source whatever the format (.mjsJSDoc,.cjs,.ts/.mts). Typegen no longer needs thetypescriptpackage.@cldmv/slothlet/runtimeexports aSlothletSelftype anchor and typesselfas it; the generated declaration extends it, andslothlet()is<T = SlothletSelf>(...) => Promise<SlothletAPI & T>. An importedselfand an awaitedslothlet()are both typed with no annotation.augmentRuntime: false(--no-augment-runtime) opts out for programs that load more than one api. Implemented in #498. See docs/TYPESCRIPT.md.Helper modules are per instance (#518, #534)
A module a leaf imports through a relative or
file:specifier, at any depth, is now evaluated once per slothlet instance instead of once per process, for.mjs/.js,.ts/.mtsand.cjsleaves. Base leaves and everyapi.add()mount of an instance share that instance's copy. A full reload (or a restart) gives the instance fresh helpers; a partial reload keeps the existing copy. Bare specifiers (packages,node:builtins,#imports,@cldmv/slothletitself) stay shared.Isolation also holds where the module systems meet: an ES module leaf importing a
.cjshelper gets the helper through the instance's own CommonJS cache, and a relativerequire()of an ES module from a CommonJS leaf returns the instance's copy with Node'srequire(esm)shape. Under vitest, when leaves load through vite's module graph, add theslothletInstanceImports()plugin from@cldmv/slothlet/helpers/instance-imports. Implemented in #537 and #551. See docs/MODULE-STRUCTURE.md → Helper Modules Are Per Instance and docs/TESTING.md.🐛 Bug Fixes
Loading and TypeScript
.jsleaves are isolated per instance (A .js leaf in a "type": "commonjs" package shares its module state across instances #521). A.jsfile Node treats as CommonJS went throughimport()with an instance query that Node's path-keyed CommonJS cache ignores, so two instances shared one module scope. The loader now classifies.jsleaves the way Node does (nearestpackage.jsontype, then syntax detection) and routes CommonJS ones through the per-instance CommonJS loader (#523)..slothlet-cachecopy, so coverage reaches the.tssource only through the copy's inline source map.typescript.sourcemapis now tri-state: an explicit boolean wins, and when unset, source maps are on exactly during a coverage run (vitest coverage, or any process withNODE_V8_COVERAGEset). A coverage run withsourcemap: falsewarns once withWARNING_COVERAGE_TS_SOURCEMAP_OFF. docs/TESTING.md gains a TypeScript section, including adding**/.slothlet-cache/**tocoverage.include(#527).module,strict,compilerOptionsandsourcemapTypeScript options are honoured (TypeScript options are silently ignored: module/strict/compilerOptions are dropped by config normalization, and sourcemap is discarded #499). Normalization dropped the first three, so strict mode always used fixed defaults, andsourcemaphad no effect in either mode. They are now carried through and validated (INVALID_CONFIGon a bad value);compilerOptionstakes thetsconfig.jsonform, and both modes emit an inline source map pointing at the leaf's absolute path (#515).tools/and importedsrc/, neither of which is published, so strict mode failed withMODULE_NOT_FOUNDfrom an npm install. It now ships asdist/lib/processors/type-generation-worker.mjs(#502).Reload, remove and ownership
replacemode, clearing every child another module had added at the same path. Children are now split by contributor at every depth: other modules' children are reattached unchanged, shared subfolders are rebuilt one level down, and ownership stacks are restored afterwards so a later remove still reverts correctly. A lazy add that collides with an existing namespace undermerge/merge-replacenow merges that folder's children too (#549).replace/forceOverwriteadd's outcome (Scoped reload of a force-overwritten module restores its code over the overwriting module #530). Afteradd("launcher.session", dir, { moduleID: "shadow", forceOverwrite: true }),reload("launcher")put launcher's shadowed members back underlauncher.sessionwhile ownership still namedshadow. The overwritten subtree now stays as the add left it, and the shadowed members are refreshed with the reloaded code so a later remove restores current code (#550).forceOverwriteadds own the paths they overwrite (Ownership registry keeps the original owner after a forceOverwrite add #524). The ownership registry kept the overwritten module as current owner, and eager and lazy mode disagreed on which paths were affected. The add's collision mode is now recorded per module and endpoint, and areplace/merge-replaceadd claims every path it provides (#536).forceOverwrite-replaced part of another mount could lose the overwritten module's nested leaves or overflow the stack. The surviving owners' content is now restored into the live wrappers, shallow-first and before anything is deleted, so references taken while the overwriting module was live keep working and values set by hand are kept (#546).replace-mode namespace (such as one placed by aforceOverwriteadd) was still materializing was deleted as a stale child. User-assigned keys are now tracked and survive the adoption (#547).mergedropped the function entirely. The namespace is now upgraded to a callable proxy soapi.<path>()works:mergekeeps an existing function and fills an empty slot, andmerge-replacetakes the incoming function. Removing a module rebuilds the namespace's function from the remaining modules in add order, so removing the module whose function won reverts to the previous function, while removing a module whose function lost, or one that contributed only children, keeps the current one. A reference held from before the upgrade keeps reading, writing and enumerating the namespace but stays non-callable; re-read it fromapi. The upgrade is one-way: if the function later goes away, calling the namespace throwsINVALID_CONFIG_NOT_A_FUNCTION(#556).plugins) and other modules merged children into it (plugins.gamma), removing that module invalidated every one of those children recursively, because the routine manager's prune walked the live namespace; callingapi.plugins.gamma()then threw "plugins.gamma is invalidated". Only wrappers that are no longer live are invalidated now, so every other module's children stay reachable and callable, with the same references, in eager and lazy mode (#558).remove()no longer throws on frozen or sealed exports (api.remove() throws on a frozen nested plain-object export and leaves the module partially mounted #485). Removing a module that exported a frozen or sealed object aborted with "Cannot delete property", leaving stale leaves behind (#491).Lifecycle and routines
shutdownexport runs once per teardown (With autoRoutines: true, a root shutdown export runs twice on teardown #542). WithautoRoutines: true, a module's rootshutdownexport is both a contribution to the defaultshutdownroutine and the root shutdown hook, soapi.shutdown()ran it twice, andapi.destroy()andrestart()inherited that. A rootdestroyexport under amode: "destroy"routine was invoked twice the same way. Each now runs exactly once per teardown,restart()included.api.slothlet.shutdown()stays framework-only: it runs the shutdown-mode routines but never calls the root hooks itself, so withautoRoutines: falseit runs no module code (#553).Wrapper, context and events
self.X = obj) forwarded primitive writes to the live object but stored object- and function-valued writes on the wrapper, which then shadowed later raw updates. Those writes now go to the underlying object, and a refused write on a frozen object throwsTypeErrorlike the primitive path (#501).once("data")fires with the EventEmitter context patch enabled (EventEmitter patch: once("data") on a stream/socket never fires — patched once bypasses Readable.prototype.on, so the stream never starts flowing #503). The patchedonceattached through the baseEventEmitter.prototype.on, bypassingReadable's override that resumes a paused stream, sosocket.once("data")andevents.once(socket, "data")hung. It now attaches through the emitter's ownon/removeListener(#505).Packaging
@rolldown/binding-linux-x64-gnuwas listed inoptionalDependenciesas a lockfile workaround, so every consumer installed a 20 MB Linux bundler binary slothlet never uses. The entry is removed (#493).🔧 CI & tooling
release-merge.ymlre-synced from theCLDMV/.githubv4.29.2 template, so an approval given before CodeQL or another non-CI check finishes no longer leaves the release PR stuck (#490).pull_requestrun posted a skipped✅ Required PR Check, which GitHub treats as satisfied, so a PR could merge before the push run finished testing. On that path the mirror job now runs as a no-op under the name⏭️ Required PR Check (reported by the push run), so✅ Required PR Checkonly ever comes from a run that has finished the test matrix, synced fromCLDMV/.github(#554, #557).npm packpublishes instead ofdist/**(#494).analyzeandi18n:checkskip gitignored paths through a shared.gitignorematcher (#492).tmp/andtrash/via@cldmv/vitest-runner1.5.1'sexcludeoption, plus a repeatable--excludeflag on the runner wrapper (Update @cldmv/vitest-runner to v1.5.1 for the exclude discovery option #520, #522).api.add()loads only the shared subfolders its merge compares; the background loading reported in Lazy api.add into an existing namespace materializes the added module's other subfolders #548 could not be reproduced (#552). Aresolve-from-callertest no longer depends on the checkout's folder name (test: resolve-from-caller asserts the checkout path contains "slothlet", so it fails in any clone not in a folder with that name #506, #507).📚 Documentation
api.slothlet.restart(), reload vs restart, held references, and scoped-reload behavior for co-mounted and overwritten modules (feat: api.slothlet.restart() — a clean-slate rebuild that swaps in a fresh instance behind the same api reference #504, Scoped reload of one module drops a co-mounted module's leaves #525, Scoped reload of a force-overwritten module restores its code over the overwriting module #530).restart/shutdown/init/restartedinstance events, and how each teardown entry point treats rootshutdown/destroyexports (feat: api.slothlet.restart() — a clean-slate rebuild that swaps in a fresh instance behind the same api reference #504, With autoRoutines: true, a root shutdown export runs twice on teardown #542).aroundhook type, andlockCaller.caller(hooks: an around hook that wraps the call (next()), fires for entry and nested calls #496, feat(permissions): lockCaller that pins the CALLING module from inside a service leaf that accepts a callback #477).event.strategy/event.deliver, and event-rule conditions (events: a host-controlled delivery strategy for slothlet.event.emit (defer, persist, per-listener redeliver) #497, bug: event-rule conditions receive the raw context store instead of the user context #511).checkCall, Overlapping calls, and the host-only lifecycle methods and default routines (permissions: opt-in implicit self-access for a module's own leaves under defaultPolicy "deny" #509, feat: permissions.global.checkCall(caller, target, args) — call-side twin of event.resolveLevel #508, Live runtime misattributes callers while ≥2 calls are suspended (async stack frames of outer calls win) #512, Modules can call slothlet.reload(), shutdown() and destroy() without a host grant #529, feat: api.slothlet.restart() — a clean-slate rebuild that swaps in a fresh instance behind the same api reference #504).checkCallandlockCaller.caller..jsfiles are CommonJS (Helper modules a leaf imports are shared across instances: every relative import should carry the instance id #518, Per-instance helper isolation doesn't cover ESM→.cjs helpers or .cjs→ESM requires #534, A .js leaf in a "type": "commonjs" package shares its module state across instances #521).slothletInstanceImports()vite plugin (Generated self-typing doesn't work (JS and TS leaves), and documented coverage doesn't reach TypeScript leaves #484, docs/TESTING.md: server.deps.inline has undocumented side effects, and "invisible" native loads are actually mis-mapped #486, Per-instance helper isolation doesn't cover ESM→.cjs helpers or .cjs→ESM requires #534).sourcemapdefault, TypeScript 7, and origin-based typegen withSlothletSelf(TypeScript options are silently ignored: module/strict/compilerOptions are dropped by config normalization, and sourcemap is discarded #499, Generated self-typing doesn't work (JS and TS leaves), and documented coverage doesn't reach TypeScript leaves #484, Strict TypeScript mode can't work with TypeScript 7, though the peer range allows ^7.0.0 #510).🔧 Dependencies
@rolldown/binding-linux-x64-gnufromoptionalDependencies(fix(package): drop the unused @rolldown/binding-linux-x64-gnu optionalDependency — it ships a 20 MB bundler binary to every consumer #461).node>=22.12.0→>=22.15.0(Helper modules a leaf imports are shared across instances: every relative import should carry the instance id #518).@cldmv/vitest-runner^1.5.1(#522),vitest5.0.2 (#538),prettier3.9.9 (#539),@types/node26.6.3 (#541), andignoreadded for the gitignore-aware tooling (#492).Upgrade notes
defaultPolicy: "deny": re-check permission grants. Callers that were previously misattributed to an outer caller are now attributed to the module that made the call, so a grant that depended on the misattribution now denies.slothlet.reload,slothlet.shutdown,slothlet.restart,slothlet.lockCaller.caller,slothlet.permissions.global.checkCall, or the rootinitialize/shutdownpaths while the default routines are configured. Grant trusted modules an exact-target allow rule. Withroutines: [], add your own deny rules onshutdownanddestroy, because the top-levelapi.shutdown()/api.destroy()still tear the instance down.typescript@6. Strict mode throwsTYPESCRIPT_STRICT_REQUIRES_TS6on TypeScript 7; fast mode and typegen are unaffected.ctx.context.*to work around the old store shape must readctx.*.reference.**/.slothlet-cache/**tocoverage.includeto see.tsleaves reported; settypescript.sourcemapexplicitly to override.--no-augment-runtimefor the extra declarations and typeslothlet<OtherApi>()explicitly.👥 Contributors
Avg: 99.5% ·
a82800d· Node lts/*Co-authored-by: Shinrai Shinrai@users.noreply.github.com