# 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 the `en-us` base locale; a `files`-negation in `package.json` trims 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 (Node `import.meta.resolve` / browser importmap), then the internal source files (present under the `slothlet-dev` condition), then a warn plus `en-us` fallback. `generateImportMap` enumerates the pack's locales when it is installed, and core declares `@cldmv/slothlet-i18n` as an optional peer dependency. - **`@cldmv/slothlet-types` (optional declarations package).** Core no longer publishes its own `.d.mts` declarations. Each typed export's production (`default`) condition resolves to a small stub under `types/stub/` that re-exports the matching subpath from the standalone `@cldmv/slothlet-types` package; the `slothlet-dev` condition still points at the rich in-repo `types/src/**`. The runtime never loads the types package, and a TypeScript project without it gets a clear `Cannot 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.mjs` computes each satellite's export map from the `slothlet-dev` (src) paths and emits a generated aggregator, `tools/build/build-typestubs.mjs` generates one stub per declaration, and the `packaging/` 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. - **`hidden` option.** The unreleased `ignore` option is renamed to `hidden` and 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 on `api.slothlet.api.add(path, dir, { hidden })`, is threaded through the shared `buildAPI` → eager/lazy → `scanDirectory` chain, and is persisted in the cache entry so reload re-applies it. The matcher reuses `compilePattern`, so no new dependency was added. - **`!` negation, evaluated gitignore-style.** `hidden` globs 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. - **`scanHiddenFolders` escape hatch (deprecated, removed in v4).** For consumers who relied on the old folder behavior, a deprecated `scanHiddenFolders` option (off by default, available at init and per-call on `api.add`) restores scanning of dot/double-underscore folders and emits a `CONFIG_SCAN_HIDDEN_FOLDERS_DEPRECATED` warning, 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/.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` 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-i18n` pack 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.add` mounting under an array element, permission deny on an array element's method, and a frozen-array `api.add` regression. - **#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 `next` merge 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](./v3.11.0.md) — this changelog. - [docs/TYPESCRIPT.md](../../TYPESCRIPT.md) — the `@cldmv/slothlet-types` declarations package and how the stubs resolve. - [docs/CONFIGURATION.md](../../CONFIGURATION.md) — the `hidden` option (globs, `!` negation, file/folder matching) and the deprecated `scanHiddenFolders` escape hatch. - README — i18n pack install/resolution, the `api.add` hidden options, a hidden-entries section, a corrected language list, and a refreshed **What's New**. Documentation clarifies that only `en-us` is 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-i18n` and `@cldmv/slothlet-types`. --- ## 🔧 Tooling - **Dependabot groups the `@vitest` family (#171).** `@vitest/browser` peers `vitest` exactly, so bumping `vitest` + `@vitest/coverage-v8` without the browser packages (or vice-versa) left the lockfile with mismatched exact peers and `npm ci` failing with `ERESOLVE`. A `groups` entry now bumps the whole `@vitest` family (`vitest` + `@vitest/*`) together in a single PR so the exact-peer versions can never diverge. The lock is aligned to a consistent `@vitest` `4.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:typestubs` is wired into the build pipelines, `types/` is cleared before `tsc` so 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:browser` scripts run under `--conditions=slothlet-dev` and auto-install Chromium via `precoverage:browser` / `pretest:browser` hooks; the node↔browser merge lives in `tools/coverage/merge-browser-coverage.mjs`. ### Dependency updates - `playwright` 1.60.0 → 1.61.1 (#167) - `globals` 17.6.0 → 17.7.0 (#166) - `@cldmv/vitest-runner` 1.1.0 → 1.2.0 (#165) - `@vitest/coverage-v8` 4.1.8 → 4.1.9 (#168), with the `@vitest` family aligned to `4.1.9` - `acorn` 8.16.0 → 8.17.0 (#160) - `prettier` 3.8.3 → 3.8.4 (#152) - `@types/node` 25.9.1 → 25.9.3 (#161) - `@eslint/css` 1.2.0 → 1.3.0 (#154) --- ## Upgrade notes - **Satellites are optional and resolve automatically.** Installing `@cldmv/slothlet-i18n` makes the non-base locales available again; without it, only `en-us` ships and a request for another locale warns and falls back to `en-us`. Installing `@cldmv/slothlet-types` restores the full TypeScript declarations; without it, a TypeScript project gets a clear `Cannot 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. - **`ignore` is now `hidden`.** The (unreleased) `ignore` loader option is renamed to `hidden` and now matches files as well as folders. If you were tracking the unreleased option, rename it; `hidden` globs 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 deprecated `scanHiddenFolders` option at init or per-call on `api.add` to 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.