Skip to content

release: v3.22.0 - type the composed api, self, and slothlet() from each… - #488

Merged
cldmv-bot[bot] merged 94 commits into
masterfrom
next
Oct 3, 2026
Merged

cldmv-bot[bot] merged 94 commits into
masterfrom
next

Conversation

@cldmv-bot

@cldmv-bot cldmv-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Slothlet v3.22.0 Changelog

Release Date: October 2026
Release Type: Minor
Branch: release/3.22.0


Overview

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 same api reference, and an around hook 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 adds permissions.owner, slothlet.lockCaller.caller and permissions.global.checkCall. Typegen now types the composed api, self and slothlet() from each node's real module origin, so an imported self and an awaited slothlet() are typed with no annotation.

Every module a leaf imports through a relative or file: specifier is now per instance, across .mjs, .ts, .cjs and CommonJS .js leaves and across ESM/CommonJS boundaries. This uses the in-thread module.registerHooks() resolve hook, so the minimum Node.js version rises from 22.12.0 to 22.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 forceOverwrite and co-mounted modules. Read Requirement and behavior changes and the upgrade notes at the end before upgrading.


⚠️ Requirement and behavior changes

Node.js >=22.15.0 is now required (#518)

engines.node rises from >=22.12.0 to >=22.15.0. Per-instance helper isolation (see Helper modules are per instance) is built on module.registerHooks(), the synchronous in-thread resolve hook added in Node 22.15 / 23.5. The off-thread module.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 EBADENGINE and 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:

  • a module entered synchronously (a nested leaf, or a lockCaller-pinned callback) whose own file was not a suspended call's was attributed to the outer suspended caller further down the stack;
  • a suspended call resuming in a sibling file of its module fell through to the outer suspended caller the same way;
  • the fallback read a shared field that is restored in settle order, not entry order, so a resumed module could be handed the rights of a caller that had already finished.

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:

Built-in deny target Applies
slothlet.reload, slothlet.shutdown always
slothlet.restart always
initialize (root api.initialize() cascade), shutdown (root api.shutdown()) only while the matching default routine (initialize / startup, shutdown / shutdown) is in the instance's effective routines config
slothlet.lockCaller.caller always
slothlet.permissions.global.checkCall always

The root api.shutdown(), api.destroy() and routine cascades now check their own path at entry, so ordinary rules on shutdown, destroy or 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 a permissions config, and an instance rule on the same exact target re-opens one for a trusted module. The new event.strategy / event.deliver surfaces are host-only through the existing slothlet.event.** deny. Changed in #544, #545, #516 and #513.

Caveat: removing the default routines (for example routines: []) removes the built-in rules on their root paths, but the top-level api.shutdown() / api.destroy() still tear the instance down. A host that removes the defaults and wants modules kept away from teardown must add its own rules:

{ caller: "**", target: "shutdown", effect: "deny" }
{ caller: "**", target: "destroy",  effect: "deny" }

Upgrade: a module that legitimately calls self.slothlet.reload(), restart(), shutdown(), the root shutdown() / initialize(), lockCaller.caller() or permissions.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 throws TYPESCRIPT_STRICT_REQUIRES_TS6, naming the installed version and pointing at typescript@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) and slothlet typegen don't use the compiler API and work on TypeScript 7. Fixed in #532.

Upgrade: stay on typescript@6 if you use typescript: { mode: "strict" }.

Other behavior changes


✨ Features

