Skip to content

v3.13.0

Choose a tag to compare

@cldmv-bot cldmv-bot released this 11 Aug 02:48
· 5 commits to master since this release
v3.13.0
2099035

release: v3.13.0 - sync/async-transparent dispatch — any mix of targets… (#265)

Slothlet v3.13.0 Changelog

Release Date: August 2026
Release Type: Minor
Branch: release/3.13.0


Overview

Version 3.13.0 lands three consumer-facing capabilities, completes the module-privacy contract for underscore-prefixed exports, and clears a batch of correctness fixes across hooks, ownership tracking, collision handling, and error propagation.

The headline features are sync/async-transparent hook dispatch (#264), a new api.slothlet.api.leaves() enumeration query (#266), and an injectable leaf importer (#267). Hook dispatch is now transparent to the sync-ness of both targets and handlers: attaching an async before/after handler to a synchronous target no longer refuses or silently corrupts the result — the call is promoted per-invocation to an asynchronous pipeline, and a promoted synchronous return is handed back as a guarded Promise so an unawaited consumer fails loudly instead of yielding NaN far from the cause. leaves() answers the inverse of the ownership tracking every mount already records — given a module, the api paths it owns — read from the loader's records rather than by walking the live api object, so it is complete under lazy mode and scoped to what the calling context may see. slothlet({ import }) lets a consumer route leaf loads through its own test runner so leaf execution attributes correctly in coverage, without inlining the whole package through Vite.

The module-private export contract (#269) closes a fail-open gap: with permissions enabled, an export whose name starts with _ or __ is now enforced as private to its own module — reachable by its sibling files through self, denied to every other module, and denied to the host by default. Separately, a module file or export named for a framework-reserved key is refused at load rather than silently breaking composition.

Compatibility. No breaking changes. A few behavior corrections could surface for a host relying on prior (unintended) behavior — sync leaves now propagate a thrown value untouched exactly as async leaves always did (#262), underscore-prefixed exports are enforced as private once permissions is configured (#269), and removing an overriding mount now reverts its shared paths to the underlying module (and merge-replace resolves a leaf conflict in favor of the replacing source) rather than what earlier releases happened to do (#274) — each is described in Upgrade notes, and the privacy enforcement is inert when no permissions block is present.


✨ Features

Sync/async-transparent hook dispatch (#264)

Hook handlers were previously required synchronous: an async before handler threw HOOK_BEFORE_RETURNED_PROMISE regardless of the target's own sync-ness, and an async after handler on a synchronous target silently leaked a pending Promise as the return value — surfacing as NaN under arithmetic, with no error. Dispatch is now derived per call from the current hook set: a path whose matching before/after handlers include an async one runs an asynchronous pipeline (awaiting only actual thenables, strict registration order preserved), while a path with only synchronous handlers keeps the synchronous fast path unchanged. Observers (always / error) never force promotion — their return values are not consumed.

A synchronous target promoted by someone else's async hook returns a guarded Promise: await works normally, but consuming it as a value (arithmetic, string coercion, JSON.stringify) throws HOOK_PROMOTED_RESULT_NOT_AWAITED naming the path — the caller's code was correct when written and broke only because another module attached a hook, and the error says so. Promotion is transient and a property of the call, never baked onto the leaf: removing the async hook restores the original synchronous contract. The one silent-corruption cell that detection blind spots left open is closed loudly — a synchronous pipeline whose after handler returns an undetected thenable now throws HOOK_AFTER_RETURNED_PROMISE instead of leaking it; such handlers should declare { async: true } at registration.

Delivering this also tightened the observer guarantees the synchronous path already had: always observers now fire when a promoted before hook refuses, when a promoted after-chain throws, and for a failure an inner hooked call already reported; version-dispatched hooks carry their tag through the promoted chains; and the per-path dispatch-strategy cache invalidates only on a real change (a no-op remove() / enable() / disable() / pattern toggle leaves it warm). See docs/HOOKS.md.

api.slothlet.api.leaves() — enumerate the leaves a module owns (#266)

A new public method answering the inverse of the ownership records every mount already keeps: given a module, list the api leaf paths it owns. Consumers previously fell back to recursively walking the live api object — which under-reports unmaterialized lazy subtrees and is caller-sensitive now that enumeration redacts under permission rules. leaves() reads the loader's own records instead:

const moduleID = await api.slothlet.api.add("modules.acme.shop", "/path/to/extension/api");
await api.slothlet.api.leaves(moduleID); // ["modules.acme.shop.connect", "modules.acme.shop.search", …]
await api.slothlet.api.leaves("modules.acme.shop"); // same, keyed by mount endpoint
await api.slothlet.api.leaves("."); // the base load's own leaves
await api.slothlet.api.leaves(moduleID, { details: true }); // [{ path, kind: "function"|"namespace"|"data" }, …]

It accepts the moduleID an api.add() returned, a mount endpoint, any owned path (resolved to its owning module), or "."/"" for the base load. The default return is the flattened callable leaf paths — one entry per function a caller can invoke; { details: true } returns every owned path tagged function / namespace / data. Under mode: "lazy" the owned subtree is settled first (with an explicit stack rather than recursion, so an arbitrarily deep tree cannot overflow the call stack, and a runtime-introduced cycle terminates on a visited set), so the answer is complete for unmaterialized modules at any depth. Enumeration is a disclosure surface, so the answer is scoped to the caller: module-private members the caller could not read are omitted, matching the redaction Object.keys already performs on the composed surface; { includePrivate: true } returns the unredacted list and is host-only. Keys resolve to the mount's current owner (matching remove() / reload()), and a concurrent removal mid-settle surfaces the method's own API_LEAVES_UNKNOWN_MODULE rather than a raw error. See docs/RELOAD.md.

Injectable leaf importer — slothlet({ import }) (#267)

Slothlet loads each leaf module via a native dynamic import() carrying a per-instance cache-busting query. When slothlet is externalized in a consumer project — the normal case for vitest with node_modules left un-inlined — that import() never enters the consumer's test-runner module graph, so leaf execution attributes to nothing in the coverage report. The existing workaround (server.deps.inline) fixes attribution only by re-processing the whole package through Vite, at a fidelity cost. The new import option on SlothletOptions hands the loader a consumer-supplied importer instead: it receives the exact cache-busted URL the loader would have imported natively (including the ?slothlet_instance=… / &module=… / &_reload=… markers), and its resolved module namespace is used unchanged — so per-instance isolation, mount identity, and hot reload behave identically; only whose import() executes differs. Because that call runs inside the consumer's own transformed code, the leaf load enters the runner's graph and coverage attributes correctly while slothlet itself stays externalized and natively loaded.

The option is boot-time-only and optional — leaving it unset preserves native-import() behavior exactly. A non-function value throws INVALID_CONFIG_IMPORT at construction. Slothlet also self-detects the misattribution case: under an active vitest coverage run with this copy externalized and no import importer configured, it emits a one-shot WARNING_COVERAGE_IMPORTER_UNSET pointing at docs/TESTING.md (suppressed by silent: true, never fired for an in-repo boot). Because the importer controls what code loads for every leaf, it carries the same host-only trust as base or node_modules and must not be built from untrusted input. See docs/TESTING.md.


🔒 Module Privacy & Permissions (#269)

Module-private _ / __ exports

An export whose name starts with _ or __ has always been the intended module-privacy marker, but enforcement was incomplete — underscore filtering in the wrapper traps was inconsistent across composition shapes, and the permission system did not recognize the prefix, so underscore-prefixed exports were readable and enumerable by every module and the host. The contract is now complete: with permissions enabled, an underscore-prefixed export is private to its module (the directory of files that export it) — reachable through self by sibling files in the same directory, denied to every other module, and denied to the host by default. A denial behaves like any other permission denial: refused on read/call, redacted from enumeration and serialization, and audited via permission:denied. Privacy resolves before rule evaluation, so no user-authored rule can grant a foreign module access to a private member. The prior trusted-root carve-out is restored with permissions.private.host: "allow". With no permissions block configured, behavior is unchanged — underscore exports remain fully public.

Reserved-name files and exports refused at load

A module file named for a framework-reserved key (e.g. _materialize, _impl) is now refused at the directory scan with MODULE_RESERVED_FILENAME, and a reserved-named export is refused when the module is read (MODULE_RESERVED_EXPORT) — at boot in eager mode, at first touch in lazy mode — instead of silently breaking composition with a bare TypeError or emptying the lazy surface. The file-name refusal is scoped to the files a mount actually loads: it runs after both the fileFilter and the hidden-glob exclusion, so a single-file api.add() or an explicitly hidden sibling no longer blocks an otherwise-valid mount, and the same check is mirrored into the browser manifest scan. A related correction fixes root-level detection: a bare top-level _name mount (no parent segment) is a mount, not a private member, so it is correctly treated as public — only members of a module are subject to the private-name rule. See docs/MODULE-STRUCTURE.md and docs/PERMISSIONS.md.


🐛 Bug Fixes

Sync leaves propagate a thrown value untouched (#262)

A synchronous leaf's throw was re-typed by the context manager into a generic CONTEXT_EXECUTION_FAILED SlothletError, while the identical leaf written async had its rejection propagate untouched — the same leaf carried two different error contracts depending only on the function vs async function keyword. Structured throw payloads (e.g. { statusCode, key, … }) lost their shape in the synchronous path, so at scale a raised 4xx could surface to an error boundary as a generic internal failure and key-branching fault handlers broke. A rawErrors signal now threads through every leaf invocation site (the unified-wrapper.mjs runInContext call sites and the class-instance method wrapper), so a leaf's thrown value reaches the caller unchanged and with referential identity intact, regardless of sync-ness; the manager-level wrap remains the defensive boundary for direct runInContext consumers outside a leaf call. A falsy-but-real thrown value (0, false, "") is now treated as a genuine original everywhere — message enrichment, hint detection, and the cause chain share one hasOriginal test — and SlothletError chains the original via the standard ES2022 cause property alongside the existing originalError field.

Version-dispatched hook registration hardened (#268)

hook.on(typePattern, handler, { versioned: true }) resolves a hook against a versioned mount through the same discriminator seam a call uses (with the registrant as the caller), or through a per-registration versionDispatcher(allVersions, caller) that may select one tag, several, or none; one physical registration is created per selected tag, each passing the ordinary registration permission gate, and the returned id is a group id whose single remove({ id }) unhooks every member. This release makes that mechanism robust: registration is all-or-nothing (a member that fails partway — e.g. a permission gate granting one mounted version but not another — rolls back every member already registered rather than leaving them live and unremovable), the group id is reserved so a later plain hook cannot claim and shadow it, the dispatcher's returned tag array is copied rather than aliased (the empty-selection fallback had pushed the default tag onto a value the caller still held), tags are de-duplicated, and tag validation uses Object.hasOwn so inherited-prototype keys like "toString" can no longer resolve truthy and register against a never-mounted path. New error codes HOOK_VERSION_UNRESOLVED / HOOK_VERSION_UNKNOWN_TAG are shipped in all 12 locales, and DUPLICATE_HOOK_ID now also rejects an id already in use as a version-dispatch group id. See docs/HOOKS.md and docs/VERSIONING.md.

Ownership records stay under the mount prefix (#271)

Several ownership-registration sites in the modes processor built the record's api path by hand instead of routing through the same buildApiPath() the composed wrapper uses, so an api.add("shop", dir) mount recorded phantom prefix-less paths (a top-level single/pair/bag, a nested deep.inner.leaf) that do not exist on the api. The api surface was always correct — only the ownership records diverged, which pollutes enumeration (and therefore leaves()) and mis-anchors remove() / reload() cleanup to the wrong subtree. Every registration site now computes its path through buildApiPath(), matching the wrapper exactly and honoring the Rule-13 hoist collapse; it is a no-op at base load, so base-load records are unchanged.

Shared and replaced mounts revert to their prior owner on remove (#274)

When more than one module contributes to the same api path — overlapping api.add() mounts, or a replace / merge-replace collision where a later module takes over an existing mount — the loader records a stack of owners per path rather than a single owner. Removing the module that currently owns a shared path was deleting the path outright, dropping members a still-mounted co-owner was responsible for; remove() now pops only the departing owner and reverts the path to the previous owner's value, so the surviving module's surface stays intact. A replace-mode takeover additionally shadows rather than discards the underlying module's exclusive members (those present on the first mount but absent from the replacing one): they are captured at replace time and restored in full when the overrider is removed, skipping any member a later module has since re-provided. Three supporting corrections keep the revert honest: re-registering a path whose ownership value is already known no longer downgrades that record to undefined (an update carrying no fresh value keeps the known one), reload() registers a reloaded child under the real reloaded module's id rather than the slothlet instance, and api.slothlet.api.leaves() resolves each path to its current owner so a module stops listing a shared path once that path has reverted to a co-owner. See docs/RELOAD.md.

merge-replace replaces terminal leaves and merges namespaces recursively (#274)

Under the merge-replace collision mode a conflict on a terminal leaf is now resolved in favor of the replacing (second) source, while a conflict between two namespaces merges their members recursively — applying the same rule to nested conflicts — instead of either source winning wholesale. Some composition shapes previously kept the first-loaded leaf on a terminal conflict, contradicting the documented collision table; resolution is now consistent across eager, lazy, live, and hooked composition. See docs/CONFIGURATION.md.

Deprecated config aliases warn as deprecated, not unsupported (#274)

The allowMutation and root-level collision options — both honored deprecated aliases in v3 (allowMutation: false maps to api.mutations, and a root-level collision normalizes to api.collision) — were emitting a V2_CONFIG_UNSUPPORTED warning that wrongly described them as v2 options not supported in v3, even as the option was applied. Both now warn through V3_CONFIG_DEPRECATED ("renamed for clarity; update before v4"), and the now-unused V2_CONFIG_UNSUPPORTED / HINT_V2_CONFIG_UNSUPPORTED keys are dropped from all 12 locales. The options continue to work; only the warning is corrected — tooling that matched the old V2_CONFIG_UNSUPPORTED code should match V3_CONFIG_DEPRECATED instead.


🧪 Tests

  • #264 — a matrix of promotion rules across sync/async targets × handlers: promotion and the guarded return, strict ordering under mixed handlers, suppressErrors, lazy materialization inside the promoted pipeline (including the cold-first-call guard edge), the always-observer guarantees on refusal / after-chain failure / inner-reported failure, version tag carried through promoted chains, and the no-op cache-invalidation arms for remove() / enable() / disable() / pattern toggles.
  • #266 — eager/lazy coverage of key resolution, kind classification, details mode, and error handling; a real 15-level fixture and a runtime-introduced cycle for the unbounded-depth settle; concurrent-removal-mid-settle; caller-scoped redaction and the host-only includePrivate (including a private subdirectory and gated private terminals); a nested namespace named after a root-reserved key; and the kindOf inconsistent-map guard.
  • #267 — the injectable importer routes leaf loads, preserves per-instance / mount / reload URL markers, rejects a non-function import, and the coverage-run warning fires only when externalized without an importer.
  • #268 — versioned registration resolves through the dispatch contract, all-or-nothing rollback on a partial failure, group-id reservation, tag-array copy, dedupe, and inherited-key rejection.
  • #269 — module-private read/call denial and enumeration/serialization redaction across composition shapes, same-module sibling access, host default-deny with the private.host: "allow" opt-in, and the reserved-filename / reserved-export refusals at scan and read (node and browser).
  • #271 — ownership records for every flatten shape (single-export file, multi-export file, object default, nested and doubly-nested namespaces) stay under the mount prefix, deep dotted mounts anchor under their full path, and base-load records stay unprefixed.
  • #274 — shared-mount co-ownership revert on remove (co-owned paths restore to the remaining owner rather than being deleted); replace-mode shadow capture and full-mount restore on removal of the overrider, including a module replacing itself (removed exports dropped, nothing resurrected) and a later module re-providing a shadowed member; merge-replace terminal-second-wins with recursive namespace merge, asserted across the eager/lazy/live/hooks config matrix; re-register not downgrading a known ownership value; leaves() reflecting current ownership after a revert; reloaded children registered under the real module id; and the allowMutation / root-collision deprecation warnings routed through V3_CONFIG_DEPRECATED.
  • Full coverage gate green across node and browser arms.

📚 Documentation

  • NEW: docs/changelog/v3/v3.13.0.md — this changelog.
  • docs/HOOKS.md — sync/async-transparent dispatch, promotion rules, the guarded promoted return, { async: true }, and versioned registration.
  • docs/RELOAD.mdapi.slothlet.api.leaves(): key forms, details output, lazy completeness, caller scoping, and the host-only includePrivate.
  • docs/TESTING.md — the injectable slothlet({ import }) importer, the coverage-run warning, and its host-only trust model.
  • docs/MODULE-STRUCTURE.md — when each reserved-name refusal fires.
  • docs/PERMISSIONS.md — the module-private _ / __ export contract and permissions.private.host.
  • docs/VERSIONING.md — hooks and versioning.
  • README — refreshed What's New.

🔧 Tooling

  • Release-base derivation in never-supersede concurrency (#263). The release workflow's never-supersede concurrency guard derived its base ref from a hardcoded master/main assumption; it now derives the release base from the repository configuration so the guard is correct regardless of the default-branch name.

Dependency updates

No dependency updates.


Upgrade notes

  • Sync leaves now propagate a thrown value untouched. A synchronous leaf that throws a structured or non-Error payload now propagates that value unmodified to the caller — with referential identity intact — exactly as an async leaf always did, instead of being re-typed into a generic CONTEXT_EXECUTION_FAILED error. This is a bug fix, not opt-in; any caller that relied on the old (unintended) re-typing of a synchronous leaf's throw should handle the propagated value directly.
  • Underscore-prefixed exports are enforced as module-private when permissions is enabled. A module or the host reading/calling another module's _ / __-prefixed export — previously allowed by omission — is now denied (PERMISSION_DENIED) unless it is the same module, or permissions.private.host: "allow" is set for host access. With no permissions block configured, behavior is unchanged: underscore exports remain fully public.
  • A file or export named for a framework-reserved key is refused at load. A source file or export named for a reserved key (e.g. _materialize, _impl, __impl) now throws MODULE_RESERVED_FILENAME (at directory scan, for files the mount loads) or MODULE_RESERVED_EXPORT (when the module is read) instead of silently breaking composition. Rename any such file or export. A bare top-level _name mount is unaffected — it is a mount, not a private member.
  • A synchronous target hooked by an async handler returns a guarded Promise. While an async before/after hook is attached to a synchronous target, that call returns a Promise: await works, but consuming the result without awaiting throws HOOK_PROMOTED_RESULT_NOT_AWAITED naming the path (previously this silently yielded NaN). Removing the async hook restores the plain synchronous return. A handler that returns a Promise but is not a native async function should register with { async: true } so it is detected rather than refused.
  • Removing an overriding mount now restores the underlying module. When two modules share a mount path (overlapping api.add() mounts, or a replace / merge-replace collision), removing the module that currently owns a shared path reverts that path to the remaining owner instead of deleting it, and removing a replace-mode overrider restores the underlying module's full mount — its leaf and any members the overrider had shadowed. A host that relied on remove() clearing the whole subtree should no longer assume co-owned paths disappear. Relatedly, merge-replace now resolves a terminal-leaf conflict in favor of the replacing (second) source and merges namespaces recursively, matching the documented collision table.
👥 Contributors