v3.11.0
release: v3.11.0 - load non-base locales from the optional… (#163)
Slothlet v3.11.0 Changelog
Release Date: June 2026
Release Type: Minor
Branch: release/3.11.0
Overview
Version 3.11.0 carves slothlet into a lean core plus two optional satellite packages, and changes how the loader treats hidden entries. The headline is the satellite split (#155): the core package now ships only the en-us base locale and thin re-export type stubs, while the other eleven locales move to the optional @cldmv/slothlet-i18n pack and the full TypeScript declarations move to the optional @cldmv/slothlet-types package. A files-negation trims the non-base locales from the published tarball. At runtime a non-base locale resolves in tiers — the installed pack first (Node import.meta.resolve / browser importmap), then the in-repo source under the slothlet-dev condition, then a warn-and-fall-back to en-us. Both satellites are declared as optional peer dependencies pinned to ^3.11.0 and are carved from this single repository by a CI build step (the packaging/ scaffold plus tools/build/build-subpackages.mjs).
The second feature reworks the loader's hidden-entry handling (#155). Dot- and double-underscore-prefixed names (. and __) are now hidden by default for folders as well as files — previously only __-prefixed files were skipped, so a dot-folder surfaced under its sanitized name. The unreleased ignore option is renamed to hidden and extended to match files as well as folders via a glob or array of globs evaluated gitignore-style (ordered, last matching rule wins, ! un-hides). A deprecated scanHiddenFolders escape hatch (off by default, removed in v4) restores scanning of dot/double-underscore folders for consumers who relied on the old behavior, emitting a translated deprecation warning.
The release also overhauls coverage infrastructure (#162). Coverage is now collected in a real Chromium browser via vitest browser mode and merged with node coverage using a location-based merge (tools/coverage/merge-browser-coverage.mjs), so browser-only code arms count toward the gate without corrupting node's map. In the course of that work the analyze audit gained a check that catches unbalanced v8 ignore start/stop ranges, which had silently truncated the coverage maps of the three largest source files — api_builder.mjs alone hid roughly 2,800 lines this way, reporting "100%" over barely a tenth of the file. Balancing those ranges restored honest coverage across the three files.
Compatibility. No API breaking changes. The satellite split is transparent to consumers: the non-base locales and full type declarations are now optional peer dependencies that resolve automatically when installed, and a project without them gets a clear, named error rather than a silent failure. The ignore loader option being renamed to hidden only affects the unreleased option name, not any shipped API. The scanHiddenFolders option is the one behavior toggle to note, and it exists only to restore the pre-3.11.0 dot/double-underscore folder behavior (see Upgrade notes).
✨ Features
Satellite packages: optional i18n pack and types package (#155)
Core no longer carries the full locale set or its own declarations.
@cldmv/slothlet-i18n(optional locale pack). The core package ships only theen-usbase locale; afiles-negation inpackage.jsontrims the other eleven from the published tarball. They live in@cldmv/slothlet-i18n. At runtime a non-base locale resolves in three tiers: the installed pack (Nodeimport.meta.resolve/ browser importmap), then the internal source files (present under theslothlet-devcondition), then a warn plusen-usfallback.generateImportMapenumerates the pack's locales when it is installed, and core declares@cldmv/slothlet-i18nas an optional peer dependency.@cldmv/slothlet-types(optional declarations package). Core no longer publishes its own.d.mtsdeclarations. Each typed export's production (default) condition resolves to a small stub undertypes/stub/that re-exports the matching subpath from the standalone@cldmv/slothlet-typespackage; theslothlet-devcondition still points at the rich in-repotypes/src/**. The runtime never loads the types package, and a TypeScript project without it gets a clearCannot find module '@cldmv/slothlet-types'error naming exactly what to install. Declaration maps ship in no package.- Carved by CI from one repository. Both satellites are built from this single repo:
tools/build/build-subpackages.mjscomputes each satellite's export map from theslothlet-dev(src) paths and emits a generated aggregator,tools/build/build-typestubs.mjsgenerates one stub per declaration, and thepackaging/scaffold aligns the satellites with the CLDMV satellite-packages convention. Carved sub-package folders are sorted for a deterministic carve order, and the publish workflow ships both satellites. Both peers are pinned to^3.11.0.
Loader hidden option; ./__ hidden by default (#155)
The built-in hidden rule now applies to folders as well as files: .- and __-prefixed entries are skipped by the api scanner in both places. Previously only __-prefixed files were skipped, so a dot-folder surfaced under its sanitized name.
hiddenoption. The unreleasedignoreoption is renamed tohiddenand extended to hide files as well as folders — a glob or array of globs matched against each entry's path relative to the api base (extension-stripped for files). It works on initial load (slothlet({ hidden })) and per-call onapi.slothlet.api.add(path, dir, { hidden }), is threaded through the sharedbuildAPI→ eager/lazy →scanDirectorychain, and is persisted in the cache entry so reload re-applies it. The matcher reusescompilePattern, so no new dependency was added.!negation, evaluated gitignore-style.hiddenglobs support!negation evaluated in order, last matching rule wins, so a!glob un-hides paths an earlier glob hid (e.g.["secret/**", "!secret/keep"]). Each glob body compiles as a positive matcher and!is applied as un-hiding, rather than the OR-of-matchers behavior that previously made a single!glob hide nearly the entire API.- Empty folders no longer create a phantom leaf. A folder that yields no files and no kept subfolders is no longer pushed, so empty or fully-hidden folders never create a phantom api leaf, in both eager and lazy modes.
scanHiddenFoldersescape hatch (deprecated, removed in v4). For consumers who relied on the old folder behavior, a deprecatedscanHiddenFoldersoption (off by default, available at init and per-call onapi.add) restores scanning of dot/double-underscore folders and emits aCONFIG_SCAN_HIDDEN_FOLDERS_DEPRECATEDwarning, translated in all twelve locales.
Browser-mode v8 coverage with location-based node merge (#162)
Coverage is now collected in a real Chromium browser via vitest browser mode and merged with node coverage. The naive istanbul union merge corrupted the report — node (vite SSR transform) and browser (vite client transform) produce per-file istanbul maps that are not byte-identical, so union-merging invented phantom entries and turned covered lines into false-uncovered. The rewritten merge (tools/coverage/merge-browser-coverage.mjs) is location-based: it keeps node's map and totals exactly and, for each node statement/function/branch, adds the browser hit-count from the entry at the same source location (branches matched by declaration location plus arm count, arm-hits transferred by index). Nothing node did not already have can be added, so totals cannot inflate; the browser only fills arms node cannot reach. The browser run is scoped to only the arm-bearing files so unrelated files never enter the merge, and Chromium is auto-installed for dev via precoverage:browser / pretest:browser hooks. The browser coverage run executes under --conditions=slothlet-dev so it is self-contained with no dependency on a built dist/.
analyze catches unbalanced v8 ignore start/stop ranges (#162)
A missing stop — or a nested second start before the first closes — leaves ast-v8-to-istanbul's ignore-depth counter stuck above zero, silently excluding everything to end-of-file from the coverage map. The file then reads as fully covered while large regions go untracked. npm run analyze now scans src/ for the three failure modes — a nested start, a stray stop, and a start left unclosed at EOF — reporting file:line for each as a MUST-FIX audit item. It flagged four pre-existing issues on introduction (api_builder.mjs L336 unclosed plus L377 nested, api-manager.mjs L663 nested, slothlet.mjs L578 nested), all fixed in this release (see Bug Fixes).
🐛 Bug Fixes
Coverage: unbalanced v8-ignore ranges truncated the three largest files (#162)
The malformed ignore ranges flagged by the new analyze check left ast-v8-to-istanbul's depth counter stuck above zero, silently excluding everything to end-of-file from the coverage map — api_builder.mjs's reported "100%" actually covered only about a tenth of the file, and api-manager.mjs and slothlet.mjs truncated the same way. The fixes: in api_builder.mjs, add the missing stop after assertNamespaceAccess's runtimeContext line (the start at L336 opened the defensive ?? block but never closed it); in api-manager.mjs and slothlet.mjs, remove the redundant inner start/stop pairs nested inside an already-ignored outer range (the nested second start is what derailed the converter). The same line-ranges stay ignored, now non-nested and balanced — with the maps no longer truncated, the previously-hidden code was then covered or honestly ignored to keep the gate honest.
UnifiedWrapper: arrays mangled during composition (#155)
A leaf's exported nested arrays were mangled during composition: an array of objects became an array-like {0,1,length:0} (Array.isArray false, not JSON-serializable) and an array of primitives was dropped, because the wrapper proxy did not faithfully represent arrays and toJSON yielded undefined. The wrapper now stays in place but is transparent to the outside — array impls get an array proxy target, getPrototypeOf returns Array.prototype so Array.isArray, length, indexing, iteration, spread and array methods resolve faithfully; toJSON returns the faithful reconstructed data (with reload-only metadata stripped) so any wrapped value is JSON-serializable. A follow-up refinement makes array elements full first-class UnifiedWrapper nodes rather than raw values, so slothlet's whole model applies uniformly at arr[i] and deeper — api.add into an element, permission gating on arr[i].method, context, hooks, and reload all work as they do for object properties, while the array itself stays transparent.
api-manager: removing a root mount deleted foreign-owned siblings (#155)
A module mounted at the root — via api.add("", …), the base module's . endpoint, or an unrecorded endpoint — can share its container with paths a different module added beneath it. removeApiComponent's children-check scanned only the removed module's own uniquePaths, so it missed those foreign siblings and deleted the shared container, taking the sibling with it (both eager and lazy). A full-ownership-registry foreign-descendant check is now scoped to root mounts only, for both the pathsToDelete children-check and the mount-root container deletion; a normal component removal still deletes its whole subtree wholesale.
api-manager: deep-mount api.remove deleted the wrong subtree (#155)
Removing a module mounted under a deep path (e.g. api.remove("data.items.0.extension")) tore down the whole top-level ancestor (data) instead of just the mounted leaf, because the moduleID-removal branch computed the mount root as the top-level segment of the deepest owned path and ran an unconditional delete api[segment]. The real mount root is now derived from the module's recorded mount endpoint (longest-common-prefix of owned paths as a fallback), and operationHistory records the correct mount root so a reload replay no longer over-removes. This also fixed a latent ownership bug in modes-processor, which registered each leaf's ownership at its folder-relative path instead of the full mount path.
api-manager: lazy parent not materialized before the child-collision probe (#155)
api.add() of a nested path under a lazy sub-container created by a root ("") mount dropped the intermediate namespace level — the added module's leaves were flattened directly onto the sub-container. setValueAtPath() probes parent[finalKey] to detect a collision, but when the parent is a lazy, unmaterialized wrapper its get trap fabricates a deferred "waiting proxy" that the merge logic mistook for a real existing child. The parent is now materialized before the probe so an absent key reads back as undefined (no collision) and the value is assigned as a genuine nested namespace.
api-manager: base module's . endpoint crashed moduleID removal (#155)
removeApiComponent fed the module's recorded mount endpoint straight into normalizeApiPath(). For a base/core module the endpoint is recorded as . (it owns the whole tree), which split to empty segments and threw INVALID_CONFIG_API_PATH_INVALID, crashing moduleID-based removal of base/root mounts and skipping the longest-common-prefix fallback meant to handle exactly this case. The . endpoint is now treated as the root ("") so that fallback derives the real mount root.
manifest: i18n-pack base derived correctly from a versioned/custom base (#155)
The i18n-pack base was derived by replacing a base ending exactly in @cldmv/slothlet/, so for the documented versioned CDN base (https://cdn.example.com/@cldmv/slothlet@3/) the pattern did not match and the pack's locale entries pointed back into the slothlet base. The match now accepts an optional @version suffix on the final @cldmv/slothlet segment and carries it onto the pack name. Relatedly, when the base has no @cldmv/slothlet segment to swap (a custom base served elsewhere, e.g. at /), the swap is a no-op, so generateImportMap now only enumerates pack locales when the swap actually fired and otherwise omits them, letting the consumer supply a pack base explicitly.
i18n: require the pack locale file to exist before using its resolved path (#155)
i18n_resolvePackPath mapped @cldmv/slothlet-i18n/language/<lang>.json through the pack's ./language/* wildcard export but never checked the target existed, so with the pack installed import.meta.resolve returned a path for any locale name — a locale the pack does not ship wrongly read as present, and the env-language fallback never reached the base locale. The resolved path is now guarded with fs.existsSync, so a resolvable-but-missing pack locale falls through to the internal copy.
platform: loadJsonBrowser honors the object|null contract (#162)
loadJsonBrowser returned mod.default directly; it now coalesces to null to honor the documented Promise<object|null> contract. A JSON module always default-exports its parsed value, so for a real locale the ?? null arm is unreachable and carries an honest /* v8 ignore */ matching this branch's existing verified-unreachable browser-arm ignores.
i18n: keep import.meta out of statement position for CodeQL (#155)
CodeQL's JS extractor mis-parses a statement that starts with import.meta (it reads import as the start of an import declaration). The throw-test in i18n_localeRefs is folded into the return expression (return Boolean(import.meta.resolve(s))) — behavior-identical, since the call still throws when the specifier cannot be resolved — and the file now parses cleanly, clearing the CodeQL syntax-error alert on the PR.
🧪 Tests
- #155 — fixtured-pack tests cover i18n pack tier-1 resolution and the pack failure paths; the runner stages the
@cldmv/slothlet-i18npack and sandboxes typestub validation. A consumer-proof test (tests/validate-typestubs.mjs) type-checks through the stubs with the satellite fixtured and asserts the named-package error without it. Loader tests cover the bare-!hidden entry (no-op) and the gitignore-style!un-hiding regression. api-manager regressions cover root-mount foreign-sibling preservation, deep-mount remove preserving siblings/ancestors, lazy-parent nested mounts, and the base-module.-endpoint removal crash. UnifiedWrapper regressions (eager + lazy) cover array identity/length/elements/JSON, element-is-wrapper apiPath, arrays-of-arrays, arrays of callable wrapped elements,api.addmounting under an array element, permission deny on an array element's method, and a frozen-arrayapi.addregression. - #162 — browser-mode coverage migrates the platform/eventemitter/api_builder browser arms and adds a compose harness plus CI wiring; the location-based merge is hardened against map divergence, and the previously-hidden code exposed by the v8-ignore fix is covered with real node tests or honestly ignored where verified unreachable.
- Coverage gaps — closed the two coverage gaps the
nextmerge exposed; restored navigator stubs to their original own-property state to prevent cross-test leakage; and materialized the quad-prefix wrapper before asserting its keys for lazy-mode determinism. - Full coverage gate green: ~100% statements / branches / functions / lines, now including the browser arms and the previously-truncated regions of the three largest files.
📚 Documentation
- NEW: docs/changelog/v3/v3.11.0.md — this changelog.
- docs/TYPESCRIPT.md — the
@cldmv/slothlet-typesdeclarations package and how the stubs resolve. - docs/CONFIGURATION.md — the
hiddenoption (globs,!negation, file/folder matching) and the deprecatedscanHiddenFoldersescape hatch. - README — i18n pack install/resolution, the
api.addhidden options, a hidden-entries section, a corrected language list, and a refreshed What's New. Documentation clarifies that onlyen-usis built into core and that the other locales ship via the optional pack. - The
packaging/scaffold encodes the satellite-packages convention used to carve@cldmv/slothlet-i18nand@cldmv/slothlet-types.
🔧 Tooling
- Dependabot groups the
@vitestfamily (#171).@vitest/browserpeersvitestexactly, so bumpingvitest+@vitest/coverage-v8without the browser packages (or vice-versa) left the lockfile with mismatched exact peers andnpm cifailing withERESOLVE. Agroupsentry now bumps the whole@vitestfamily (vitest+@vitest/*) together in a single PR so the exact-peer versions can never diverge. The lock is aligned to a consistent@vitest4.1.9. - Release PR opens on bot merges (#170). The release-PR workflow no longer gates on
github.actor, so the persistent release PR is opened on bot merges as well. - Build pipeline.
build:typestubsis wired into the build pipelines,types/is cleared beforetscso generation drops orphans, and carved sub-package folders are sorted for a deterministic carve order. The publish workflow adds both satellites to its extra-packages list. - Coverage scripts. New
coverage:browser/test:browserscripts run under--conditions=slothlet-devand auto-install Chromium viaprecoverage:browser/pretest:browserhooks; the node↔browser merge lives intools/coverage/merge-browser-coverage.mjs.
Dependency updates
playwright1.60.0 → 1.61.1 (#167)globals17.6.0 → 17.7.0 (#166)@cldmv/vitest-runner1.1.0 → 1.2.0 (#165)@vitest/coverage-v84.1.8 → 4.1.9 (#168), with the@vitestfamily aligned to4.1.9acorn8.16.0 → 8.17.0 (#160)prettier3.8.3 → 3.8.4 (#152)@types/node25.9.1 → 25.9.3 (#161)@eslint/css1.2.0 → 1.3.0 (#154)
Upgrade notes
- Satellites are optional and resolve automatically. Installing
@cldmv/slothlet-i18nmakes the non-base locales available again; without it, onlyen-usships and a request for another locale warns and falls back toen-us. Installing@cldmv/slothlet-typesrestores the full TypeScript declarations; without it, a TypeScript project gets a clearCannot find module '@cldmv/slothlet-types'error naming what to install. Both are pinned to^3.11.0— keep them in lockstep with core across upgrades. ignoreis nowhidden. The (unreleased)ignoreloader option is renamed tohiddenand now matches files as well as folders. If you were tracking the unreleased option, rename it;hiddenglobs are evaluated gitignore-style, so order matters and!un-hides.- Dot/double-underscore folders are hidden by default.
.- and__-prefixed folders are now skipped by the scanner (previously only__-prefixed files were). If you relied on such a folder surfacing in the API under its sanitized name, set the deprecatedscanHiddenFoldersoption at init or per-call onapi.addto restore the old behavior — it warns and is removed in v4. Prefer renaming the folder so it is not hidden. - Empty / fully-hidden folders no longer create a leaf. A folder with no kept files or subfolders no longer produces a phantom api leaf. If you depended on an empty folder appearing in the tree, add a file to it.