api.slothlet.restart() — a clean-slate rebuild behind the same api (#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 original slothlet({...}) config, swapped in behind the same api proxy.

const api = await slothlet({ base: "./api" });

api.conn.handlers = { onSend }; // runtime assignment
await api.slothlet.api.add("plugins", "./plugins");

await api.slothlet.restart(); // both gone; the instance is back to its original config
  • Original config: the config passed to slothlet() is snapshotted at creation. Plain objects and arrays are copied and frozen, reference and context are kept by identity, and a relative base stays resolved against the creating call.
  • Nothing runtime carries over: 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 after control.seal().
  • Held references are re-pointed: after the swap, const conn = api.conn forwards every operation to the node at the same path in the new instance. A path that no longer exists throws RESTART_REFERENCE_UNRESOLVED.
  • Lifecycle: restart (old instance) → shutdown (old) → init (new) → restarted (new). shutdown now fires on every teardown and init on the cold start. Handlers in the lifecycle config option receive all four; runtime subscribers go with the old instance.
  • Concurrency: concurrent restart() calls share one restart, and calls already in flight finish on the old implementation. Gated by api.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.

around hooks (#496)

A fifth hook type wraps the rest of the call pipeline:

api.slothlet.hook.on("db.**:around", async ({ path, args, next, caller, entry }) => {
	const started = Date.now();
	try {
		return await next(args); // remaining around hooks → before → function → after
	} finally {
		metrics.record(path, Date.now() - started);
	}
});
  • 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 calls next short-circuits, and its return value is the result. Calling next twice throws HOOK_AROUND_NEXT_CALLED_TWICE.
  • Order, outermost first: always / error observers → around hooks (highest priority outermost) → before → function → after. error hooks and suppressErrors apply only to what escapes the around chain; an error an around throws itself carries the new around source type.
  • entry is true when the call has no module caller in the active flow, and caller is that caller's metadata.
  • A synchronous around keeps a synchronous call synchronous; an async one promotes it, as with before/after hooks.
  • Around hooks are permission-gated like before hooks, and are pinned when registered from a module. A pinned around's 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.

const pending = [];

api.slothlet.event.strategy((envelope, listeners, defaultDeliver) => {
	if (!inTransaction()) return defaultDeliver(); // deliver now
	pending.push({ envelope, listeners }); // defer; emit resolves once this returns
});

// On commit:
for (const { envelope, listeners } of pending.splice(0)) {
	for (const id of listeners) await retry(() => api.slothlet.event.deliver(envelope, id));
}
  • strategy(fn) is called per emit as fn(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 listener id (<owner moduleID>:<event>:<n>, or an explicit { key } in place of n), 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 one api.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 } (default false) 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 emit permission:owner-allow under audit: "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's every(), a registry) could only pin itself. self.slothlet.lockCaller.caller(fn) pins the current leaf's caller: the identity metadata.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.checkAccess is 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 stale requires principals (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 with via: "checkCall". It is host-only by default. Implemented in #513. See docs/PERMISSIONS.md.

Typed composed api, self and slothlet() 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 any or went missing. The builder now records each node's module origin (exportPath next to filePath in the ownership registry, readable through ownership.getOrigin(apiPath)), in eager mode, lazy materialization, reload and api.add() mounts alike. The type generator emits typeof import("<leaf>")[...] for every member, so each leaf is typed from its own source whatever the format (.mjs JSDoc, .cjs, .ts / .mts). Typegen no longer needs the typescript package.

@cldmv/slothlet/runtime exports a SlothletSelf type anchor and types self as it; the generated declaration extends it, and slothlet() is <T = SlothletSelf>(...) => Promise<SlothletAPI & T>. An imported self and an awaited slothlet() 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 / .mts and .cjs leaves. Base leaves and every api.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/slothlet itself) stay shared.

Isolation also holds where the module systems meet: an ES module leaf importing a .cjs helper gets the helper through the instance's own CommonJS cache, and a relative require() of an ES module from a CommonJS leaf returns the instance's copy with Node's require(esm) shape. Under vitest, when leaves load through vite's module graph, add the slothletInstanceImports() 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

Reload, remove and ownership

  • A scoped reload keeps co-mounted modules' contributions (Scoped reload of one module drops a co-mounted module's leaves #525). Reloading one module handed the shared namespace only that module's fresh impl in replace mode, 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 under merge / merge-replace now merges that folder's children too (#549).
  • A scoped reload keeps a replace / forceOverwrite add's outcome (Scoped reload of a force-overwritten module restores its code over the overwriting module #530). After add("launcher.session", dir, { moduleID: "shadow", forceOverwrite: true }), reload("launcher") put launcher's shadowed members back under launcher.session while ownership still named shadow. 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).
  • forceOverwrite adds 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 a replace / merge-replace add claims every path it provides (#536).
  • Removing an overwriting module restores the overwritten namespace in place (Lazy remove() of a force-overwriting module loses the replaced module's other leaves #531). In lazy mode, removing a module that 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).
  • Values assigned to a lazy namespace during materialization are kept (Lazy mode discards values set on a namespace while it is still materializing #543). A value assigned while a replace-mode namespace (such as one placed by a forceOverwrite add) was still materializing was deleted as a stale child. User-assigned keys are now tracked and survive the adoption (#547).
  • A namespace becomes callable when a later module supplies its function (A namespace can't become callable when a later module adds its function #533). A Proxy's callability is fixed at creation, so a namespace created as a plain object by one module stayed non-callable after another module added a function at the same path, and a merge dropped the function entirely. The namespace is now upgraded to a callable proxy so api.<path>() works: merge keeps an existing function and fills an empty slot, and merge-replace takes 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 from api. The upgrade is one-way: if the function later goes away, calling the namespace throws INVALID_CONFIG_NOT_A_FUNCTION (#556).
  • Removing the module that created a namespace keeps other modules' children (Removing a namespace's creating module invalidates children other modules merged into it #555). When a module's callable default export created a namespace (for example 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; calling api.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

  • A root shutdown export runs once per teardown (With autoRoutines: true, a root shutdown export runs twice on teardown #542). With autoRoutines: true, a module's root shutdown export is both a contribution to the default shutdown routine and the root shutdown hook, so api.shutdown() ran it twice, and api.destroy() and restart() inherited that. A root destroy export under a mode: "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 with autoRoutines: false it runs no module code (#553).

Wrapper, context and events

Packaging


🔧 CI & tooling

  • Release merge re-arms on every check-producing workflow. release-merge.yml re-synced from the CLDMV/.github v4.29.2 template, so an approval given before CodeQL or another non-CI check finishes no longer leaves the release PR stuck (#490).
  • The required PR check can no longer pass while tests are still running. On an in-repo feature PR, the skipped pull_request run 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 Check only ever comes from a run that has finished the test matrix, synced from CLDMV/.github (#554, #557).
  • Bundle-size measures the published JS files. The workflow now measures exactly the 62 JS files npm pack publishes instead of dist/** (#494).
  • analyze and i18n:check skip gitignored paths through a shared .gitignore matcher (#492).
  • Test discovery excludes tmp/ and trash/ via @cldmv/vitest-runner 1.5.1's exclude option, plus a repeatable --exclude flag on the runner wrapper (Update @cldmv/vitest-runner to v1.5.1 for the exclude discovery option #520, #522).
  • Tests: a regression guard that a lazy 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). A resolve-from-caller test 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


🔧 Dependencies


Upgrade notes

  1. Node.js 22.15 or later is required. Upgrade the runtime before installing 3.22.0.
  2. Live runtime with 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.
  3. Permissions config present: modules can no longer call slothlet.reload, slothlet.shutdown, slothlet.restart, slothlet.lockCaller.caller, slothlet.permissions.global.checkCall, or the root initialize / shutdown paths while the default routines are configured. Grant trusted modules an exact-target allow rule. With routines: [], add your own deny rules on shutdown and destroy, because the top-level api.shutdown() / api.destroy() still tear the instance down.
  4. TypeScript strict mode: stay on typescript@6. Strict mode throws TYPESCRIPT_STRICT_REQUIRES_TS6 on TypeScript 7; fast mode and typegen are unaffected.
  5. Event-rule conditions that read ctx.context.* to work around the old store shape must read ctx.*.
  6. Helper state is per instance. If two instances relied on sharing module-level state in a relative helper, move that state into a package or pass it through reference.
  7. Coverage: TypeScript source maps are now on automatically during coverage runs. Add **/.slothlet-cache/** to coverage.include to see .ts leaves reported; set typescript.sourcemap explicitly to override.
  8. Typegen: regenerate declarations to pick up origin-based types. Programs loading more than one api should pass --no-augment-runtime for the extra declarations and type slothlet<OtherApi>() explicitly.
👥 Contributors

coverage

Metric Coverage
Statements 99.6%
Branches 99.1%
Functions 99.7%
Lines 99.8%

Avg: 99.5% · a82800d · Node lts/*

Co-authored-by: Shinrai Shinrai@users.noreply.github.com

@cldmv-bot cldmv-bot Bot added ! release → master v4 flow: persistent next → master release PR (carries the next feature release) release Marks a pull request as a pending release — merge to publish a new version semver: minor This release adds new functionality in a backwards-compatible way type: bug Something is broken or not behaving as expected type: feature Implements new functionality — a PR or issue that adds a feature area: core Touches core library / runtime source code area: tests Touches test files, fixtures, or test infrastructure type: ci Changes to CI workflows, actions, or build pipelines type: config Changes to repository or project configuration files type: dependencies Relates to dependency updates, version bumps, or package management type: documentation Relates to docs, README updates, guides, or inline code comments labels Sep 28, 2026
@cldmv-bot cldmv-bot Bot closed this Sep 28, 2026
Shinrai and others added 2 commits September 27, 2026 22:40
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
@cldmv-bot cldmv-bot Bot reopened this Sep 28, 2026
@cldmv-bot

cldmv-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

🔒 Dependency Review

  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ✅ 0 package(s) with unknown licenses
  • ✅ 0 denied package(s)
  • ✅ 0 package(s) with OpenSSF Scorecard score < 3

Full job summary

@cldmv-bot

cldmv-bot Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor Author

⚠️ Bundle size increased

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.

@cldmv-bot cldmv-bot Bot changed the title release: v3.22.0 - named, module-owned principals for sync conditions release: v3.21.1 - re-arm release-merge on every check-producing workflow Sep 28, 2026
@cldmv-bot cldmv-bot Bot added semver: patch This release contains only backwards-compatible bug fixes and removed semver: minor This release adds new functionality in a backwards-compatible way type: bug Something is broken or not behaving as expected type: feature Implements new functionality — a PR or issue that adds a feature labels Sep 28, 2026
Shinrai and others added 6 commits September 27, 2026 23:09
…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.
Shinrai and others added 28 commits October 2, 2026 11:33
…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
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
@cldmv-bot
cldmv-bot Bot merged commit 98b79ab into master Oct 3, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: cli Touches CLI / executable / bin entry points area: core Touches core library / runtime source code area: tests Touches test files, fixtures, or test infrastructure ! release → master v4 flow: persistent next → master release PR (carries the next feature release) release Marks a pull request as a pending release — merge to publish a new version semver: minor This release adds new functionality in a backwards-compatible way type: bug Something is broken or not behaving as expected type: ci Changes to CI workflows, actions, or build pipelines type: config Changes to repository or project configuration files type: dependencies Relates to dependency updates, version bumps, or package management type: documentation Relates to docs, README updates, guides, or inline code comments type: feature Implements new functionality — a PR or issue that adds a feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants