release: v3.10.0 - synthetic / in-memory leaf for api.slothlet.api.add()… (#135)
Slothlet v3.10.0 Changelog
Release Date: June 2026
Release Type: Minor
Branch: release/3.10.0
Overview
Version 3.10.0 adds two features and closes the last browser-mode resolution gap. The headline is synthetic / in-memory leaves: api.slothlet.api.add() now accepts inline content — a function or an export-map object — so a single leaf (or a whole branch) can be mounted without first writing it to a temp directory (#117). The second feature integrates the hook system with the permission system (#118): when permissions are configured, a hook can no longer intercept a leaf its registrant has no permission to reach, hooks are pinned to their owner by default, and hook selectors move to a pattern:type suffix syntax. The release also finishes the browser importmap — it is now built from the package's full public export surface, so every exported endpoint (including the documented @cldmv/slothlet/runtime aggregator) resolves in a real browser (#137).
Compatibility. No API breaking changes. The synthetic-leaf forms are additive overloads of api.add(). Hook permission gating is inert unless a permissions block is configured; the legacy type:pattern hook selector still works but is deprecated in favor of pattern:type. One behavior change to note: module-registered hooks are now force-pinned to their owner by default (see Upgrade notes).
✨ Features
Synthetic / in-memory leaf for api.slothlet.api.add() (#117)
api.add() previously required a directory to scan — mounting a single in-memory function meant writing it to a temp file first. It now accepts inline content directly: a bare function, a plain { default?, ...named } export map, or a { exports, ...options } shorthand. The inline exports flow through the same smart-flatten + wrap pipeline a file-backed leaf does, so a synthetic leaf behaves identically to one loaded from disk — self, per-request context, lifecycle events, and permissions all apply. Malformed inputs (arrays, class instances, empty export maps) fail up front with a structured SlothletError instead of a confusing TypeError deep in the flatten pipeline. See the api.slothlet.api.add() section of the README.
Hooks integrated with the permission system (#118)
The hook system and the permission system were two independent interception layers stacked in the same call path. Any module that could reach api.slothlet.hook.on could register a hook on any path — observing or tampering with the arguments, results, and errors of leaves the permission rules were specifically configured to keep it away from. This release closes that side-channel:
- Permission-gated registration and firing. When a
permissionsblock is configured, registering and firing a hook is checked through the same decision function as calls and reads (enforceHookAccess): a module can only hook a path it is itself allowed to access. Rule targets use thepattern:typesuffix form (e.g."db.*:error"), with:hookmatching any hook type on a path. - Force-pinned ownership. To stop a hook from laundering access through the bound
api, module-registered hooks are pinned to their owner module by default — the handler'sself.*calls and permission checks run as the registering module. Opt out per-instance withhook: { pin: false }at init, orapi.slothlet.hook.pin.disable()at runtime. pattern:typeselector syntax. Hook selectors now read path-first with the type as a trailing suffix ("math.*:before"); the legacy type-first prefix ("before:math.*") is deprecated — it still works but emits a deprecation warning and is removed in v4.
All of this reuses the existing rule shape, glob dialect, and decision function — no new matcher or precedence model — and is inert unless a permissions block is present. See docs/HOOKS.md and docs/PERMISSIONS.md.
🐛 Bug Fixes
Browser importmap omitted public endpoints (#137)
generateImportMap() built its map by scanning slothlet's source for @cldmv/slothlet/* imports, so any public export the source never imported itself was missing — including the bare @cldmv/slothlet/runtime aggregator that API modules are documented to import for self / context. A browser following the docs hit TypeError: Failed to resolve module specifier "@cldmv/slothlet/runtime". The importmap is now built from the package's public export surface: every flat export is seeded from package.json exports, and every wildcard export directory is enumerated per file, so a browser can resolve any exported endpoint by construction — not just the ones slothlet imports internally. The source scan remains only as a defense-in-depth backstop.
Eager root-level reload(".") was a silent no-op (#134)
In eager mode, a root-level reload() rebuilt the API but never applied the fresh implementation to the live root wrapper — _restoreApiTree's eager-root path extracted the new impl into a local and then dropped it, so the existing wrapper kept running the old code after reload. The eager-root path now applies the rebuilt impl via ___setImpl, mirroring the working non-root path, and a stale function→object extraction that would have made reloaded function leaves non-callable was removed. Regression tests cover reload(".") applying a mutated root leaf in both eager and lazy modes.
CI: scorecard-action pinned to a real release (#133)
The OpenSSF Scorecard workflow was pinned to a non-existent v3.x tag; it is now pinned to the real v2.4.3, and upload-sarif is bumped to v4.
🧪 Tests
- #117 / #118 / #137 — synthetic-leaf mounting, hook permission gating, and importmap completeness are covered by suites added alongside their features.
- Coverage backfill — added unit tests for the previously-uncovered error-detail branches in
buildAPIsynthetic validation (class-instance and empty-name reporting), the eager/lazypreloadedStructureshape guard, and the importmap wildcard enumeration's missing-dir and non-.mjsskips.collectSlothletSpecifiersis now exported so the latter can be driven against a temp-fixture package root. - Full coverage gate green: ~100% statements / branches / functions / lines.
📚 Documentation
- NEW: docs/changelog/v3/v3.10.0.md — this changelog.
- docs/HOOKS.md / docs/PERMISSIONS.md — hook permission gating, the
pattern:typerule-target form, and force-pin ownership. - README —
api.slothlet.api.add()synthetic-leaf forms and a refreshed What's New. - Performance benchmarks backfilled — per-version docs for v3.4.0–v3.10.0 (+ v3.9.2) re-run on current hardware; cross-version-summary.md gains a current-hardware era and performance/README.md a startup mermaid across all releases. Documents why lazy startup ≈ eager on root-leaf-heavy fixtures (lazy loads all root-level leaves at init; only nested subtrees defer).
🔧 Tooling
- Prettier adopted repo-wide. Formatting is enforced via
npm run format/format:check(config at.configs/.prettierrc); theanalyzeaudit reports an advisory "not Prettier-clean" count next to the file-header check, and precommit runs a Prettier fix-pass afterfix:headers— the same check-in-audit / fix-in-precommit model the headers use. - File-header audit hardened.
analyzenow header-checks.cjs/.jsonc/.jsonv(previously.mjsonly) via aFILE_HEADER_EXTENSIONSconstant shared withfix:headersso the two can't drift, accepts both/**and/*openers, and flags stacked/duplicate headers — closing the gap that let a double header land in a.jsoncundetected. @cldmv/fix-headers1.2.2 → 1.2.3 — fixes a data-loss bug where replacing a header with a non-canonical closer (e.g.**/) could delete file content up to the next*/, including one inside a//comment.
Upgrade notes
- Synthetic leaves are additive. Existing directory-based
api.add(path, dir)calls are unchanged; the inline-content forms are new overloads. - Hook permission gating is opt-in by configuration. With no
permissionsblock, hooks behave exactly as before. When permissions are configured, hook registration and firing now respect leaf access — audit your rules for any module that registers hooks on paths it cannot call. - Hooks are force-pinned by default. Module-registered hooks now run under their owner's identity; a
lockCaller: falseon a hook is ignored (with a warning). If you relied on unpinned hooks, sethook: { pin: false }at init or callapi.slothlet.hook.pin.disable(). - Hook selector syntax. Prefer the
path.glob:typesuffix form; the legacytype:path.globprefix still works but warns and is removed in v4.