v3.8.0
release: v3.8.0 - add metadata.getFor(pathOrModuleId) public wrapper (#100)
Slothlet v3.8.0 Changelog
Release Date: May 2026
Release Type: Minor
Branch: release/3.8.0
Overview
Version 3.8.0 adds the module discovery + mount pipeline — a runtime system for composing subsystems that ship as separate npm packages into a host's api tree. Each module package ships a slothlet.module.json manifest declaring where it mounts; slothlet walks the filesystem, validates the manifests, and grafts each module onto the api tree via the existing api.slothlet.api.add() primitive.
The new public surface lives at api.slothlet.api.modules.*:
await api.slothlet.api.modules.addDiscovered({
scanRoot: process.cwd(),
prefix: "@cldmv/packrat-driver-"
});
// Discover → sort → mount; events fire throughout.
api.drivers.opensearch.connect(/* ... */);Plus a new api.slothlet.metadata.getFor(path) wrapper, five new lifecycle events, and a canonical JSON Schema shipped with the package.
Compatibility. v3.8.0 has no breaking changes. The new surface is additive — existing api.slothlet.api.{add, remove, reload} calls behave identically, and no init-time configuration is required to use or ignore the new feature. Hosts that don't call api.slothlet.api.modules.* see no change in behavior or performance.
🚀 New Features
Module discovery + mount pipeline (api.slothlet.api.modules.*)
Nine methods compose the full surface:
| Method | Purpose |
|---|---|
discover(options?) |
Walk the filesystem for slothlet modules; replace the per-instance discovery cache |
sort(results, comparator?) |
Pure sort; default comparator is priority desc, packageName asc tiebreak |
addModule(name | result, options?) |
Mount one module; lazy-triggers discover() if cache is empty |
addModules(items[], options?) |
Batch mount; accepts heterogeneous (string | DiscoverResult)[] |
removeModule(name, opts?) |
Unmount a previously-mounted module |
addDiscovered(options?) |
One-shot convenience: chains discover() → sort() → addModules() |
getDiscoveryCache() |
Snapshot of the current discovery cache |
clearDiscoveryCache() |
Empty the cache (does not unmount anything) |
getStaleMounts() |
Mounts that no longer appear in the discovery cache (reconciliation aid) |
Scan modes: discover auto-detects per-scanRoot. If <scanRoot>/node_modules exists → npm mode (walks node_modules/* and node_modules/@*/*, one level deep). Otherwise → folder mode (walks immediate subfolders of scanRoot). scanRoot accepts string | string[] for multi-root sweeps.
Default scanRoot: upward-walk from process.cwd() to the nearest ancestor containing node_modules, capped at 20 levels. Handles the common monorepo case (host invoked from a subpackage automatically walks up to workspace root).
Dedupe + multi-version:
- Same real path (symlink aliasing) → silent dedupe; first wins.
- Same packageName + different versions → both surface as separate entries;
addModulesroutes each through slothlet's existingversionConfigso they mount under versioned paths (v1.<mountPath>,v2.<mountPath>). Highest semver becomes the registered default — the unversioned mountPath dispatches to it. - Same name + same version + different real paths → throws
MODULE_DUPLICATE_NAME_VERSION_MISMATCH(almost certainly a misconfiguration).
Pre-mount collision policy: caller-supplied collisionMode: "error" runs an exact-mountPath pre-flight check against api-manager's addHistory and throws MODULE_MOUNT_COLLISION if the path is occupied. All other modes (merge default, replace, skip, warn, merge-replace) defer to slothlet's underlying behavior.
Batch mount failure policy: onFailure option on addModules — "throw" (default; throw on first failure, leave mounted entries in place), "rollback" (throw + best-effort unmount of entries mounted in this call), or "best-effort" (continue past failures, return { mounted, failed } aggregate).
Concurrency: concurrency option on addModules — default 1 (serial). Infinity for all-at-once parallel via bounded worker pool. Under concurrency > 1, lifecycle event order tracks completion order, not start order.
slothlet.module.json manifest format + JSON Schema
The canonical manifest format ships with a JSON Schema at schemas/slothlet.module.schema.json, exposed via the package's exports map:
Editors with JSON-schema support auto-validate manifests; CI tools can $ref it for batch validation. The schema's additionalProperties: false enforces the strict "unknown fields rejected" rule at the editor level too.
Alternative manifest sources: discover({ manifest: "package.json#myproject.slothlet" }) reads from a nested key in another file. discover({ manifest: "manifest.json#backend", schema: { mountPath: "apiPath", apiDir: "apiFolder" } }) remaps legacy field names to canonical slothlet names without requiring projects to rewrite their existing manifest format. Missing schemaVersion under an override locator is leniently assumed 1.
api.slothlet.metadata.getFor(pathOrModuleId)
New public method symmetric with the existing setFor() / removeFor(). Returns merged user metadata for the path, walking root-to-leaf so descendants inherit ancestor entries automatically. Used internally by the module discovery system to round-trip the per-module manifest stored at the mount point under the _module key:
const meta = api.slothlet.metadata.getFor("drivers.opensearch");
console.log(meta._module.manifest);
// { schemaVersion: 1, name: "@org/foo", version: "1.4.2", ... }See docs/METADATA.md for the full signature.
Five new lifecycle events
All modules:* events emit through slothlet's standard lifecycle handler. Subscribe via api.slothlet.lifecycle.on(name, handler):
| Event | When |
|---|---|
modules:discover-start |
Beginning of discover() |
modules:discover-complete |
After cache replacement; payload includes stale[] for reconciliation |
modules:mount-start |
Beginning of addModule / addModules mount phase |
modules:mount-complete |
Once per successfully mounted module |
modules:loaded |
After the full async chain settles |
See docs/LIFECYCLE.md — Module Discovery Events for full payload shapes.
11 new MODULE_* error codes
All thrown as SlothletError with typed codes, translated across all 12 supported locales (en-us, en-gb, de-de, es-es, es-mx, fr-fr, pt-br, ru-ru, ja-jp, ko-kr, zh-cn, hi-in):
MODULE_MANIFEST_NOT_FOUND,MODULE_MANIFEST_INVALID,MODULE_MANIFEST_UNKNOWN_FIELDMODULE_MANIFEST_NAME_MISMATCH,MODULE_MANIFEST_VERSION_MISMATCHMODULE_PATH_TRAVERSAL,MODULE_VERSION_UNSUPPORTEDMODULE_PACKAGE_NOT_FOUND,MODULE_RESERVED_MOUNTPATHMODULE_DUPLICATE_NAME_VERSION_MISMATCH,MODULE_MOUNT_COLLISION
Each has a matching HINT_MODULE_* entry for actionable remediation guidance.
🐛 Bug Fixes
EventEmitter context — multi-registration tracking now matches Node's count semantics
src/lib/helpers/eventemitter-context.mjs is the shim that threads AsyncLocalStorage context across EventEmitter listener boundaries when runtime: "async". Its tracking map was keyed (emitter, event, originalListener) → wrappedListener — one slot per listener, holding the latest wrapper. Node's EventEmitter allows the same listener function reference to be added multiple times via repeated on(event, fn), and each registration must be removed by a corresponding removeListener(event, fn). The single-slot shape silently overwrote the prior wrapper, so a second on orphaned the first one — un-removable through the patched removeListener and visible to long-lived emitters as a slow MaxListenersExceededWarning accumulation under repeated add+remove cycles on the same listener (the field-reported symptom on connection-pool clients such as node-redis and the smithy HTTP handler).
The tracking shape is now (emitter, event, originalListener) → wrappedListener[]. on / addListener / prependListener push; the patched removeListener pops one (LIFO matches Node's own removal semantics). once / prependOnceListener install an inner wrapper that, after firing, removes itself from the tracking array by identity rather than by LIFO-pop — so a mixed once(fn) + on(fn) pair where the once runs first does NOT pop the on-wrapper. removeAllListeners iterates the wrapper arrays. Net effect: listenerCount() matches Node's, emit fires the listener the correct number of times, and N add+remove cycles return the count to zero with no orphaned wrappers.
Regression coverage at tests/vitests/suites/context/eventemitter-multi-register-and-isolation.test.vitest.mjs — 9 tests covering multi-registration count + emit-N-times, repeated add+remove cycles, mixed on+once in both orderings, two-emitter shared-reference isolation, error-emit cross-emitter isolation, a 10-emitter independence stress, and a 60-cycle long-run shaped after the field-reported workload. Three of the nine fail against the pre-fix tracking; all nine pass post-fix.
The original field report also hypothesized cross-emitter listener bleed (one library's emit triggering another library's error handler). The keying strategy — full (emitter, event, originalListener) triple — already isolates emitters; code review surfaced no mechanism for the bleed. The isolation tests are included as regression guards in case a future refactor weakens the keying.
MODULE_MANIFEST_INVALID reports the full @scope/pkg name
loadManifestRaw() in src/lib/helpers/module-discovery.mjs previously computed the error context's packageName via path.basename(path.dirname(manifestPath)). For a scoped package at node_modules/@scope/pkg/slothlet.module.json, that returned only pkg — ambiguous in monorepos where the unscoped basename can match multiple packages. discoverModules() already reads package.json before calling loadManifestRaw() and has the validated pkg.name in scope at the call site; that value is now threaded through and used in the error context directly. Regression coverage added in the modules discovery suite.
🧹 Chore
Dependency cleanup
- Removed
@html-eslint/parserfromdevDependencies— never loaded by the ESLint config; no HTML files in the repo to lint. - Removed three
overridesentries (rollup ^4.59.0,minimatch ^10.2.3,rolldown 1.0.0-rc.17) verified as no-ops against current upstream constraints.npm lsconfirms identical resolved versions with or without the overrides;npm run coveragepasses green post-removal.
Stale v3-issue doc retired
docs/v3-issues/tag-system-metadata-enforcement.md was deleted — the honor-system _fromLifecycle flag it documented was replaced in an earlier release by the LIFECYCLE_TOKEN capability-token pattern at src/lib/handlers/lifecycle-token.mjs. The issue is resolved; the doc was outdated.
New deferred v3-issues filed
Two v3-issue docs added to track design decisions deferred during the module discovery work:
docs/v3-issues/module-discovery-error-channel.md— the throw-vs-event convention for module discovery errors. Current implementation throws (consistent with the rest of slothlet); revisit if telemetry consumers ask for an event channel.docs/v3-issues/test-suite-restructure-targeting-and-runtime.md— collision-mode matrix expansion. Currently deferred because doubling the matrix (16 configs) would push test runtime past 40 minutes; needs a broader layered-matrix + per-subsystem-targeted-runner restructure first.
CI / Workflows polish (v4 framework adoption)
Three iterative workflow fixes landed during release-PR validation following the v4 framework adoption:
- Wire
ci.ymlfor v4 release-PR status checks. The previous configuration didn't post theRequired PR Checkstatus on release PRs fromnext/hotfixes→masterbecause those PR head SHAs are the bot'schore: bump versioncommit, which the upstreamworkflow-ci.ymlcommit-gate filter skipped. - Regenerate
package-lock.jsonfor rolldown native binding. CI Linux runners failed with rolldown'sCannot find native bindingerror tied to npm's optional-dependencies hoisting bug (npm #4828). Regenerated the lockfile so the native binding installs nested under the optional dep entry. - Default
lts_only_matrix=true. rolldown's transitiveengines: "^20.19.0 || >=22.12.0"excludes Node 21 and 23, so the CI matrix is constrained to LTS even-numbered majors.
Wired the v4 release-notify companion workflow (release-notify.yml) — fires on release:published and dispatches to whichever of DISCORD_RELEASES_PUBLIC_WEBHOOK / SLACK_RELEASES_PUBLIC_WEBHOOK / GENERIC_RELEASES_PUBLIC_WEBHOOK are present. Public/private visibility auto-detected from the repo; no per-repo config file.
Corrected update-major-version-tags.yml workflow secret reference — was passing a non-existent BOT_APP_ID: ${{ secrets.CLDMV_BOT_APP_ID }} (this repo's bot creds are CLDMV_BOT_APP_CLIENT_ID + CLDMV_BOT_APP_PRIVATE_KEY); input name corrected to BOT_APP_CLIENT_ID and the secret reference updated. The same bug exists in the upstream CLDMV/.github v4 template and needs an independent upstream fix.
Iterative typings + docs + i18n polish during PR validation
Multiple PR-review passes against the v3.8.0 surface landed source/docs/schema/i18n corrections without changing runtime behavior:
SlothletAPItypedef surfacesmodules.*andmetadata.getForto TS consumers. Previously had no entry for either, so TS users hit a property-does-not-exist error onapi.slothlet.api.modules.addDiscovered(). Source JSDoc now declares all 9modules.*methods as individual@propertyentries (autocomplete-ready) plus the full inline definition formetadata.getFor.AddModuleOptions.collisionMode/AddModulesOptions.collisionModenarrowed to a string-literal union. JSDoc previously typed both as{string}and documented the default as"error". Neither matched runtime, which usesDEFAULT_MODULE_COLLISION_MODE = "merge"and accepts a six-value literal set. JSDoc now narrows to{"skip"|"warn"|"replace"|"merge"|"merge-replace"|"error"}with"merge"as the documented default.MountResultJSDoc declaresversionConfig. Runtime already returnedversionConfig: { version, default }|nullon every mount; the typedef omitted it. Added the@propertyline so TS consumers see the field.docs/LIFECYCLE.mdmodules:*event contracts reframed peronFailuremode. Previous text claimedmodules:loaded"always fires exactly once per call" andmodules:mount-completefailures "surface only inmodules:loaded.failed[]" — both broken underonFailure: "throw"/"rollback", where the call throws beforemodules:loadedever fires. Rewrote the section to enumerate per-mode behavior:mount-completestill fires for every prior success in throw/rollback paths, butmodules:loadedis skipped on those paths. Hosts that need a settled-regardless signal should usebest-effortor wrap in try/catch.schemas/slothlet.module.schema.jsoncorrections.prioritydescription said "Ties broken alphabetically by name"; the defaultmodule-sort.mjscomparator ties onpackageNamefromDiscoverResult. Updated to "bypackageName".permissionsdescription referenced a non-existentaddModules({ applyModulePermissions: true })option; rewrote to describe the actual flow — manifest entries land in per-module metadata under_module.manifest.permissions, retrievable viametadata.getFor(), and the host applies them by callingapi.slothlet.permissions.addRule()for each entry.HINT_MODULE_MOUNT_COLLISIONcorrected across all 12 locales. Previously referred tocollisionModeas a "discovery option" — wrong API surface; it's anAddModuleOptions/AddModulesOptionsfield. Rewrote per-locale to referenceaddModule()/addModules()directly, with translations native to each language.- Test + doc micro-fixes. Module sort test single-element copy assertion (
expect(out).not.toBe([r])always passes since[r]creates a fresh literal) now stores input in a const for proper reference comparison;docs/v3-issues/test-suite-restructure-targeting-and-runtime.mdrelative links re-anchored to repo root;src/lib/builders/api_builder.mjsJSDoc comment redirected from a non-existentreference/plugin-discovery-mount-review.mdtodocs/MODULE-DISCOVERY.md.
📚 Documentation
- NEW: docs/MODULE-DISCOVERY.md — canonical reference for the module discovery + mount pipeline (manifest format, all 9 methods, options, error codes, lifecycle events, multi-version routing).
- docs/METADATA.md — added
getFor()documentation in the runtime metadata API section. - docs/LIFECYCLE.md — added the "Module Discovery Events" section with full event payloads.
- docs/VERSIONING.md — added cross-link noting that the module discovery system uses
versionConfigfor multi-version mounts. - docs/MODULE-STRUCTURE.md — added cross-link to MODULE-DISCOVERY.md in the See Also section.
- REFERENCE.md — added entries for the new MODULE-DISCOVERY.md doc and the canonical JSON Schema.
🧪 Tests
- New
tests/vitests/suites/modules/directory with 7 test files (197 tests) covering manifest validation, discovery, sort, the ModuleManager handler, lifecycle events, multi-version mounting, and targeted coverage-gap branches. - All four module-discovery + mount source files (
module-manager.mjs,module-discovery.mjs,module-sort.mjs,module-manifest-validator.mjs) ship at 100% line/branch/function/statement coverage. Two defensive guards that cannot be reached from any present-day caller (pickHighestSemversingle-element fast-path,deepFreezeprimitive/frozen short-circuit) carry/* v8 ignore next */markers with inline contract comments naming the upstream invariant that makes them unreachable. - New
api_tests/fixture trees:api_test_modules_npm/,api_test_modules_folder/,api_test_modules_malformed/,api_test_modules_versioned/,api_test_modules_legacy/,api_test_modules_dupversion/,api_test_modules_bad_pkgjson/. Each carries the package.json +slothlet.module.json(or override locator) plus minimal apiDir contents needed to exercise the corresponding code path. .gitignoreupdated to allowapi_tests/**/node_modules/so the fixture trees ship with the repo.
🔧 Internal
src/lib/handlers/module-manager.mjs— newModuleManagerhandler (auto-registered viaslothletProperty = "moduleManager"). Owns the discovery cache and mount-tracking state. CallsapiManager.addApiComponent()handler-to-handler for mounts.src/lib/helpers/module-manifest-validator.mjs— pure validation function; performs no I/O.src/lib/helpers/module-discovery.mjs— pure async discovery function; performs filesystem I/O but no mount.src/lib/helpers/module-sort.mjs— pure sort function.src/lib/builders/api_builder.mjs— added theapi.slothlet.api.modules.*namespace block that delegates to theModuleManagerhandler.
Upgrade notes
No action required. The new surface is purely additive and opt-in.
Hosts that want to adopt it:
- Install slothlet module packages as normal npm dependencies.
- Each module ships its own
slothlet.module.jsonat the package root (see docs/MODULE-DISCOVERY.md for the format). - In the host's startup code, call
await api.slothlet.api.modules.addDiscovered({ scanRoot: process.cwd() })afterslothlet({...})returns. - Optional: subscribe to
modules:loadedviaapi.slothlet.lifecycle.on()for a "ready" signal.
That's it.
{ "$ref": "@cldmv/slothlet/schemas/slothlet.module.schema.json" }