From 55cef13890afdef2c3452ebc20ac90966943d2e8 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 04:44:37 -0700 Subject: [PATCH 01/15] fix: let a moduleID containing a colon round-trip through add/remove/reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:` is slothlet's internal composite "moduleID:apiPath" separator. A user (or internal) moduleID that itself contained a colon — e.g. a `vine:abc` namespaced convention — mounted and resolved fine and add() returned the exact id, but the id could not be removed or reloaded by that id: removeApiComponent split the argument on `:` and looked up only the first segment, and child wrappers of a multi-child mount were attributed to the truncated base, so remove() left the subtree behind. The mount was silently unmanageable. - removeApiComponent now resolves an exact registered moduleID verbatim before falling back to the split-based "_" / composite heuristic, and the no-ownership branch no longer truncates the id on ":". - Base-id recovery no longer splits the composite: tagSystemMetadata stores the raw owning-module id as baseModuleID alongside the composite moduleID, and the two unified-wrapper recovery sites (impl:changed event, child attribution) read it verbatim. This also corrects internal `versionDispatcher:` base ids, which were previously truncated to `versionDispatcher`. The composite stays the per-path user-metadata key; only the fragile colon-split recovery is removed. Adds colon-moduleID round-trip coverage (remove, leaves, reload, reload-then-remove, synthetic, multi-colon, hyphen control). Fixes #303 --- src/lib/handlers/api-manager.mjs | 36 +++--- src/lib/handlers/metadata.mjs | 7 +- src/lib/handlers/unified-wrapper.mjs | 12 +- ...pi-manager-colon-module-id.test.vitest.mjs | 114 ++++++++++++++++++ 4 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 0b365986..f80a1201 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2365,20 +2365,27 @@ export class ApiManager extends ComponentBase { let moduleID; if (this.slothlet.handlers.ownership) { - // Extract moduleID from full moduleID format "moduleID:path" if present - const candidateModuleID = pathOrModuleId.split(":")[0]; - - // Try to find a matching moduleID - // This allows api.remove("removableInternal") to remove "removableInternal_abc123" - // Walk from the end to prefer the most recently registered module when multiple match, - // as stale entries from prior add/remove cycles may linger due to async lazy materialization. const registeredModules = Array.from(this.slothlet.handlers.ownership.moduleToPath.keys()); let matchingModule = null; - for (let i = registeredModules.length - 1; i >= 0; i--) { - const candidate = registeredModules[i]; - if (candidate === candidateModuleID || candidate.startsWith(`${candidateModuleID}_`)) { - matchingModule = candidate; - break; + + // Verbatim first: the exact id add() returned must resolve to itself. A user moduleID may + // legitimately contain ':' — slothlet's internal composite "moduleID:apiPath" separator — so + // splitting the argument to recover a "base" is only a fallback, never the first attempt (#303). + if (this.slothlet.handlers.ownership.moduleToPath.has(pathOrModuleId)) { + matchingModule = pathOrModuleId; + } else { + // Fallback: strip a trailing internal "moduleID:apiPath" composite and match by base or the + // auto-generated "_" id — this allows api.remove("removableInternal") to remove + // "removableInternal_abc123". Walk from the end to prefer the most recently registered + // module when multiple match, as stale entries from prior add/remove cycles may linger due + // to async lazy materialization. + const candidateModuleID = pathOrModuleId.split(":")[0]; + for (let i = registeredModules.length - 1; i >= 0; i--) { + const candidate = registeredModules[i]; + if (candidate === candidateModuleID || candidate.startsWith(`${candidateModuleID}_`)) { + matchingModule = candidate; + break; + } } } @@ -2398,10 +2405,11 @@ export class ApiManager extends ComponentBase { } } } else { - // No ownership tracking - use old heuristic (dots = apiPath) + // No ownership tracking - use old heuristic (dots = apiPath). Use the id verbatim: a + // user moduleID may contain ':' and must not be truncated (#303). const isModuleId = !pathOrModuleId.includes("."); apiPath = isModuleId ? null : pathOrModuleId; - moduleID = isModuleId ? pathOrModuleId.split(":")[0] : null; + moduleID = isModuleId ? pathOrModuleId : null; } if (!this.slothlet || !this.slothlet.isLoaded) { throw new this.SlothletError("INVALID_CONFIG_NOT_LOADED", { diff --git a/src/lib/handlers/metadata.mjs b/src/lib/handlers/metadata.mjs index ec04aaf4..e87e9ab1 100644 --- a/src/lib/handlers/metadata.mjs +++ b/src/lib/handlers/metadata.mjs @@ -237,7 +237,10 @@ export class Metadata extends ComponentBase { return; } - // Construct full moduleID as "moduleID:apiPath/with/slashes" + // Construct full moduleID as "moduleID:apiPath/with/slashes". This composite stays the + // per-path user-metadata key, but the raw base id is stored separately (baseModuleID) so + // consumers recover the owning module verbatim instead of splitting on ":" — a user (or + // internal) base id may itself contain a colon, which splitting truncated (#303). let fullModuleID = systemData.moduleID; if (systemData.apiPath && systemData.moduleID) { const apiPathSlashes = systemData.apiPath.replace(/\./g, "/"); @@ -258,6 +261,8 @@ export class Metadata extends ComponentBase { sourceFolder: sourceFolder, apiPath: systemData.apiPath, moduleID: fullModuleID, + // Raw owning-module id, verbatim — the colon-safe source for base recovery (#303). + baseModuleID: systemData.moduleID, taggedAt: Date.now() }); diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index b4d2b332..9780dd2a 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -1206,7 +1206,7 @@ export class UnifiedWrapper extends ComponentBase { // caller now passes a string moduleID (or null) — the stale-signature caller that passed // the slothlet instance was fixed in #274 (a7a711f), so the former object-coercion guard // here is dead and was removed with it. - const extractedModuleId = moduleID || (wrapperMetadata?.moduleID ? wrapperMetadata.moduleID.split(":")[0] : null); + const extractedModuleId = moduleID || wrapperMetadata?.baseModuleID || null; this.slothlet.handlers.lifecycle.emit("impl:changed", { apiPath: this.____slothletInternal.apiPath, @@ -2023,11 +2023,11 @@ export class UnifiedWrapper extends ComponentBase { // code used the child VALUE's own moduleID whenever it carried its own metadata, which // attributed re-mounted base leaves to base_slothlet and made api.remove() roll them back // instead of deleting them (impl:removed never fired). - if (parentMetadata?.moduleID) { - const colonIndex = parentMetadata.moduleID.indexOf(":"); - // `colonIndex > 0` is always true because moduleIDs use "id:apiPath" format; no-colon fallback is unreachable. - /* v8 ignore next */ - childModuleId = colonIndex > 0 ? parentMetadata.moduleID.substring(0, colonIndex) : parentMetadata.moduleID; + if (parentMetadata?.baseModuleID) { + // The raw base id, stored verbatim (colon-safe) — no longer recovered by splitting the + // composite "moduleID:apiPath" tag, which truncated a base id that itself contained a + // colon (a user `vine:abc` convention, or an internal `versionDispatcher:`) (#303). + childModuleId = parentMetadata.baseModuleID; } const childSourceFolder = childExistingMetadata?.sourceFolder || parentMetadata?.sourceFolder || null; diff --git a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs new file mode 100644 index 00000000..d94b1189 --- /dev/null +++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs @@ -0,0 +1,114 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs + * @Date: 2026-08-24T00:00:00-08:00 (1756022400) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview A moduleID containing a colon must round-trip through add/leaves/remove/reload (#303). + * + * @description + * `:` is slothlet's internal composite `moduleID:apiPath` separator, so a user-supplied moduleID + * containing a colon (e.g. a `vine:abc` namespaced convention) used to be un-removable: add() returned + * the id, but removeApiComponent split the argument on `:` and looked up only the first segment, so + * remove(id)/reload of that id could not find the mount. The id must now be stored and matched verbatim. + * + * @module tests/vitests/suites/api-manager/api-manager-colon-module-id + */ + +process.env.SLOTHLET_INTERNAL_TEST_MODE = "true"; + +import { describe, it, expect, afterEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +const EAGER_CONFIGS = [ + { name: "eager/hooks-on", config: { mode: "eager", runtime: "async", hook: { enabled: true } } }, + { name: "eager/hooks-off", config: { mode: "eager", runtime: "async", hook: { enabled: false } } } +]; + +// Every id here is an accepted moduleID that a consumer might use; the colon ones are the regression, +// the hyphen one is the control that always worked, and the multi-colon one guards the "split on the +// first colon" assumption specifically. +const COLON_IDS = ["vine:abc", "plugin:opensearch:v1"]; +const CONTROL_ID = "vine-abc"; + +describe.each(EAGER_CONFIGS)("colon moduleID round-trips — $name", ({ config }) => { + let api; + + afterEach(async () => { + if (api?.shutdown) await api.shutdown(); + api = null; + await new Promise((r) => setTimeout(r, 30)); + }); + + it.each(COLON_IDS)("remove(id) removes a directory mount added under moduleID %s", async (moduleID) => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + const id = await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID }); + expect(id).toBe(moduleID); // add() hands back the exact id + expect(api.shopfront).toBeDefined(); + + // The exact id add() returned must remove the mount. + const removed = await api.slothlet.api.remove(id); + expect(removed).toBe(true); + expect(api.shopfront).toBeUndefined(); + }); + + it.each(COLON_IDS)("leaves(id) enumerates the owned paths for moduleID %s", async (moduleID) => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID }); + + // Resolution is by verbatim id — leaves must not throw API_LEAVES_UNKNOWN_MODULE and must + // return this mount's owned callable paths (host tooling call). + const paths = await api.slothlet.api.leaves(moduleID, { includePrivate: true }); + expect(Array.isArray(paths)).toBe(true); + expect(paths.length).toBeGreaterThan(0); + expect(paths.every((p) => p.startsWith("shopfront"))).toBe(true); + }); + + it.each(COLON_IDS)("reload(id) rebuilds a mount added under moduleID %s", async (moduleID) => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID }); + expect(api.shopfront).toBeDefined(); + + await expect(api.slothlet.api.reload(moduleID)).resolves.toBeUndefined(); + expect(api.shopfront).toBeDefined(); + }); + + it("remove(id) removes a synthetic (in-memory) mount added under a colon moduleID", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + const id = await api.slothlet.api.add("synth", () => 1, { moduleID: "vine:xyz" }); + expect(id).toBe("vine:xyz"); + expect(typeof api.synth).toBe("function"); + + expect(await api.slothlet.api.remove(id)).toBe(true); + expect(api.synth).toBeUndefined(); + }); + + it.each(COLON_IDS)("remove(id) still works after reload(id) for moduleID %s", async (moduleID) => { + // Guards against reload re-attributing the mount's children under a colon-truncated base id, + // which would leave remove() unable to find them afterward. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID }); + await api.slothlet.api.reload(moduleID); + expect(api.shopfront).toBeDefined(); + + expect(await api.slothlet.api.remove(moduleID)).toBe(true); + expect(api.shopfront).toBeUndefined(); + }); + + it("control: a hyphen moduleID still removes (no regression)", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + const id = await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID: CONTROL_ID }); + expect(id).toBe(CONTROL_ID); + expect(await api.slothlet.api.remove(id)).toBe(true); + expect(api.shopfront).toBeUndefined(); + }); +}); From 569858056cca9afdcf74f7c4a4bedbfed144d80e Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 06:39:42 -0700 Subject: [PATCH 02/15] fix: don't ':'-truncate a moduleID when resolving remove() fallback Address Copilot review on #306. The verbatim-first match resolves an exact registered id, but the fallback still did `pathOrModuleId.split(":")[0]`, which truncated on ':': `remove("vine:abc")` with only a "vine" module registered matched "vine" and wrongly removed it (returned true, tore down the wrong mount). Match the auto-generated "_" form of the WHOLE id instead of a ':'-truncated prefix, so a colon-containing id never collides with a shorter base. Adds a regression test asserting remove("vine:abc") leaves a registered "vine" mount untouched (and the real id still removes it). --- src/lib/handlers/api-manager.mjs | 17 ++++++++--------- .../api-manager-colon-module-id.test.vitest.mjs | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index f80a1201..1d1dc00e 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2369,20 +2369,19 @@ export class ApiManager extends ComponentBase { let matchingModule = null; // Verbatim first: the exact id add() returned must resolve to itself. A user moduleID may - // legitimately contain ':' — slothlet's internal composite "moduleID:apiPath" separator — so - // splitting the argument to recover a "base" is only a fallback, never the first attempt (#303). + // legitimately contain ':' — slothlet's internal composite "moduleID:apiPath" separator (#303). if (this.slothlet.handlers.ownership.moduleToPath.has(pathOrModuleId)) { matchingModule = pathOrModuleId; } else { - // Fallback: strip a trailing internal "moduleID:apiPath" composite and match by base or the - // auto-generated "_" id — this allows api.remove("removableInternal") to remove - // "removableInternal_abc123". Walk from the end to prefer the most recently registered - // module when multiple match, as stale entries from prior add/remove cycles may linger due - // to async lazy materialization. - const candidateModuleID = pathOrModuleId.split(":")[0]; + // Fallback: match the auto-generated "_" form of the WHOLE id — this allows + // api.remove("removableInternal") to remove "removableInternal_abc123". Match the full id + // verbatim, never a ':'-truncated prefix: splitting on ':' collided a lookup of "vine:abc" + // with a registered "vine" and wrongly removed it (#303). Walk from the end to prefer the + // most recently registered module when multiple match, as stale entries from prior + // add/remove cycles may linger due to async lazy materialization. for (let i = registeredModules.length - 1; i >= 0; i--) { const candidate = registeredModules[i]; - if (candidate === candidateModuleID || candidate.startsWith(`${candidateModuleID}_`)) { + if (candidate.startsWith(`${pathOrModuleId}_`)) { matchingModule = candidate; break; } diff --git a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs index d94b1189..17e0b9d0 100644 --- a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs @@ -111,4 +111,19 @@ describe.each(EAGER_CONFIGS)("colon moduleID round-trips — $name", ({ config } expect(await api.slothlet.api.remove(id)).toBe(true); expect(api.shopfront).toBeUndefined(); }); + + it("remove('vine:abc') must NOT collide with a registered 'vine' module (no ':'-prefix truncation)", async () => { + // Regression: resolving a moduleID must never truncate on ':' — removing an unregistered + // "vine:abc" once matched the registered base "vine" and wrongly removed it. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("shopfront", TEST_DIRS.API_TEST_MIXED, { moduleID: "vine" }); + expect(api.shopfront).toBeDefined(); + + expect(await api.slothlet.api.remove("vine:abc")).toBe(false); + expect(api.shopfront).toBeDefined(); // the "vine" mount survives + + // And the real id still removes it. + expect(await api.slothlet.api.remove("vine")).toBe(true); + expect(api.shopfront).toBeUndefined(); + }); }); From 1dc1060f087ea774ce1b8c8d51e42de045c5efee Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 07:00:47 -0700 Subject: [PATCH 03/15] feat: scope api.remove() to one module's node via an optional apiPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove(id) removes every path a module owns and remove(apiPath) removes that path's whole subtree regardless of which module owns each node, so neither can surgically remove a single module's node at a shared path — e.g. two modules mounted under the same namespace, or one moduleID reused across mounts. Add an optional second argument: remove(moduleID, apiPath) resolves the first argument strictly as a moduleID, verifies it owns apiPath, and removes only that node — sibling modules sharing the mount and the module's other mounts are left intact. Returns whether anything was removed; the single-argument forms are unchanged. Routes through the existing single-node removal path. --- docs/generated/API.md | 7 +- src/lib/builders/api_builder.mjs | 29 +++++- src/lib/handlers/api-manager.mjs | 22 +++++ src/slothlet.mjs | 2 +- .../api-manager-remove-scoped.test.vitest.mjs | 98 +++++++++++++++++++ 5 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs diff --git a/docs/generated/API.md b/docs/generated/API.md index 675a30ae..3da516f5 100644 --- a/docs/generated/API.md +++ b/docs/generated/API.md @@ -581,17 +581,18 @@ await api.slothlet.api.reload(); -#### api.slothlet.api.remove(pathOrModuleId) ⇒ Promise.<void> +#### api.slothlet.api.remove(pathOrModuleId, apiPath?) ⇒ Promise.<boolean> -Unmount an API module at runtime. +Unmount an API module at runtime. remove(id) removes every path the module owns; remove(apiPath) removes that path's whole subtree; the two-argument remove(moduleID, apiPath) removes only that module's single node at the path, leaving sibling modules sharing the mount and the module's other mounts intact. Resolves to whether anything was removed. **Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) | Param | Type | Description | | --- | --- | --- | | pathOrModuleId | string | | +| apiPath? | string | | -**Returns**: Promise.<void> +**Returns**: Promise.<boolean> **Example** ```javascript diff --git a/src/lib/builders/api_builder.mjs b/src/lib/builders/api_builder.mjs index 9ca527de..8bf71b65 100644 --- a/src/lib/builders/api_builder.mjs +++ b/src/lib/builders/api_builder.mjs @@ -803,16 +803,26 @@ export class ApiBuilder extends ComponentBase { /** * @param {string} pathOrModuleId - API path or module ID to remove. - * @returns {Promise} + * @param {string} [apiPath] - Optional mount path to scope removal to. When given, the first + * argument is treated as a moduleID and ONLY that module's node at `apiPath` is removed — + * other modules sharing the path, and that module's other mounts, are left untouched. Omit + * it for the whole-module (by id) or whole-subtree (by path) removal. + * @returns {Promise} True if something was removed, false if nothing matched. * @public * * @description - * Removes API modules by apiPath or moduleID from the current instance. + * Removes API modules by apiPath or moduleID from the current instance. `remove(id)` removes + * every path the module owns; `remove(apiPath)` removes that path's whole subtree; and the + * two-argument `remove(moduleID, apiPath)` removes only that module's single node at the path. * * @example - * await api.slothlet.api.remove("plugins.tools"); + * await api.slothlet.api.remove("plugins.tools"); // by api path + * @example + * await api.slothlet.api.remove("plugins-core"); // by module id (all its paths) + * @example + * await api.slothlet.api.remove("plugins-core", "plugins.tools"); // just that module's node */ - remove: async function slothlet_api_remove(pathOrModuleId) { + remove: async function slothlet_api_remove(pathOrModuleId, apiPath) { // Check if remove mutation is allowed if (!config.api?.mutations?.remove) { throw new slothlet.SlothletError("INVALID_CONFIG_MUTATIONS_DISABLED", { @@ -828,7 +838,16 @@ export class ApiBuilder extends ComponentBase { validationError: true }); } - return slothlet.handlers.apiManager.removeApiComponent(pathOrModuleId); + // The optional scoping path, when supplied, must be a string. + if (apiPath !== undefined && typeof apiPath !== "string") { + throw new slothlet.SlothletError("INVALID_ARGUMENT", { + argument: "apiPath", + expected: "string", + received: typeof apiPath, + validationError: true + }); + } + return slothlet.handlers.apiManager.removeApiComponent(pathOrModuleId, { scopedApiPath: apiPath }); }, /** diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 1d1dc00e..82318e28 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2359,6 +2359,11 @@ export class ApiManager extends ComponentBase { }); } + // Two-argument form remove(moduleID, apiPath): scope removal to a single node the module owns, + // rather than the whole module (by id) or whole subtree (by path). When set, pathOrModuleId is + // resolved strictly as a moduleID and only its node at `scopedApiPath` is removed. + const scopedApiPath = typeof options.scopedApiPath === "string" ? options.scopedApiPath : null; + // Detect if this is a moduleID or apiPath // Try moduleID first (more specific), then fall back to API path let apiPath = null; @@ -2391,6 +2396,10 @@ export class ApiManager extends ComponentBase { if (matchingModule) { // Found a moduleID match moduleID = matchingModule; + } else if (scopedApiPath !== null) { + // remove(moduleID, apiPath): the first argument must be a known moduleID — there is nothing + // to scope a removal to otherwise. + return false; } else { // No moduleID match, check if it's a valid API path const owner = this.slothlet.handlers.ownership.getCurrentOwner(pathOrModuleId); @@ -2417,6 +2426,19 @@ export class ApiManager extends ComponentBase { }); } + // Two-argument scoping: the moduleID resolved above must actually own the requested path, else + // there is nothing to remove. When it does, target exactly that node by handing (apiPath, moduleID) + // to the single-node removal path below — the same path a bare apiPath resolves to, but pinned to + // this module so a sibling module sharing the mount is left intact. + if (scopedApiPath !== null) { + const normalizedScoped = this.normalizeApiPath(scopedApiPath).apiPath; + const ownedPaths = this.slothlet.handlers.ownership?.moduleToPath?.get(moduleID); + if (!ownedPaths || !ownedPaths.has(normalizedScoped)) { + return false; + } + apiPath = normalizedScoped; + } + if (apiPath && moduleID) { const normalizedPath = this.normalizeApiPath(apiPath).apiPath; const moduleIDKey = String(moduleID); diff --git a/src/slothlet.mjs b/src/slothlet.mjs index c2267840..9700ffd2 100644 --- a/src/slothlet.mjs +++ b/src/slothlet.mjs @@ -1500,7 +1500,7 @@ export default slothlet; * @property {object} slothlet.api - Runtime API mutation methods — availability controlled by `api.mutations` config option. * @property {Function} slothlet.api.add - Mount a new API module at runtime. %%sig: (apiPath: string, folderPath: string, [options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.api.add('utils.math', './api/utils/math');%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.add('utils.math', './api/utils/math');|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.add('utils.math', './api/utils/math');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.add('utils.math', './api/utils/math');%% * @property {Function} slothlet.api.reload - Hot-reload a specific module or directory path. %%sig: ([pathOrModuleId]: string|null, [options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|// Reload a specific module|await api.slothlet.api.reload('utils.math');|// Reload everything|await api.slothlet.api.reload();%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| // Reload a specific module| await api.slothlet.api.reload('utils.math');| // Reload everything| await api.slothlet.api.reload();|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| // Reload a specific module| await api.slothlet.api.reload('utils.math');| // Reload everything| await api.slothlet.api.reload();|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|// Reload a specific module|await api.slothlet.api.reload('utils.math');|// Reload everything|await api.slothlet.api.reload();%% - * @property {Function} slothlet.api.remove - Unmount an API module at runtime. %%sig: (pathOrModuleId: string): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%% + * @property {Function} slothlet.api.remove - Unmount an API module at runtime. `remove(id)` removes every path the module owns; `remove(apiPath)` removes that path's whole subtree; the two-argument `remove(moduleID, apiPath)` removes only that module's single node at the path, leaving sibling modules sharing the mount and the module's other mounts intact. Resolves to whether anything was removed. %%sig: (pathOrModuleId: string, apiPath?: string): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.remove('utils.math');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.remove('utils.math');%% * @property {Function} slothlet.api.leaves - Enumerate the api paths a module owns, read from the loader's ownership records. Pass a moduleID, a mount endpoint, any owned path, or `"."` for the base load; `{ details: true }` returns every owned path tagged with its kind instead of the callable paths alone. The answer is scoped to the caller — module-private members the caller could not read are omitted; `{ includePrivate: true }` (host-only) returns the unredacted list, and a module caller passing it is refused with PERMISSION_DENIED. %%sig: (key: string, [options]: Object): Promise.<string[]|Array.<{path: string, kind: "function"|"namespace"|"data"}>>%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|const moduleID = await api.slothlet.api.add('shop', './ext/shop/api');|await api.slothlet.api.leaves(moduleID);%% %%example: // ESM usage via slothlet API (inside async function)|async function example() {| const { default: slothlet } = await import("@cldmv/slothlet");| const api = await slothlet({ base: './api' });| await api.slothlet.api.leaves('shop', { details: true });|}%% %%example: // CJS usage via slothlet API (top-level)|let slothlet;|(async () => {| ({ slothlet } = await import("@cldmv/slothlet"));| const api = await slothlet({ base: './api' });| await api.slothlet.api.leaves('shop');|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.api.leaves('shop');%% * @property {object} slothlet.api.modules - Module discovery + mount sub-namespace. Composes subsystems shipped as separate npm packages (each with a `slothlet.module.json` manifest) into this api tree at runtime. Each method is typed as `Function` here; the detailed parameter / return shapes (`DiscoverOptions`, `DiscoverResult`, `AddModuleOptions`, `AddModulesOptions`, `MountResult`, `FailureEntry`) live in [`docs/MODULE-DISCOVERY.md`](../docs/MODULE-DISCOVERY.md), which also covers multi-version routing, error codes, and the `modules:*` lifecycle events. * @property {Function} slothlet.api.modules.discover - Walk the filesystem for slothlet modules; replace the per-instance discovery cache and return the fresh results. %%sig: ([options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|const found = await api.slothlet.api.modules.discover({ scanRoot: process.cwd(), prefix: "@cldmv/packrat-driver-" });%% diff --git a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs new file mode 100644 index 00000000..af2126c8 --- /dev/null +++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs @@ -0,0 +1,98 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs + * @Date: 2026-08-24T00:00:00-08:00 (1756022400) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview api.remove() accepts an optional second apiPath to scope removal to one module's node. + * + * @description + * `remove(id)` removes every path a moduleID owns; `remove(apiPath)` removes that path's whole subtree + * regardless of which module owns each node. Neither can surgically remove a single module's node at a + * shared path. The optional second argument closes that gap: `remove(moduleID, apiPath)` removes ONLY the + * node at `apiPath` owned by `moduleID`, leaving other modules — and that same module's other mounts — + * untouched. Verified in eager and lazy modes. + * @module tests/vitests/suites/api-manager/api-manager-remove-scoped + */ + +process.env.SLOTHLET_INTERNAL_TEST_MODE = "true"; + +import { describe, it, expect, afterEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +const CONFIGS = [ + { name: "eager", config: { mode: "eager", runtime: "async", hook: { enabled: true } } }, + { name: "lazy", config: { mode: "lazy", runtime: "async", hook: { enabled: true } } } +]; + +describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ config }) => { + let api; + + afterEach(async () => { + if (api?.shutdown) await api.shutdown(); + api = null; + }); + + it("removes only the named module's node at a shared parent path", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("svc.a", () => "A", { moduleID: "modA" }); + await api.slothlet.api.add("svc.b", () => "B", { moduleID: "modB" }); + expect(api.svc.a()).toBe("A"); + expect(api.svc.b()).toBe("B"); + + // Scoped to modA at svc.a — modB's svc.b must survive. + expect(await api.slothlet.api.remove("modA", "svc.a")).toBe(true); + expect(api.svc?.a).toBeUndefined(); + expect(api.svc.b()).toBe("B"); + }); + + it("removes only one of a moduleID's several mounts", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("alpha", () => "A", { moduleID: "dup" }); + await api.slothlet.api.add("beta", () => "B", { moduleID: "dup" }); + + expect(await api.slothlet.api.remove("dup", "alpha")).toBe(true); + expect(api.alpha).toBeUndefined(); + expect(api.beta()).toBe("B"); // dup's other mount survives + }); + + it("returns false when the module does not own that path", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("alpha", () => "A", { moduleID: "dup" }); + + expect(await api.slothlet.api.remove("dup", "not.there")).toBe(false); + expect(api.alpha()).toBe("A"); // untouched + }); + + it("returns false when the moduleID is unknown", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("svc.b", () => "B", { moduleID: "modB" }); + + expect(await api.slothlet.api.remove("noSuchModule", "svc.b")).toBe(false); + expect(api.svc.b()).toBe("B"); // modB's node is not removed by another module's scoped call + }); + + it("single-arg remove(moduleID) still removes every path the module owns", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("alpha", () => "A", { moduleID: "dup" }); + await api.slothlet.api.add("beta", () => "B", { moduleID: "dup" }); + + expect(await api.slothlet.api.remove("dup")).toBe(true); + expect(api.alpha).toBeUndefined(); + expect(api.beta).toBeUndefined(); + }); + + it("rejects a non-string apiPath argument", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await expect(api.slothlet.api.remove("dup", 123)).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + }); +}); From 68a1df06d9807a8a2a408346812bad0805f0b2ae Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 08:24:02 -0700 Subject: [PATCH 04/15] fix: resolve a composite moduleID:apiPath in remove() without colliding on ':' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fallback dropped colon handling entirely to stop remove("vine:abc") from truncating to a registered "vine". But a leaf's __metadata.moduleID is the internal composite ":", and remove(meta.moduleID) is a supported call — dropping the split broke removing a module by that composite (metadata-collision-modes). Restore composite resolution safely: match a registered module whose id is the ':'-delimited prefix ONLY when it actually owns the apiPath encoded in the suffix. That ownership check distinguishes a real composite (base owns the path) from a bare user id that merely contains ':' (a "vine:abc" whose "abc" no "vine" owns still resolves to nothing, not to "vine"). Verbatim and "_" matches still run first. --- src/lib/handlers/api-manager.mjs | 35 ++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 82318e28..63cd307a 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2373,17 +2373,18 @@ export class ApiManager extends ComponentBase { const registeredModules = Array.from(this.slothlet.handlers.ownership.moduleToPath.keys()); let matchingModule = null; - // Verbatim first: the exact id add() returned must resolve to itself. A user moduleID may + // 1. Verbatim: the exact id add() returned must resolve to itself. A user moduleID may // legitimately contain ':' — slothlet's internal composite "moduleID:apiPath" separator (#303). if (this.slothlet.handlers.ownership.moduleToPath.has(pathOrModuleId)) { matchingModule = pathOrModuleId; - } else { - // Fallback: match the auto-generated "_" form of the WHOLE id — this allows - // api.remove("removableInternal") to remove "removableInternal_abc123". Match the full id - // verbatim, never a ':'-truncated prefix: splitting on ':' collided a lookup of "vine:abc" - // with a registered "vine" and wrongly removed it (#303). Walk from the end to prefer the - // most recently registered module when multiple match, as stale entries from prior - // add/remove cycles may linger due to async lazy materialization. + } + + // 2. Auto-generated "_" form of the WHOLE id — this allows api.remove("removableInternal") + // to remove "removableInternal_abc123". Match the full id, never a ':'-truncated prefix: splitting + // on ':' collided a lookup of "vine:abc" with a registered "vine" and wrongly removed it (#303). + // Walk from the end to prefer the most recently registered module when multiple match, as stale + // entries from prior add/remove cycles may linger due to async lazy materialization. + if (!matchingModule) { for (let i = registeredModules.length - 1; i >= 0; i--) { const candidate = registeredModules[i]; if (candidate.startsWith(`${pathOrModuleId}_`)) { @@ -2393,6 +2394,24 @@ export class ApiManager extends ComponentBase { } } + // 3. Composite ":" form — e.g. a leaf's `__metadata.moduleID`, + // which tagSystemMetadata builds as `${moduleID}:${apiPath.replace(/./g,"/")}`. Recover the base + // only when the ':'-delimited prefix is a registered module that actually OWNS the apiPath encoded + // in the suffix. That ownership check is what distinguishes a real composite from a user id that + // merely contains ':' (a bare "vine:abc" whose "abc" no "vine" owns must NOT collide) (#303). + if (!matchingModule) { + for (let i = registeredModules.length - 1; i >= 0; i--) { + const candidate = registeredModules[i]; + if (pathOrModuleId.startsWith(`${candidate}:`)) { + const suffixPath = pathOrModuleId.slice(candidate.length + 1).replace(/\//g, "."); + if (this.slothlet.handlers.ownership.moduleToPath.get(candidate)?.has(suffixPath)) { + matchingModule = candidate; + break; + } + } + } + } + if (matchingModule) { // Found a moduleID match moduleID = matchingModule; From fb3ac954c52d21f3793e1a22bd94b057e3264750 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 13:18:47 -0700 Subject: [PATCH 05/15] refactor: use a reserved multi-char separator for the moduleID:apiPath composite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ':' as slothlet's internal composite separator with a reserved token (MODULE_ID_SEPARATOR = "__slothlet_sep__"). A ':' is common in real module ids — a `vine:abc` namespacing convention, the internal `versionDispatcher:` id — so using it as the splitter made those ids un-round-trippable and forced fragile disambiguation. With a reserved token no module id may contain, the composite is always unambiguous to split and ':' (and every other character) is free. - metadata.mjs builds the composite with MODULE_ID_SEPARATOR (exported). - add() refuses a moduleID containing the separator (MODULE_ID_RESERVED_SEPARATOR, all 12 locales) — the only token a module id may not contain. - removeApiComponent recovers the base by splitting on the separator: a plain id (which cannot contain it) is preserved intact, a composite strips to its base. Drops the earlier ':'-specific verbatim/ownership-verified workarounds. - Update the metadata format tests (system-metadata, user-metadata) that pinned the old `prefix:apiPath` shape to the new separator; they now assert structure via the imported constant. __metadata.moduleID reads e.g. "modm__slothlet_sep__m/leaf". Fixes #303 --- src/lib/handlers/api-manager.mjs | 71 +++++++++---------- src/lib/handlers/metadata.mjs | 23 ++++-- src/lib/i18n/languages/de-de.json | 2 + src/lib/i18n/languages/en-gb.json | 2 + src/lib/i18n/languages/en-us.json | 2 + src/lib/i18n/languages/es-es.json | 2 + src/lib/i18n/languages/es-mx.json | 2 + src/lib/i18n/languages/fr-fr.json | 2 + src/lib/i18n/languages/hi-in.json | 2 + src/lib/i18n/languages/ja-jp.json | 2 + src/lib/i18n/languages/ko-kr.json | 2 + src/lib/i18n/languages/pt-br.json | 2 + src/lib/i18n/languages/ru-ru.json | 2 + src/lib/i18n/languages/zh-cn.json | 2 + ...pi-manager-colon-module-id.test.vitest.mjs | 20 ++++-- .../metadata/system-metadata.test.vitest.mjs | 19 ++--- .../metadata/user-metadata.test.vitest.mjs | 3 +- 17 files changed, 102 insertions(+), 58 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 63cd307a..ac0740e3 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -47,6 +47,7 @@ import { translate } from "@cldmv/slothlet/i18n"; import { ComponentBase } from "#factories/component-base"; import { UnifiedWrapper, resolveWrapper } from "#handlers/unified-wrapper"; import { isFrameworkInternal } from "#handlers/framework-internals"; +import { MODULE_ID_SEPARATOR } from "#handlers/metadata"; // Node-only static imports resolved via top-level await so `node:*` never // enters the static-import graph in browser bundles. ApiManager methods that @@ -1496,6 +1497,17 @@ export class ApiManager extends ComponentBase { }); } + // A user-supplied moduleID must not contain the reserved composite separator: it is the delimiter + // slothlet joins `moduleID` and `apiPath` with in the internal metadata key, so a moduleID carrying + // it would corrupt that key and make the mount unresolvable. Refuse it up front with a named error. + if (typeof restOptions.moduleID === "string" && restOptions.moduleID.includes(MODULE_ID_SEPARATOR)) { + throw new this.SlothletError("MODULE_ID_RESERVED_SEPARATOR", { + moduleID: restOptions.moduleID, + separator: MODULE_ID_SEPARATOR, + validationError: true + }); + } + const { apiPath: normalizedPath, parts } = this.normalizeApiPath(apiPath); // Compute effective (versioned) mount path when versionConfig.version is present @@ -2373,42 +2385,22 @@ export class ApiManager extends ComponentBase { const registeredModules = Array.from(this.slothlet.handlers.ownership.moduleToPath.keys()); let matchingModule = null; - // 1. Verbatim: the exact id add() returned must resolve to itself. A user moduleID may - // legitimately contain ':' — slothlet's internal composite "moduleID:apiPath" separator (#303). - if (this.slothlet.handlers.ownership.moduleToPath.has(pathOrModuleId)) { - matchingModule = pathOrModuleId; - } - - // 2. Auto-generated "_" form of the WHOLE id — this allows api.remove("removableInternal") - // to remove "removableInternal_abc123". Match the full id, never a ':'-truncated prefix: splitting - // on ':' collided a lookup of "vine:abc" with a registered "vine" and wrongly removed it (#303). - // Walk from the end to prefer the most recently registered module when multiple match, as stale - // entries from prior add/remove cycles may linger due to async lazy materialization. - if (!matchingModule) { - for (let i = registeredModules.length - 1; i >= 0; i--) { - const candidate = registeredModules[i]; - if (candidate.startsWith(`${pathOrModuleId}_`)) { - matchingModule = candidate; - break; - } - } - } - - // 3. Composite ":" form — e.g. a leaf's `__metadata.moduleID`, - // which tagSystemMetadata builds as `${moduleID}:${apiPath.replace(/./g,"/")}`. Recover the base - // only when the ':'-delimited prefix is a registered module that actually OWNS the apiPath encoded - // in the suffix. That ownership check is what distinguishes a real composite from a user id that - // merely contains ':' (a bare "vine:abc" whose "abc" no "vine" owns must NOT collide) (#303). - if (!matchingModule) { - for (let i = registeredModules.length - 1; i >= 0; i--) { - const candidate = registeredModules[i]; - if (pathOrModuleId.startsWith(`${candidate}:`)) { - const suffixPath = pathOrModuleId.slice(candidate.length + 1).replace(/\//g, "."); - if (this.slothlet.handlers.ownership.moduleToPath.get(candidate)?.has(suffixPath)) { - matchingModule = candidate; - break; - } - } + // Recover the base moduleID from the argument. The argument is either a plain moduleID (a user + // id — which can never contain the reserved separator, refused at add()) or the internal + // composite `moduleIDapiPath` (e.g. a leaf's `__metadata.moduleID`). Splitting on the + // reserved separator is therefore unambiguous: it yields the base for a composite and the whole + // id otherwise. A ':' — or any other character — in a user id is preserved intact (#303). + const candidateModuleID = pathOrModuleId.split(MODULE_ID_SEPARATOR)[0]; + + // Match the base verbatim, or its auto-generated "_" form — this allows + // api.remove("removableInternal") to remove "removableInternal_abc123". Walk from the end to + // prefer the most recently registered module when multiple match, as stale entries from prior + // add/remove cycles may linger due to async lazy materialization. + for (let i = registeredModules.length - 1; i >= 0; i--) { + const candidate = registeredModules[i]; + if (candidate === candidateModuleID || candidate.startsWith(`${candidateModuleID}_`)) { + matchingModule = candidate; + break; } } @@ -2432,11 +2424,12 @@ export class ApiManager extends ComponentBase { } } } else { - // No ownership tracking - use old heuristic (dots = apiPath). Use the id verbatim: a - // user moduleID may contain ':' and must not be truncated (#303). + // No ownership tracking - use old heuristic (dots = apiPath). Recover the base by splitting on + // the reserved separator: it strips an internal composite while preserving a plain id (which + // cannot contain the separator), so a ':' in a user id is never truncated (#303). const isModuleId = !pathOrModuleId.includes("."); apiPath = isModuleId ? null : pathOrModuleId; - moduleID = isModuleId ? pathOrModuleId : null; + moduleID = isModuleId ? pathOrModuleId.split(MODULE_ID_SEPARATOR)[0] : null; } if (!this.slothlet || !this.slothlet.isLoaded) { throw new this.SlothletError("INVALID_CONFIG_NOT_LOADED", { diff --git a/src/lib/handlers/metadata.mjs b/src/lib/handlers/metadata.mjs index e87e9ab1..a30e8278 100644 --- a/src/lib/handlers/metadata.mjs +++ b/src/lib/handlers/metadata.mjs @@ -22,6 +22,18 @@ import { ComponentBase } from "#factories/component-base"; import { resolveWrapper } from "#handlers/unified-wrapper"; import { verifyToken } from "#handlers/lifecycle-token"; +/** + * Internal delimiter joining a module's id and apiPath in the composite `moduleID` metadata key + * (`` `${moduleID}${MODULE_ID_SEPARATOR}${apiPath}` ``). Deliberately a readable, slothlet-branded + * multi-character token rather than a lone `:` — a `:` is common in real module ids (a `vine:abc` + * namespacing convention, the internal `versionDispatcher:` id), and using it as the splitter + * made those ids un-round-trippable. A moduleID containing this token is refused at `add()`, so the + * composite is always unambiguous to split. + * @type {string} + * @internal + */ +export const MODULE_ID_SEPARATOR = "__slothlet_sep__"; + /** * Metadata handler for introspection of function metadata * @class Metadata @@ -237,14 +249,15 @@ export class Metadata extends ComponentBase { return; } - // Construct full moduleID as "moduleID:apiPath/with/slashes". This composite stays the - // per-path user-metadata key, but the raw base id is stored separately (baseModuleID) so - // consumers recover the owning module verbatim instead of splitting on ":" — a user (or - // internal) base id may itself contain a colon, which splitting truncated (#303). + // Construct the composite moduleID as `${moduleID}${MODULE_ID_SEPARATOR}${apiPath/with/slashes}`. + // It stays the per-path user-metadata key; the raw base id is also stored separately (baseModuleID) + // so consumers recover the owning module without splitting. The separator is a reserved token no + // moduleID may contain (enforced at add()), so a ':' — or any other character — is free in a + // module id and the composite is always unambiguous to split (#303). let fullModuleID = systemData.moduleID; if (systemData.apiPath && systemData.moduleID) { const apiPathSlashes = systemData.apiPath.replace(/\./g, "/"); - fullModuleID = `${systemData.moduleID}:${apiPathSlashes}`; + fullModuleID = `${systemData.moduleID}${MODULE_ID_SEPARATOR}${apiPathSlashes}`; } // Derive sourceFolder from filePath if not provided diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 702a617a..0b69c6c5 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Reservierte Namen (INTERNAL_KEYS wie _materialize, _impl) sind die eigenen Wrapper-Handles des Frameworks; eine gleichnamige Moduldatei würde sie bei der Child-Adoption überschreiben. Benennen Sie die Datei um.", "MODULE_RESERVED_EXPORT": "Der Modul-Export '{name}' trägt den Namen eines framework-reservierten Schlüssels und kann nicht geladen werden.", "HINT_MODULE_RESERVED_EXPORT": "Reservierte Namen (_materialize, __impl, ...) sind die eigenen Wrapper-Handles des Frameworks — ein solcher Export könnte nur verdeckt und unerreichbar sein. Benennen Sie den Export um.", + "MODULE_ID_RESERVED_SEPARATOR": "Die Modul-ID '{moduleID}' enthält das reservierte Trennzeichen '{separator}' und kann nicht verwendet werden.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet verbindet die ID und den API-Pfad eines Moduls intern mit diesem Token; eine Modul-ID, die es enthält, würde diesen Schlüssel beschädigen. Wählen Sie eine andere ID.", "MODULE_IMPORT_FAILED": "Fehler beim Importieren des Moduls '{modulePath}': {error}. Überprüfen Sie, ob die Datei existiert und eine gültige Syntax hat.", "HINT_MODULE_IMPORT_FAILED": "Stellen Sie sicher, dass die Moduldatei existiert und importiert werden kann. Prüfen Sie auf Syntaxfehler oder fehlende Abhängigkeiten.", "CONTEXT_ALREADY_EXISTS": "Kontext für Instanz '{instanceID}' existiert bereits. Initialisierung kann nicht zweimal erfolgen.", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index 00d718cf..d9a30ce1 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Reserved names (INTERNAL_KEYS such as _materialize, _impl) are the framework's own wrapper handles; a module file by that name would overwrite them during child adoption. Rename the file.", "MODULE_RESERVED_EXPORT": "Module export '{name}' is named for a framework-reserved key and cannot be loaded.", "HINT_MODULE_RESERVED_EXPORT": "Reserved names (_materialize, __impl, ...) are the framework's own wrapper handles — such an export could only ever be shadowed and unreachable. Rename the export.", + "MODULE_ID_RESERVED_SEPARATOR": "Module ID '{moduleID}' contains the reserved separator '{separator}' and cannot be used.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet joins a module's id and api path with this token internally; a module id containing it would corrupt that key. Choose a different id.", "MODULE_IMPORT_FAILED": "Failed to import module '{modulePath}': {error}. Check that the file exists and has valid syntax.", "HINT_MODULE_IMPORT_FAILED": "Ensure the module file exists and can be imported. Check for syntax errors or missing dependencies.", "CONTEXT_ALREADY_EXISTS": "Context for instance '{instanceID}' already exists. Cannot initialize twice.", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index 2ba93b98..50849d63 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Reserved names (INTERNAL_KEYS such as _materialize, _impl) are the framework's own wrapper handles; a module file by that name would overwrite them during child adoption. Rename the file.", "MODULE_RESERVED_EXPORT": "Module export '{name}' is named for a framework-reserved key and cannot be loaded.", "HINT_MODULE_RESERVED_EXPORT": "Reserved names (_materialize, __impl, ...) are the framework's own wrapper handles — such an export could only ever be shadowed and unreachable. Rename the export.", + "MODULE_ID_RESERVED_SEPARATOR": "Module ID '{moduleID}' contains the reserved separator '{separator}' and cannot be used.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet joins a module's id and api path with this token internally; a module id containing it would corrupt that key. Choose a different id.", "MODULE_IMPORT_FAILED": "Failed to import module '{modulePath}': {error}. Check that the file exists and has valid syntax.", "HINT_MODULE_IMPORT_FAILED": "Ensure the module file exists and can be imported. Check for syntax errors or missing dependencies.", "CONTEXT_ALREADY_EXISTS": "Context for instance '{instanceID}' already exists. Cannot initialize twice.", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index 45f266b9..d316aecd 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Los nombres reservados (INTERNAL_KEYS como _materialize, _impl) son los manejadores internos del framework; un archivo de módulo con ese nombre los sobrescribiría durante la adopción de hijos. Renombra el archivo.", "MODULE_RESERVED_EXPORT": "La exportación de módulo '{name}' lleva el nombre de una clave reservada del framework y no puede cargarse.", "HINT_MODULE_RESERVED_EXPORT": "Los nombres reservados (_materialize, __impl, ...) son los manejadores internos del framework — tal exportación solo podría quedar oculta e inaccesible. Renombra la exportación.", + "MODULE_ID_RESERVED_SEPARATOR": "El ID de módulo '{moduleID}' contiene el separador reservado '{separator}' y no puede usarse.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet une internamente el ID y la ruta de API de un módulo con este token; un ID de módulo que lo contenga corrompería esa clave. Elige otro ID.", "MODULE_IMPORT_FAILED": "Error al importar el módulo '{modulePath}': {error}. Comprueba que el fichero existe y tiene una sintaxis válida.", "HINT_MODULE_IMPORT_FAILED": "Asegúrate de que el fichero del módulo existe y puede ser importado. Comprueba si hay errores de sintaxis o dependencias faltantes.", "CONTEXT_ALREADY_EXISTS": "El contexto para la instancia '{instanceID}' ya existe. No se puede inicializar dos veces.", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index ae1b78d8..b05ebba4 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Los nombres reservados (INTERNAL_KEYS como _materialize, _impl) son los manejadores internos del framework; un archivo de módulo con ese nombre los sobrescribiría durante la adopción de hijos. Renombra el archivo.", "MODULE_RESERVED_EXPORT": "La exportación de módulo '{name}' lleva el nombre de una clave reservada del framework y no puede cargarse.", "HINT_MODULE_RESERVED_EXPORT": "Los nombres reservados (_materialize, __impl, ...) son los manejadores internos del framework — tal exportación solo podría quedar oculta e inaccesible. Renombra la exportación.", + "MODULE_ID_RESERVED_SEPARATOR": "El ID de módulo '{moduleID}' contiene el separador reservado '{separator}' y no puede usarse.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet une internamente el ID y la ruta de API de un módulo con este token; un ID de módulo que lo contenga corrompería esa clave. Elige otro ID.", "MODULE_IMPORT_FAILED": "Error al importar el módulo '{modulePath}': {error}. Compruebe que el archivo existe y tiene una sintaxis válida.", "HINT_MODULE_IMPORT_FAILED": "Asegúrese de que el archivo del módulo existe y puede ser importado. Compruebe si hay errores de sintaxis o dependencias faltantes.", "CONTEXT_ALREADY_EXISTS": "El contexto para la instancia '{instanceID}' ya existe. No se puede inicializar dos veces.", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index 73a4a57f..072db1c3 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Les noms réservés (INTERNAL_KEYS comme _materialize, _impl) sont les poignées internes du framework ; un fichier de module portant ce nom les écraserait lors de l'adoption des enfants. Renommez le fichier.", "MODULE_RESERVED_EXPORT": "L'export de module '{name}' porte le nom d'une clé réservée du framework et ne peut pas être chargé.", "HINT_MODULE_RESERVED_EXPORT": "Les noms réservés (_materialize, __impl, ...) sont les poignées internes du framework — un tel export ne pourrait qu'être masqué et inaccessible. Renommez l'export.", + "MODULE_ID_RESERVED_SEPARATOR": "L'ID de module '{moduleID}' contient le séparateur réservé '{separator}' et ne peut pas être utilisé.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet joint en interne l'ID et le chemin d'API d'un module avec ce jeton ; un ID de module le contenant corromprait cette clé. Choisissez un autre ID.", "MODULE_IMPORT_FAILED": "Échec de l'importation du module '{modulePath}' : {error}. Vérifiez que le fichier existe et possède une syntaxe valide.", "HINT_MODULE_IMPORT_FAILED": "Assurez-vous que le fichier du module existe et peut être importé. Vérifiez les erreurs de syntaxe ou les dépendances manquantes.", "CONTEXT_ALREADY_EXISTS": "Le contexte pour l'instance '{instanceID}' existe déjà. Impossible d'initialiser deux fois.", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index c1acee29..041ed167 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "आरक्षित नाम (INTERNAL_KEYS जैसे _materialize, _impl) फ्रेमवर्क के अपने रैपर हैंडल हैं; उसी नाम की मॉड्यूल फ़ाइल चाइल्ड अपनाने के दौरान उन्हें अधिलेखित कर देगी। फ़ाइल का नाम बदलें।", "MODULE_RESERVED_EXPORT": "मॉड्यूल एक्सपोर्ट '{name}' फ्रेमवर्क-आरक्षित कुंजी के नाम पर है और लोड नहीं किया जा सकता।", "HINT_MODULE_RESERVED_EXPORT": "आरक्षित नाम (_materialize, __impl, ...) फ्रेमवर्क के अपने रैपर हैंडल हैं — ऐसा एक्सपोर्ट केवल छिपा और अप्राप्य रह सकता है। एक्सपोर्ट का नाम बदलें।", + "MODULE_ID_RESERVED_SEPARATOR": "मॉड्यूल ID '{moduleID}' में आरक्षित विभाजक '{separator}' है और इसका उपयोग नहीं किया जा सकता।", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet आंतरिक रूप से इस टोकन से मॉड्यूल की ID और API पथ को जोड़ता है; इसे युक्त मॉड्यूल ID उस कुंजी को दूषित कर देगी। कोई दूसरी ID चुनें।", "MODULE_IMPORT_FAILED": "मॉड्यूल '{modulePath}' आयात करने में विफल: {error}। जांचें कि फ़ाइल मौजूद है और इसमें मान्य सिंटैक्स है।", "HINT_MODULE_IMPORT_FAILED": "सुनिश्चित करें कि मॉड्यूल फ़ाइल मौजूद है और आयात की जा सकती है। सिंटैक्स त्रुटियों या अनुपलब्ध निर्भरताओं की जांच करें।", "CONTEXT_ALREADY_EXISTS": "इंस्टेंस '{instanceID}' के लिए संदर्भ पहले से मौजूद है। दो बार प्रारंभ नहीं किया जा सकता।", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index dc6d515a..a5a74440 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "予約名 (INTERNAL_KEYS の _materialize、_impl など) はフレームワーク自身のラッパーハンドルです。同名のモジュールファイルは子の取り込み時にそれらを上書きしてしまいます。ファイル名を変更してください。", "MODULE_RESERVED_EXPORT": "モジュールのエクスポート '{name}' はフレームワーク予約キーの名前を持つため読み込めません。", "HINT_MODULE_RESERVED_EXPORT": "予約名 (_materialize、__impl など) はフレームワーク自身のラッパーハンドルです — そのようなエクスポートは隠されて到達不能になるだけです。エクスポート名を変更してください。", + "MODULE_ID_RESERVED_SEPARATOR": "モジュールID '{moduleID}' は予約された区切り文字 '{separator}' を含んでいるため使用できません。", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet は内部でこのトークンを使ってモジュールのIDとAPIパスを連結します。これを含むモジュールIDはそのキーを破損させます。別のIDを選んでください。", "MODULE_IMPORT_FAILED": "モジュール '{modulePath}' のインポートに失敗しました: {error}。ファイルが存在し、構文が正しいか確認してください。", "HINT_MODULE_IMPORT_FAILED": "モジュールファイルが存在し、インポート可能であることを確認してください。構文エラーや依存関係の不足がないかを確認してください。", "CONTEXT_ALREADY_EXISTS": "インスタンス '{instanceID}' のコンテキストは既に存在します。2回初期化することはできません。", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index 24d9d22d..2c652bdb 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "예약 이름 (INTERNAL_KEYS의 _materialize, _impl 등)은 프레임워크 자체의 래퍼 핸들입니다. 같은 이름의 모듈 파일은 자식 채택 시 이를 덮어씁니다. 파일 이름을 변경하세요.", "MODULE_RESERVED_EXPORT": "모듈 내보내기 '{name}'은(는) 프레임워크 예약 키의 이름이므로 로드할 수 없습니다.", "HINT_MODULE_RESERVED_EXPORT": "예약 이름 (_materialize, __impl 등)은 프레임워크 자체의 래퍼 핸들입니다 — 이런 내보내기는 가려져 도달할 수 없을 뿐입니다. 내보내기 이름을 변경하세요.", + "MODULE_ID_RESERVED_SEPARATOR": "모듈 ID '{moduleID}'에 예약된 구분자 '{separator}'가 포함되어 있어 사용할 수 없습니다.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet은 내부적으로 이 토큰으로 모듈의 ID와 API 경로를 연결합니다. 이를 포함하는 모듈 ID는 해당 키를 손상시킵니다. 다른 ID를 선택하세요.", "MODULE_IMPORT_FAILED": "모듈 '{modulePath}' 가져오기 실패: {error}. 파일이 존재하고 유효한 구문인지 확인하십시오.", "HINT_MODULE_IMPORT_FAILED": "모듈 파일이 존재하고 가져올 수 있는지 확인하십시오. 구문 오류나 누락된 종속성을 확인하십시오.", "CONTEXT_ALREADY_EXISTS": "인스턴스 '{instanceID}'의 컨텍스트가 이미 존재합니다. 두 번 초기화할 수 없습니다.", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index ced94f40..29ab6cd3 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Nomes reservados (INTERNAL_KEYS como _materialize, _impl) são os manipuladores internos do framework; um arquivo de módulo com esse nome os sobrescreveria durante a adoção de filhos. Renomeie o arquivo.", "MODULE_RESERVED_EXPORT": "A exportação de módulo '{name}' tem o nome de uma chave reservada do framework e não pode ser carregada.", "HINT_MODULE_RESERVED_EXPORT": "Nomes reservados (_materialize, __impl, ...) são os manipuladores internos do framework — tal exportação só poderia ficar oculta e inacessível. Renomeie a exportação.", + "MODULE_ID_RESERVED_SEPARATOR": "O ID do módulo '{moduleID}' contém o separador reservado '{separator}' e não pode ser usado.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "o slothlet une internamente o ID e o caminho de API de um módulo com este token; um ID de módulo que o contenha corromperia essa chave. Escolha outro ID.", "MODULE_IMPORT_FAILED": "Falha ao importar o módulo '{modulePath}': {error}. Verifique se o arquivo existe e possui sintaxe válida.", "HINT_MODULE_IMPORT_FAILED": "Certifique-se de que o arquivo do módulo existe e pode ser importado. Verifique se há erros de sintaxe ou dependências ausentes.", "CONTEXT_ALREADY_EXISTS": "O contexto para a instância '{instanceID}' já existe. Não é possível inicializar duas vezes.", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index 50375723..726accf5 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "Зарезервированные имена (INTERNAL_KEYS, такие как _materialize, _impl) — это собственные обработчики оболочки фреймворка; файл модуля с таким именем перезаписал бы их при принятии потомков. Переименуйте файл.", "MODULE_RESERVED_EXPORT": "Экспорт модуля '{name}' назван по зарезервированному ключу фреймворка и не может быть загружен.", "HINT_MODULE_RESERVED_EXPORT": "Зарезервированные имена (_materialize, __impl, ...) — это собственные обработчики оболочки фреймворка — такой экспорт может быть лишь скрыт и недостижим. Переименуйте экспорт.", + "MODULE_ID_RESERVED_SEPARATOR": "Идентификатор модуля '{moduleID}' содержит зарезервированный разделитель '{separator}' и не может использоваться.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet внутренне объединяет идентификатор модуля и путь API этим токеном; идентификатор модуля, содержащий его, повредит этот ключ. Выберите другой идентификатор.", "MODULE_IMPORT_FAILED": "Не удалось импортировать модуль '{modulePath}': {error}. Проверьте, существует ли файл и корректен ли синтаксис.", "HINT_MODULE_IMPORT_FAILED": "Проверьте существование файла и корректность импорта. Проверьте зависимости.", "CONTEXT_ALREADY_EXISTS": "Контекст для экземпляра '{instanceID}' уже существует. Невозможно инициализировать дважды.", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index 9136c9ab..0ed8ce39 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -63,6 +63,8 @@ "HINT_MODULE_RESERVED_FILENAME": "保留名称(INTERNAL_KEYS 中的 _materialize、_impl 等)是框架自身的包装器句柄;同名模块文件会在子级收养时覆盖它们。请重命名该文件。", "MODULE_RESERVED_EXPORT": "模块导出 '{name}' 使用了框架保留键的名称,无法加载。", "HINT_MODULE_RESERVED_EXPORT": "保留名称(_materialize、__impl 等)是框架自身的包装器句柄 — 这样的导出只会被遮蔽且不可达。请重命名该导出。", + "MODULE_ID_RESERVED_SEPARATOR": "模块 ID '{moduleID}' 包含保留分隔符 '{separator}',无法使用。", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet 在内部用此标记连接模块的 ID 和 API 路径;包含它的模块 ID 会破坏该键。请选择其他 ID。", "MODULE_IMPORT_FAILED": "导入模块 '{modulePath}' 失败:{error}。请检查文件是否存在且语法正确。", "HINT_MODULE_IMPORT_FAILED": "请确保模块文件存在且可导入。检查语法错误或缺失的依赖项。", "CONTEXT_ALREADY_EXISTS": "实例 '{instanceID}' 的上下文已存在。不能初始化两次。", diff --git a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs index 17e0b9d0..cb6af6b0 100644 --- a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs @@ -15,10 +15,11 @@ * @fileoverview A moduleID containing a colon must round-trip through add/leaves/remove/reload (#303). * * @description - * `:` is slothlet's internal composite `moduleID:apiPath` separator, so a user-supplied moduleID - * containing a colon (e.g. a `vine:abc` namespaced convention) used to be un-removable: add() returned - * the id, but removeApiComponent split the argument on `:` and looked up only the first segment, so - * remove(id)/reload of that id could not find the mount. The id must now be stored and matched verbatim. + * `:` was once slothlet's internal composite `moduleID:apiPath` separator, so a user-supplied moduleID + * containing a colon (e.g. a `vine:abc` namespaced convention) used to be un-removable: removeApiComponent + * split the argument on `:` and looked up only the first segment. The internal separator is now a reserved + * multi-character token (`MODULE_ID_SEPARATOR`) that a moduleID may not contain, so `:` — and every other + * character — is a free, fully round-trippable character in a module id. * * @module tests/vitests/suites/api-manager/api-manager-colon-module-id */ @@ -27,6 +28,7 @@ process.env.SLOTHLET_INTERNAL_TEST_MODE = "true"; import { describe, it, expect, afterEach } from "vitest"; import slothlet from "@cldmv/slothlet"; +import { MODULE_ID_SEPARATOR } from "#handlers/metadata"; import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; const EAGER_CONFIGS = [ @@ -126,4 +128,14 @@ describe.each(EAGER_CONFIGS)("colon moduleID round-trips — $name", ({ config } expect(await api.slothlet.api.remove("vine")).toBe(true); expect(api.shopfront).toBeUndefined(); }); + + it("rejects a moduleID containing the reserved internal separator", async () => { + // The one token a module id may NOT contain — it is the delimiter slothlet joins the id and + // apiPath with in the internal composite key, so allowing it would corrupt that key. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await expect(api.slothlet.api.add("blocked", TEST_DIRS.API_TEST_MIXED, { moduleID: `a${MODULE_ID_SEPARATOR}b` })).rejects.toMatchObject( + { code: "MODULE_ID_RESERVED_SEPARATOR" } + ); + expect(api.blocked).toBeUndefined(); + }); }); diff --git a/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs b/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs index 2d47c20d..79197f75 100644 --- a/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs +++ b/tests/vitests/suites/metadata/system-metadata.test.vitest.mjs @@ -15,7 +15,7 @@ * @fileoverview Tests for system metadata (immutable, auto-generated). * * System metadata is automatically set by slothlet and CANNOT be modified: - * - moduleID: Module identifier with format "prefix_id:apiPath" + * - moduleID: Module identifier with format `prefix_id${MODULE_ID_SEPARATOR}apiPath` * - filePath: Absolute path to source file * - apiPath: Dotted path in API tree * - sourceFolder: Directory where module was loaded from @@ -25,6 +25,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import slothlet from "@cldmv/slothlet"; +import { MODULE_ID_SEPARATOR } from "#handlers/metadata"; import { getMatrixConfigs, TEST_DIRS, materialize } from "../../setup/vitest-helper.mjs"; describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config }) => { @@ -51,7 +52,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config expect(meta).toBeDefined(); expect(meta.moduleID).toBeDefined(); expect(typeof meta.moduleID).toBe("string"); - expect(meta.moduleID).toMatch(/^base_[a-z0-9]+:rootMath\/add$/); + expect(meta.moduleID).toMatch(new RegExp(`^base_[a-z0-9]+${MODULE_ID_SEPARATOR}rootMath/add$`)); }); it("should have correct filePath for base API", async () => { @@ -89,7 +90,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config expect(meta.moduleID).toBeDefined(); expect(typeof meta.moduleID).toBe("string"); - expect(meta.moduleID).toMatch(/^plugins_[a-z0-9]+:plugins\/config\/settings\/getPluginConfig$/); + expect(meta.moduleID).toMatch(new RegExp(`^plugins_[a-z0-9]+${MODULE_ID_SEPARATOR}plugins/config/settings/getPluginConfig$`)); }); it("should have sourceFolder matching added dir", async () => { @@ -212,7 +213,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config // After explicit materialization, metadata comes from specific function expect(metaAfter.apiPath).toBe("lazyTest.config.settings.getPluginConfig"); - expect(metaAfter.moduleID).toMatch(/^lazyTest_[a-z0-9]+:lazyTest\/config\/settings\/getPluginConfig$/); + expect(metaAfter.moduleID).toMatch(new RegExp(`^lazyTest_[a-z0-9]+${MODULE_ID_SEPARATOR}lazyTest/config/settings/getPluginConfig$`)); expect(metaAfter.filePath).toContain("settings.mjs"); // After materialization, __type should return "function" (the typeof the impl) @@ -247,7 +248,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config await materialize(api, "rootMath.add", 1, 2); const meta = api.rootMath.add.__metadata; - expect(meta.moduleID).toMatch(/^base_[a-z0-9]+:/); + expect(meta.moduleID).toMatch(new RegExp(`^base_[a-z0-9]+${MODULE_ID_SEPARATOR}`)); }); it("should use custom prefix for added API", async () => { @@ -255,14 +256,14 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config await materialize(api, "custom.config.settings.getPluginConfig"); const meta = api.custom.config.settings.getPluginConfig.__metadata; - expect(meta.moduleID).toMatch(/^custom_[a-z0-9]+:/); + expect(meta.moduleID).toMatch(new RegExp(`^custom_[a-z0-9]+${MODULE_ID_SEPARATOR}`)); }); - it("should include full apiPath after colon", async () => { + it("should include full apiPath after the separator", async () => { await materialize(api, "rootMath.add", 1, 2); const meta = api.rootMath.add.__metadata; - const [prefix, path] = meta.moduleID.split(":"); + const [prefix, path] = meta.moduleID.split(MODULE_ID_SEPARATOR); expect(prefix).toMatch(/^base_[a-z0-9]+$/); expect(path).toBe("rootMath/add"); }); @@ -278,7 +279,7 @@ describe.each(getMatrixConfigs())("System Metadata > Config: '$name'", ({ config // Should retrieve system metadata expect(metadata).toBeDefined(); expect(metadata.moduleID).toBeDefined(); - expect(metadata.moduleID).toMatch(/^base_[a-z0-9]+:rootMath\/add$/); + expect(metadata.moduleID).toMatch(new RegExp(`^base_[a-z0-9]+${MODULE_ID_SEPARATOR}rootMath/add$`)); expect(metadata.filePath).toContain("root-math.mjs"); expect(metadata.apiPath).toBe("rootMath.add"); expect(metadata.sourceFolder).toContain("api_test"); diff --git a/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs b/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs index 95e38ecd..8f9d4900 100644 --- a/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs +++ b/tests/vitests/suites/metadata/user-metadata.test.vitest.mjs @@ -25,6 +25,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import slothlet from "@cldmv/slothlet"; +import { MODULE_ID_SEPARATOR } from "#handlers/metadata"; import { getMatrixConfigs, TEST_DIRS, materialize } from "../../setup/vitest-helper.mjs"; describe.each(getMatrixConfigs())("User Metadata > Config: '$name'", ({ config }) => { @@ -223,7 +224,7 @@ describe.each(getMatrixConfigs())("User Metadata > Config: '$name'", ({ config } const meta = api.separate.config.settings.getPluginConfig.__metadata; // System metadata should be correct, not overridden by user - expect(meta.moduleID).toMatch(/^separate_[a-z0-9]+:/); + expect(meta.moduleID).toMatch(new RegExp(`^separate_[a-z0-9]+${MODULE_ID_SEPARATOR}`)); expect(meta.filePath).toContain("settings.mjs"); expect(meta.apiPath).toBe("separate.config.settings.getPluginConfig"); }); From 9f7207cce0972f7be003693f0e46d32a03500fdf Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 13:38:47 -0700 Subject: [PATCH 06/15] docs: correct removeApiComponent JSDoc (boolean return, options, separator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #306. The doc block still described a Promise return and an underscore-based moduleID heuristic. Document the boolean return, the options argument (scopedApiPath / recordHistory), and that the argument is resolved by splitting on the reserved composite separator — matching the current behavior. --- src/lib/handlers/api-manager.mjs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index ac0740e3..dfd460cb 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2345,20 +2345,31 @@ export class ApiManager extends ComponentBase { /** * Remove API modules at runtime. - * @param {string} pathOrModuleId - API path (with dots) or module ID (with underscore) to remove. - * @returns {Promise} + * @param {string} pathOrModuleId - An apiPath (dotted), a moduleID, or the composite `__metadata.moduleID`. + * @param {object} [options={}] - Options. + * @param {string} [options.scopedApiPath] - When set, `pathOrModuleId` is resolved strictly as a + * moduleID and only that module's single node at `scopedApiPath` is removed (drives the public + * two-argument `api.remove(moduleID, apiPath)`); sibling modules and the module's other mounts stay. + * @param {boolean} [options.recordHistory=true] - Whether to record the removal in the add/operation history. + * @returns {Promise} True if something was removed, false if nothing matched. * @throws {SlothletError} When inputs are invalid. * @package * * @description - * Removes an API subtree by apiPath or removes all paths owned by a moduleID. - * Automatically detects whether the parameter is a moduleID (contains underscore) or apiPath. + * Removes an API subtree by apiPath, or every path owned by a moduleID. The argument is resolved by + * splitting on the reserved composite separator ({@link MODULE_ID_SEPARATOR}): a plain id (which can + * never contain the separator) passes through whole, while a composite `__metadata.moduleID` strips to + * its base. It matches a registered module verbatim or by its auto-generated `_` form, and + * otherwise falls back to treating the argument as an apiPath. * * @example * await manager.removeApiComponent("plugins.tools"); // Remove by API path * * @example - * await manager.removeApiComponent("plugins_abc123"); // Remove by module ID + * await manager.removeApiComponent("plugins-core"); // Remove all paths owned by a module ID + * + * @example + * await manager.removeApiComponent("plugins-core", { scopedApiPath: "plugins.tools" }); // just that node */ async removeApiComponent(pathOrModuleId, options = {}) { const recordHistory = options.recordHistory !== false; From 4ba4570e1ff59dc9e333547420cee18332066549 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 14:19:52 -0700 Subject: [PATCH 07/15] fix: refuse an auto-generated moduleID that would contain the reserved separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #306. add() rejected a user-supplied moduleID containing MODULE_ID_SEPARATOR, but a default moduleID is derived from the apiPath (buildDefaultModuleId), so mounting at an apiPath whose segment carries the separator produced an auto-generated id containing it — breaking the "no moduleID contains the separator" invariant that remove()/metadata splitting rely on. Check the effective moduleID (user-supplied or auto-generated) once it is finalized and refuse it with MODULE_ID_RESERVED_SEPARATOR. Adds a regression test for the no-moduleID (apiPath-carries-separator) case. --- src/lib/handlers/api-manager.mjs | 11 +++++++++++ .../api-manager-colon-module-id.test.vitest.mjs | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index dfd460cb..b910db05 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -1706,6 +1706,17 @@ export class ApiManager extends ComponentBase { } const moduleID = restOptions.moduleID ? String(restOptions.moduleID) : this.buildDefaultModuleId(normalizedPath, resolvedFolderPath); + // The default moduleID is derived from the apiPath, so an apiPath whose segment carries the reserved + // separator would yield an auto-generated id that carries it too — breaking the "no moduleID contains + // the separator" invariant that composite splitting (remove/metadata) relies on. A user-supplied id + // is already refused above; this catches the auto-generated case at the point the id is finalized. + if (moduleID.includes(MODULE_ID_SEPARATOR)) { + throw new this.SlothletError("MODULE_ID_RESERVED_SEPARATOR", { + moduleID, + separator: MODULE_ID_SEPARATOR, + validationError: true + }); + } // buildDefaultModuleId always returns a non-empty "_" string (randomSuffix is // always 6 chars), and String(truthy-moduleID) always produces a non-empty string. // So !moduleID is never true — this guard is a defensive belt-and-suspenders check. diff --git a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs index cb6af6b0..3e5b9e1e 100644 --- a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs @@ -138,4 +138,13 @@ describe.each(EAGER_CONFIGS)("colon moduleID round-trips — $name", ({ config } ); expect(api.blocked).toBeUndefined(); }); + + it("rejects when the auto-generated moduleID would contain the separator (apiPath carries it)", async () => { + // No moduleID supplied: the default is derived from the apiPath, so an apiPath segment carrying + // the reserved token would smuggle it into the auto-generated id. Refuse it at add() too. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + const badPath = `seg${MODULE_ID_SEPARATOR}ment`; + await expect(api.slothlet.api.add(badPath, TEST_DIRS.API_TEST_MIXED)).rejects.toMatchObject({ code: "MODULE_ID_RESERVED_SEPARATOR" }); + expect(api[badPath]).toBeUndefined(); + }); }); From 2b717bed9745d96cb638801e94e33067f88d2cd0 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 16:27:11 -0700 Subject: [PATCH 08/15] docs: refresh stale ':'-separator comments in unified-wrapper baseModuleID reads Address a Copilot suppressed-comment finding on #306. Two comments near the child-attribution and impl:changed baseModuleID reads still described recovering the base by splitting a "moduleID:apiPath" tag on ':'. The composite now joins with the reserved MODULE_ID_SEPARATOR and the base is read verbatim from baseModuleID; reword to match. Comment-only. --- src/lib/handlers/unified-wrapper.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index 9780dd2a..b91686dc 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -2016,17 +2016,18 @@ export class UnifiedWrapper extends ComponentBase { } } - // moduleID: always prefer the PARENT/build owner (extract the SHORT id from the - // "moduleID:apiPath" form). One buildAPI() builds exactly one module's subtree, so a child + // moduleID: always prefer the PARENT/build owner (its raw base id, read from baseModuleID). + // One buildAPI() builds exactly one module's subtree, so a child // VALUE shared from another mount (e.g. eager+browser re-mounting a base leaf — same function // object, still carrying base's metadata) must be owned by THIS mount's module. The previous // code used the child VALUE's own moduleID whenever it carried its own metadata, which // attributed re-mounted base leaves to base_slothlet and made api.remove() roll them back // instead of deleting them (impl:removed never fired). if (parentMetadata?.baseModuleID) { - // The raw base id, stored verbatim (colon-safe) — no longer recovered by splitting the - // composite "moduleID:apiPath" tag, which truncated a base id that itself contained a - // colon (a user `vine:abc` convention, or an internal `versionDispatcher:`) (#303). + // The raw base id, stored verbatim — read directly rather than recovered from the composite + // metadata tag. The composite joins the id and apiPath with the reserved MODULE_ID_SEPARATOR + // specifically so an id may contain any character (a user `vine:abc` convention, an internal + // `versionDispatcher:` id) without being truncated on recovery (#303). childModuleId = parentMetadata.baseModuleID; } From 1b7354ab356dcd352f1a2d2b798aac9c5f910617 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 18:13:39 -0700 Subject: [PATCH 09/15] fix: prefix-remove the whole subtree in scoped remove(moduleID, apiPath) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address a Copilot suppressed-comment finding on #306. Scoped removal verified ownership of only the exact scopedApiPath, then removed that single node — so scoping to a container the module owns descendants under deleted the container's subtree from the api tree but left the descendants' ownership records orphaned in moduleToPath/pathToModule, breaking later reload/remove. Scoped removal now drops this module's ownership of every descendant path under the scoped node before removing the node itself (whose deletePath removes the subtree from the tree in one shot). A leaf scoped path has no descendants, so the single-node behavior is unchanged; a container removes the module's whole subtree there while a sibling module sharing the mount is left intact. Adds a container prefix-removal test asserting no orphaned ownership and sibling survival. --- src/lib/handlers/api-manager.mjs | 12 ++++++++++++ .../api-manager-remove-scoped.test.vitest.mjs | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index b910db05..0735f44c 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2470,6 +2470,18 @@ export class ApiManager extends ComponentBase { if (!ownedPaths || !ownedPaths.has(normalizedScoped)) { return false; } + // Prefix removal: drop this module's ownership of every descendant path under the scoped node + // first, so removing a container does not leave its descendants' ownership records orphaned in + // the registry (still listed but gone from the api tree). The single-node block below then + // removes the scoped node itself and deletes its subtree from the tree in one shot. A leaf + // scoped path has no descendants, so this loop is a no-op for it. + const descendantPrefix = `${normalizedScoped}.`; + const scopedModuleIDKey = String(moduleID); + for (const ownedPath of [...ownedPaths]) { + if (ownedPath.startsWith(descendantPrefix)) { + this.slothlet.handlers.ownership.removePath(ownedPath, scopedModuleIDKey); + } + } apiPath = normalizedScoped; } diff --git a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs index af2126c8..8e1db5eb 100644 --- a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs @@ -27,6 +27,7 @@ process.env.SLOTHLET_INTERNAL_TEST_MODE = "true"; import { describe, it, expect, afterEach } from "vitest"; import slothlet from "@cldmv/slothlet"; +import { resolveWrapper } from "#handlers/unified-wrapper"; import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; const CONFIGS = [ @@ -95,4 +96,22 @@ describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); await expect(api.slothlet.api.remove("dup", 123)).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); }); + + it("prefix-removes a whole subtree by container path without orphaning descendants", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("svc", TEST_DIRS.API_TEST_MIXED, { moduleID: "modA" }); // container + children + await api.slothlet.api.add("keep", () => "K", { moduleID: "modB" }); + // Settle the subtree so every descendant ownership record exists (esp. under lazy). + const owned = await api.slothlet.api.leaves("modA", { includePrivate: true }); + expect(owned.length).toBeGreaterThan(0); + const ownership = resolveWrapper(api.keep).slothlet.handlers.ownership; + expect(ownership.moduleToPath.get("modA")).toBeDefined(); + + // Scope to the CONTAINER: modA's whole subtree goes, the sibling module survives, and no + // descendant ownership record is left orphaned in the registry. + expect(await api.slothlet.api.remove("modA", "svc")).toBe(true); + expect(api.svc).toBeUndefined(); + expect(ownership.moduleToPath.get("modA")).toBeUndefined(); + expect(api.keep()).toBe("K"); + }); }); From 94561b286eda1ca31b718f46afd4628826228962 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 20:09:08 -0700 Subject: [PATCH 10/15] fix: make scoped remove(moduleID, apiPath) detach only this module, not the path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt deleted the whole subtree at apiPath, which is wrong: the two-argument form must remove only THAT module's contribution across the path, never the entire path (that is what remove(apiPath) is for). Scoped removal now walks every path the module owns at or under apiPath, deepest-first, and for each either removes it (when this module was its sole owner) or reverts it to the module that owned it before (when the node is shared). A node still holding another module's descendant is left standing — a container is deleted only once nothing remains under it and it is unowned. So remove(modA, "svc.a") drops modA's leaf and keeps a sibling's svc.b; remove(modA, "shop") on a shared container reverts the container and keeps modB's shop.b; and a module that solely owns a subtree has the whole subtree removed with no orphaned ownership. Tests cover the leaf, shared-container, and sole-owned-subtree cases. --- src/lib/handlers/api-manager.mjs | 67 ++++++++++++++----- .../api-manager-remove-scoped.test.vitest.mjs | 26 +++++-- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 0735f44c..45f1d2a2 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2460,29 +2460,66 @@ export class ApiManager extends ComponentBase { }); } - // Two-argument scoping: the moduleID resolved above must actually own the requested path, else - // there is nothing to remove. When it does, target exactly that node by handing (apiPath, moduleID) - // to the single-node removal path below — the same path a bare apiPath resolves to, but pinned to - // this module so a sibling module sharing the mount is left intact. + // Two-argument scoping: the moduleID resolved above must actually own the requested path, else there + // is nothing to remove. When it does, remove only THIS module's ownership across the path's subtree. if (scopedApiPath !== null) { + const ownership = this.slothlet.handlers.ownership; const normalizedScoped = this.normalizeApiPath(scopedApiPath).apiPath; - const ownedPaths = this.slothlet.handlers.ownership?.moduleToPath?.get(moduleID); + const ownedPaths = ownership?.moduleToPath?.get(moduleID); if (!ownedPaths || !ownedPaths.has(normalizedScoped)) { return false; } - // Prefix removal: drop this module's ownership of every descendant path under the scoped node - // first, so removing a container does not leave its descendants' ownership records orphaned in - // the registry (still listed but gone from the api tree). The single-node block below then - // removes the scoped node itself and deletes its subtree from the tree in one shot. A leaf - // scoped path has no descendants, so this loop is a no-op for it. - const descendantPrefix = `${normalizedScoped}.`; + // Scoped two-argument removal is recursive but pinned to THIS module: walk every path this + // module owns at or under scopedApiPath and, for each, remove it (when this module was its sole + // owner) or revert it to the module that owned it before (when the node is shared). A node still + // owned by another module is left in place. A container node is deleted only once nothing remains + // under it — so it survives as long as it still holds another module's descendants. It never + // blanket-deletes the subtree; that is what remove(apiPath) (path as the first argument) is for. const scopedModuleIDKey = String(moduleID); - for (const ownedPath of [...ownedPaths]) { - if (ownedPath.startsWith(descendantPrefix)) { - this.slothlet.handlers.ownership.removePath(ownedPath, scopedModuleIDKey); + const scopedPrefix = `${normalizedScoped}.`; + // Deepest-first so a container is reached only after its own leaves are gone. + const targets = [...ownedPaths] + .filter((p) => p === normalizedScoped || p.startsWith(scopedPrefix)) + .sort((a, b) => b.length - a.length); + for (const target of targets) { + const targetParts = this.normalizeApiPath(target).parts; + const scopedResult = ownership.removePath(target, scopedModuleIDKey); + if (scopedResult.action === "restore") { + // Shared node: revert the tree value to the owner it fell back to. + const revertValue = ownership.getCurrentValue?.(target); + const revertOwner = ownership.getCurrentOwner?.(target)?.moduleID; + if (revertValue !== undefined && revertOwner) { + const revertOptions = { mutateExisting: true, allowOverwrite: true, collisionMode: "replace", moduleID: revertOwner }; + await this.setValueAtPath(this.slothlet.api, targetParts, revertValue, revertOptions); + await this.setValueAtPath(this.slothlet.boundApi, targetParts, revertValue, revertOptions); + } else { + // Defensive: a restored node always has a current value + owner; this fallback mirrors + // the single-node path and is not reproducible in the suite. + /* v8 ignore next */ + await this.restoreApiPath(target, scopedResult.restoreModuleId); + } + } else { + // action "delete": this module was the node's last owner (an owned target never resolves + // to "none"). Remove the node from the tree ONLY when nothing is registered under it. A + // container is often owned solely by whichever module created it, so another module's + // descendant can still live under a node this module was the last owner of — deleting it + // would take that foreign node with it. Leave such a container standing (now unowned) for + // its remaining descendants; a childless leaf/container is deleted. + const stillHasChild = [...ownership.moduleToPath.values()].some((set) => { + for (const owned of set) if (owned.startsWith(`${target}.`)) return true; + return false; + }); + if (!stillHasChild) { + await this.deletePath(this.slothlet.api, targetParts); + await this.deletePath(this.slothlet.boundApi, targetParts); + } } } - apiPath = normalizedScoped; + // The apiPath branch (unlike the moduleID branch) does not call ownership.unregister(), so sweep + // any cache entry the removed nodes orphaned, and record the op for reload replay. + this.#sweepOrphanedCaches(); + this.state.operationHistory.push({ type: "remove", apiPath: normalizedScoped }); + return true; } if (apiPath && moduleID) { diff --git a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs index 8e1db5eb..984cad1d 100644 --- a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs @@ -97,7 +97,9 @@ describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ await expect(api.slothlet.api.remove("dup", 123)).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); }); - it("prefix-removes a whole subtree by container path without orphaning descendants", async () => { + it("recursively removes the module's own subtree under the scoped container, no orphans", async () => { + // The module solely owns the whole subtree at "svc": every one of its nodes is removed, the now + // childless+unowned container goes too, and nothing is left orphaned in the ownership registry. api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); await api.slothlet.api.add("svc", TEST_DIRS.API_TEST_MIXED, { moduleID: "modA" }); // container + children await api.slothlet.api.add("keep", () => "K", { moduleID: "modB" }); @@ -107,11 +109,25 @@ describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ const ownership = resolveWrapper(api.keep).slothlet.handlers.ownership; expect(ownership.moduleToPath.get("modA")).toBeDefined(); - // Scope to the CONTAINER: modA's whole subtree goes, the sibling module survives, and no - // descendant ownership record is left orphaned in the registry. expect(await api.slothlet.api.remove("modA", "svc")).toBe(true); expect(api.svc).toBeUndefined(); - expect(ownership.moduleToPath.get("modA")).toBeUndefined(); - expect(api.keep()).toBe("K"); + expect(ownership.moduleToPath.get("modA")).toBeUndefined(); // no orphaned ownership + expect(api.keep()).toBe("K"); // unrelated module untouched + }); + + it("under a shared container, removes only this module's nodes and keeps the container for the others", async () => { + // modA and modB both live under "shop": remove(modA, "shop") deletes modA's leaf, reverts the + // shared "shop" container to modB, and leaves modB's leaf and the container standing. It must NOT + // delete the whole "shop" path — that is what remove("shop") (path as the first arg) is for. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + await api.slothlet.api.add("shop.a", () => "A", { moduleID: "modA" }); + await api.slothlet.api.add("shop.b", () => "B", { moduleID: "modB" }); + expect(api.shop.a()).toBe("A"); + expect(api.shop.b()).toBe("B"); + + expect(await api.slothlet.api.remove("modA", "shop")).toBe(true); + expect(api.shop).toBeDefined(); // container survives for modB + expect(api.shop?.a).toBeUndefined(); // modA's node gone + expect(api.shop.b()).toBe("B"); // modB's node untouched }); }); From 5ccd752862b557c1c649af12c7018c7fa6a8cc0f Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 20:31:52 -0700 Subject: [PATCH 11/15] fix: don't record scoped remove in operationHistory (would replay as full-path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #306. The scoped remove(moduleID, apiPath) branch pushed {type:"remove", apiPath} to operationHistory, but reload replay re-runs a "remove" op as a single-argument removeApiComponent(apiPath) — a whole-path removal — which would over-delete other modules sharing the path. A module-scoped replay is not expressible either: replay regenerates moduleIDs, so the original id could never be matched. Recording a whole-path remove is worse than not recording, so drop the push (which also stops it from ignoring recordHistory). A scoped removal therefore does not persist across a reload — documented inline; a follow-up could add moduleID-stable replay if scoped removals need to survive reload. --- src/lib/handlers/api-manager.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 45f1d2a2..5f802543 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2516,9 +2516,14 @@ export class ApiManager extends ComponentBase { } } // The apiPath branch (unlike the moduleID branch) does not call ownership.unregister(), so sweep - // any cache entry the removed nodes orphaned, and record the op for reload replay. + // any cache entry the removed nodes orphaned. this.#sweepOrphanedCaches(); - this.state.operationHistory.push({ type: "remove", apiPath: normalizedScoped }); + // Deliberately NOT recorded in operationHistory: reload replay re-runs a {type:"remove",apiPath} + // entry as a single-argument removeApiComponent(apiPath) — a whole-path removal — which would + // over-delete other modules sharing the path. A module-scoped replay is not expressible either, + // because replay regenerates moduleIDs (see slothlet reload), so the original moduleID could not + // be matched. A scoped removal therefore does not survive a reload (the module is re-added from + // its own add op); recording a whole-path remove would be worse than not recording it. return true; } From a50c35a01404f23b4cefa0f45a779f946b767431 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 21:04:36 -0700 Subject: [PATCH 12/15] feat: replay scoped remove(moduleID, apiPath) across reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module's id survives a reload — the "add" op records options.moduleID and addApiComponent reuses it on re-add (user-supplied and auto-generated _ ids alike; the "replay_" param the replay passed was dead, addApiComponent has no top-level moduleID param). So a scoped removal CAN be replayed by id, contrary to the earlier assumption. Record scoped removals as {type:"remove", apiPath, scopedModuleID} (honoring recordHistory), and replay them via the two-argument removeApiComponent(scopedModuleID, {scopedApiPath}) so only that module's nodes go — not the whole path. Verified end-to-end: after a reload, remove(modA,"shop") keeps modA's subtree gone while modB's survives (no over-delete). Also drop the dead replay_ param and correct the replay comment. Adds a reload- persistence test. --- src/lib/handlers/api-manager.mjs | 14 ++++++++------ src/slothlet.mjs | 17 +++++++++++++++-- .../api-manager-remove-scoped.test.vitest.mjs | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 5f802543..3eb9dba4 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2518,12 +2518,14 @@ export class ApiManager extends ComponentBase { // The apiPath branch (unlike the moduleID branch) does not call ownership.unregister(), so sweep // any cache entry the removed nodes orphaned. this.#sweepOrphanedCaches(); - // Deliberately NOT recorded in operationHistory: reload replay re-runs a {type:"remove",apiPath} - // entry as a single-argument removeApiComponent(apiPath) — a whole-path removal — which would - // over-delete other modules sharing the path. A module-scoped replay is not expressible either, - // because replay regenerates moduleIDs (see slothlet reload), so the original moduleID could not - // be matched. A scoped removal therefore does not survive a reload (the module is re-added from - // its own add op); recording a whole-path remove would be worse than not recording it. + // Record a SCOPED remove for reload replay — carrying both the moduleID and the apiPath so the + // replay re-runs it as the two-argument form, not a whole-path removal. This is safe because a + // module's id survives a reload: the "add" op records `options.moduleID` and it is reused on + // re-add (user-supplied ids AND auto-generated `_` ids alike), so the same id exists + // when this op replays. `scopedModuleID` is what distinguishes it from a plain apiPath remove. + if (recordHistory) { + this.state.operationHistory.push({ type: "remove", apiPath: normalizedScoped, scopedModuleID: scopedModuleIDKey }); + } return true; } diff --git a/src/slothlet.mjs b/src/slothlet.mjs index 9700ffd2..3c3265a1 100644 --- a/src/slothlet.mjs +++ b/src/slothlet.mjs @@ -926,10 +926,13 @@ class Slothlet { await this.handlers.apiManager.addApiComponent({ apiPath: operation.apiPath, folderPath: operation.folderPath, + // operation.options carries the ORIGINAL moduleID recorded at add time; addApiComponent + // reuses it (there is no top-level moduleID param), so a module keeps the same id across a + // reload — for user-supplied ids and auto-generated `_` ids alike. That stable + // id is what lets a scoped remove op below resolve on replay. // operation.options is always provided during api.add() replay; {} fallback is dead code. /* v8 ignore next */ options: { ...(operation.options || {}), recordHistory: false }, - moduleID: `replay_${this.helpers.utilities.generateId().substring(0, 8)}`, // Generate new moduleID for replay versionConfig: operation.versionConfig || null }); } else if (operation.type === "remove") { @@ -939,7 +942,17 @@ class Slothlet { // A bare deletePath would prune only `this.api` and leave boundApi / cache / // ownership remnants, so the rebuilt tree would not match a clean // build-up-to-this-point. recordHistory:false avoids re-appending the op. - await this.handlers.apiManager.removeApiComponent(operation.apiPath, { recordHistory: false }); + if (operation.scopedModuleID) { + // Scoped removal (remove(moduleID, apiPath)): replay the two-argument form so only this + // module's nodes go. The module's id survives the add replay above, so it resolves; a + // plain apiPath removal here would over-delete other modules sharing the path. + await this.handlers.apiManager.removeApiComponent(operation.scopedModuleID, { + scopedApiPath: operation.apiPath, + recordHistory: false + }); + } else { + await this.handlers.apiManager.removeApiComponent(operation.apiPath, { recordHistory: false }); + } } else if (operation.type === "addPermissionRule") { // permissionManager is always re-registered by load() before replay (slothletProperty); the absent-manager arm is unreachable. /* v8 ignore else */ diff --git a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs index 984cad1d..5eab2ec5 100644 --- a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs @@ -130,4 +130,21 @@ describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ expect(api.shop?.a).toBeUndefined(); // modA's node gone expect(api.shop.b()).toBe("B"); // modB's node untouched }); + + it("a scoped removal survives a reload (replayed as scoped, not a whole-path removal)", async () => { + // Directory mounts (synthetic adds don't replay), each a whole module under a shared "shop" + // container. reload() replays the two adds then the scoped remove; the modules keep their ids + // across the reload, so the scoped remove resolves and only modA's subtree stays gone — modB's + // must NOT be over-deleted. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: { mutations: { add: true, remove: true, reload: true } } }); + await api.slothlet.api.add("shop.a", TEST_DIRS.API_TEST_MIXED, { moduleID: "modA" }); + await api.slothlet.api.add("shop.b", TEST_DIRS.API_TEST_MIXED, { moduleID: "modB" }); + expect(await api.slothlet.api.remove("modA", "shop")).toBe(true); + expect(api.shop?.a).toBeUndefined(); + expect(api.shop?.b).toBeDefined(); + + await api.slothlet.reload(); + expect(api.shop?.a).toBeUndefined(); // scoped removal persisted + expect(api.shop?.b).toBeDefined(); // modB survived the reload, not over-deleted + }); }); From 128704a55a36959a734133bf9c9b0f67b39f37bb Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 24 Aug 2026 21:57:23 -0700 Subject: [PATCH 13/15] fix(api-manager): replay synthetic adds across reload; cover scoped-restore revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthetic (in-memory) adds — an inline function, an export map, or a `{ exports, ...options }` object — now record their original inline value as the replay folderPath instead of the internal `synthetic:` sentinel, which a full-instance reload() would otherwise resolve as a filesystem path and fail on. A synthetic add (and a scoped removal of one) now survives reload(), keeping its moduleID so it stays removable afterward. restoreApiPath re-adds via the same recorded folderPath, so ownership rollback of a synthetic mount is consistent too. Also cover the scoped-removal restore branch that had no test: a shadowed-node case drives the revert-to-previous-owner path, and the dead else fallback now uses `/* v8 ignore else */` with an honest unreachability comment (a node whose removePath returns "restore" always has a concrete current value, so the else never runs) instead of a `never exercised in tests` justification. --- src/lib/handlers/api-manager.mjs | 19 ++-- .../api-manager-remove-scoped.test.vitest.mjs | 36 +++++++- ...i-manager-synthetic-reload.test.vitest.mjs | 86 +++++++++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 3eb9dba4..35d6e1fd 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -2163,9 +2163,14 @@ export class ApiManager extends ComponentBase { /* v8 ignore next */ if (this.slothlet.handlers.ownership) { if (restOptions.recordHistory !== false) { + // For a synthetic / in-memory add there is no file to re-read: record the ORIGINAL inline + // value (the function / export map / `{exports,...}` object the caller passed) as folderPath + // so replay and restore re-run the identical synthetic add. `resolvedFolderPath` is only the + // `synthetic:` sentinel, which replay would wrongly treat as a filesystem path (#117). + const historyFolderPath = isSynthetic ? folderPath : resolvedFolderPath; this.state.addHistory.push({ apiPath: normalizedPath, - folderPath: resolvedFolderPath, + folderPath: historyFolderPath, options: { ...restOptions, metadata, moduleID }, moduleID, versionConfig: versionConfig || null @@ -2176,7 +2181,7 @@ export class ApiManager extends ComponentBase { this.state.operationHistory.push({ type: "add", apiPath: normalizedPath, - folderPath: resolvedFolderPath, + folderPath: historyFolderPath, options: { ...restOptions, metadata, moduleID }, moduleID, versionConfig: versionConfig || null @@ -2488,14 +2493,18 @@ export class ApiManager extends ComponentBase { // Shared node: revert the tree value to the owner it fell back to. const revertValue = ownership.getCurrentValue?.(target); const revertOwner = ownership.getCurrentOwner?.(target)?.moduleID; + /* v8 ignore else */ if (revertValue !== undefined && revertOwner) { const revertOptions = { mutateExisting: true, allowOverwrite: true, collisionMode: "replace", moduleID: revertOwner }; await this.setValueAtPath(this.slothlet.api, targetParts, revertValue, revertOptions); await this.setValueAtPath(this.slothlet.boundApi, targetParts, revertValue, revertOptions); } else { - // Defensive: a restored node always has a current value + owner; this fallback mirrors - // the single-node path and is not reproducible in the suite. - /* v8 ignore next */ + // Unreachable defensive mirror of the single-node restore path: removePath only returns + // "restore" when an owner remains on the node's stack, and every ownership entry carries a + // concrete value (a leaf's callable, or a container's object), so getCurrentValue above is + // never undefined for a restored node — revertValue is always defined and this else never + // runs. Kept so the scoped path degrades the same way the single-node path would if that + // invariant were ever broken. await this.restoreApiPath(target, scopedResult.restoreModuleId); } } else { diff --git a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs index 5eab2ec5..ce082f18 100644 --- a/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs @@ -131,11 +131,23 @@ describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ expect(api.shop.b()).toBe("B"); // modB's node untouched }); + it("reverts a shadowed node to its previous owner instead of deleting it", async () => { + // modA and modB register the SAME leaf path (modB shadows modA via replace), so that node's + // ownership stack is two deep. A scoped remove(modB, "leaf") pops modB off the stack and the + // node reverts to modA's value — it is NOT deleted, because modA still owns it underneath. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, allowAddApiOverwrite: true }); + await api.slothlet.api.add("leaf", () => "A", { moduleID: "modA" }); + await api.slothlet.api.add("leaf", () => "B", { moduleID: "modB", collisionMode: "replace" }); + expect(typeof api.leaf).toBe("function"); // both owners registered at "leaf" + + expect(await api.slothlet.api.remove("modB", "leaf")).toBe(true); + expect(api.leaf()).toBe("A"); // reverted to modA underneath, not deleted + }); + it("a scoped removal survives a reload (replayed as scoped, not a whole-path removal)", async () => { - // Directory mounts (synthetic adds don't replay), each a whole module under a shared "shop" - // container. reload() replays the two adds then the scoped remove; the modules keep their ids - // across the reload, so the scoped remove resolves and only modA's subtree stays gone — modB's - // must NOT be over-deleted. + // Directory mounts, each a whole module under a shared "shop" container. reload() replays the + // two adds then the scoped remove; the modules keep their ids across the reload, so the scoped + // remove resolves and only modA's subtree stays gone — modB's must NOT be over-deleted. api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: { mutations: { add: true, remove: true, reload: true } } }); await api.slothlet.api.add("shop.a", TEST_DIRS.API_TEST_MIXED, { moduleID: "modA" }); await api.slothlet.api.add("shop.b", TEST_DIRS.API_TEST_MIXED, { moduleID: "modB" }); @@ -147,4 +159,20 @@ describe.each(CONFIGS)("remove(moduleID, apiPath) scoped removal — $name", ({ expect(api.shop?.a).toBeUndefined(); // scoped removal persisted expect(api.shop?.b).toBeDefined(); // modB survived the reload, not over-deleted }); + + it("a scoped removal of a SYNTHETIC (in-memory) mount survives a reload", async () => { + // Synthetic adds record their original inline value as the replay folderPath, so reload() + // re-runs the identical synthetic add and then the scoped remove — same guarantee as a + // directory mount: modA's node stays gone, modB's synthetic node survives. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: { mutations: { add: true, remove: true, reload: true } } }); + await api.slothlet.api.add("shop.a", () => "A", { moduleID: "modA" }); + await api.slothlet.api.add("shop.b", () => "B", { moduleID: "modB" }); + expect(await api.slothlet.api.remove("modA", "shop")).toBe(true); + expect(api.shop?.a).toBeUndefined(); + expect(api.shop.b()).toBe("B"); + + await api.slothlet.reload(); + expect(api.shop?.a).toBeUndefined(); // scoped removal persisted + expect(api.shop.b()).toBe("B"); // modB's synthetic node replayed, not over-deleted + }); }); diff --git a/tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs new file mode 100644 index 00000000..18a3eae0 --- /dev/null +++ b/tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs @@ -0,0 +1,86 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/api-manager/api-manager-synthetic-reload.test.vitest.mjs + * @Date: 2026-08-24T00:00:00-08:00 (1756022400) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview A synthetic (in-memory) add survives a full-instance reload(). + * + * @description + * A directory mount can be replayed by re-reading its folder, but a synthetic add — an inline + * function, an export map, or a `{ exports, ...options }` object — has no file to re-read. The add + * record therefore captures the ORIGINAL inline value as its replay folderPath (not the internal + * `synthetic:` sentinel, which reload would wrongly resolve as a filesystem path). reload() + * then re-runs the identical synthetic add and the mount comes back intact, keeping its moduleID so + * it stays removable afterward. + * + * @module tests/vitests/suites/api-manager/api-manager-synthetic-reload + */ + +process.env.SLOTHLET_INTERNAL_TEST_MODE = "true"; + +import { describe, it, expect, afterEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +const CONFIGS = [ + { name: "eager", config: { mode: "eager", runtime: "async", hook: { enabled: true } } }, + { name: "lazy", config: { mode: "lazy", runtime: "async", hook: { enabled: true } } } +]; + +const mutations = { mutations: { add: true, remove: true, reload: true } }; + +describe.each(CONFIGS)("synthetic add survives reload() — $name", ({ config }) => { + let api; + + afterEach(async () => { + if (api?.shutdown) await api.shutdown(); + api = null; + }); + + it("re-runs an inline-function add across reload", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations }); + await api.slothlet.api.add("probe", () => "P", { moduleID: "synthFn" }); + expect(api.probe()).toBe("P"); + + await api.slothlet.reload(); + expect(api.probe()).toBe("P"); // replayed from the recorded inline function + }); + + it("re-runs an export-map add across reload", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations }); + await api.slothlet.api.add("bag", { one: () => 1, two: () => 2 }, { moduleID: "synthObj" }); + expect(api.bag.one()).toBe(1); + expect(api.bag.two()).toBe(2); + + await api.slothlet.reload(); + expect(api.bag.one()).toBe(1); + expect(api.bag.two()).toBe(2); + }); + + it("re-runs a { exports, ...options } shorthand add across reload", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations }); + await api.slothlet.api.add("short", { exports: { hi: () => "hi" }, moduleID: "synthShort" }); + expect(api.short.hi()).toBe("hi"); + + await api.slothlet.reload(); + expect(api.short.hi()).toBe("hi"); + }); + + it("keeps the synthetic mount removable by its moduleID after reload", async () => { + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST, api: mutations }); + await api.slothlet.api.add("probe", () => "P", { moduleID: "synthFn" }); + await api.slothlet.reload(); + + expect(await api.slothlet.api.remove("synthFn")).toBe(true); // id survived the reload + expect(api.probe).toBeUndefined(); + }); +}); From 19f86a934eb0c06ef73dabeb42be6a4108d5d723 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Tue, 25 Aug 2026 07:19:39 -0700 Subject: [PATCH 14/15] refactor(api-manager): replace dishonest "never in tests" v8-ignores with honest, reachability-based ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The removeApiComponent apiPath+moduleID restore path was hidden behind a whole-block `/* v8 ignore */` labelled "never triggered in tests", but its restore branch IS reachable and already covered by the stacked-remove-by-apiPath tests — the ignore was burying tested code. Expose it and ignore only the genuinely-dead parts precisely: `/* v8 ignore else */` on the always-true action and value checks, and a scoped ignore on the none/return-false tail that cannot run (in that branch moduleID is always the path's current owner, so removePath yields "delete" or "restore", never "none"). The apiPath-only restore block and restoreApiPath are genuinely unreachable, but were justified with "tests never trigger" wording. Restate the real reasons: the apiPath-only branch runs only when the ownership handler is absent, which forces action "none"; and restoreApiPath's four call sites are all in unreachable branches (each restore fallback, which never fires because a restored node always resolves to a concrete value, and the reload zero-affected-caches path, guarded by an INVALID_API_PATH throw and the always-present base "." module). Consolidate restoreApiPath's scattered per-line ignores into one honest whole-body ignore. No behaviour change; full coverage remains 100% (the exposed restore path is covered). --- src/lib/handlers/api-manager.mjs | 53 ++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 35d6e1fd..bcf2ed83 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -1285,19 +1285,19 @@ export class ApiManager extends ComponentBase { * await this.restoreApiPath("plugins", "plugins-core"); */ async restoreApiPath(apiPath, moduleID) { - // moduleID is always supplied by callers; the null fallback is unreachable. - /* v8 ignore next */ + // Unreachable in the test suite, and by construction: restoreApiPath is a defensive rollback + // fallback whose four call sites are all in unreachable branches — the "getCurrentValue returned + // undefined" else in each removeApiComponent restore path (a restored node always resolves to a + // concrete value, so that else never runs) and the reload "zero affected caches" fallback (a + // reload always has at least one cache). No reachable path executes this method, so the whole + // body is ignored as genuinely-dead defensive code rather than with a per-line "never in tests". + /* v8 ignore start */ const normalizedModuleId = moduleID || null; const historyEntry = this.state.addHistory .slice() .reverse() - // addHistory is always empty when restoreApiPath is called in tests; the ternary fallback never fires. - /* v8 ignore start */ .find((entry) => entry.apiPath === apiPath && (normalizedModuleId ? entry.moduleID === normalizedModuleId : true)); - /* v8 ignore stop */ - // historyEntry is never populated in tests (addHistory is empty on restore calls). - /* v8 ignore start */ if (historyEntry) { await this.addApiComponent({ apiPath: historyEntry.apiPath, @@ -1313,10 +1313,7 @@ export class ApiManager extends ComponentBase { }); return; } - /* v8 ignore stop */ - // restoreApiPath is only ever called with "base" or "core"; the IF FALSE arm is unreachable. - /* v8 ignore next */ if (normalizedModuleId === "base" || normalizedModuleId === "core") { const baseApi = await this.slothlet.builders.builder.buildAPI({ dir: this.____config.dir, @@ -1337,8 +1334,6 @@ export class ApiManager extends ComponentBase { // For eager mode: __impl is the actual implementation (object/function) - extract it // For lazy mode: if __impl is a function, it's unmaterialized - extract it anyway for reload const baseValueRaw = resolveWrapper(baseValue); - // baseValue is always a wrapper proxy from buildAPI; both conditions always true. - /* v8 ignore next */ if (baseValue && baseValueRaw !== null) { baseValue = baseValueRaw.__impl; } @@ -1356,6 +1351,7 @@ export class ApiManager extends ComponentBase { moduleID: normalizedModuleId }); } + /* v8 ignore stop */ } /** @@ -2589,12 +2585,20 @@ export class ApiManager extends ComponentBase { }); return true; } - // The "restore" and "none+no-history" ownership actions in the apiPath+moduleID path are - // never triggered in tests \u2014 removeApi with both arguments always hits "delete" action. - /* v8 ignore start */ + // In this branch moduleID is always the path's current owner (resolved from getCurrentOwner during + // detection), so removePath returns "delete" (single owner) or "restore" (a shadow remains) — never + // "none". The "delete" arm above and the "restore" primary below are both covered by the + // stacked-remove-by-apiPath tests; only the restore fallback and the none/return-false tail are + // unreachable defensive handlers, ignored precisely below. The action is always "restore" once + // "delete" is handled above and "none" is impossible here, so this if's else-arm never runs. + /* v8 ignore else */ if (ownershipResult.action === "restore") { const restoredValue = this.slothlet.handlers.ownership?.getCurrentValue?.(normalizedPath); const restoredModuleId = this.slothlet.handlers.ownership?.getCurrentOwner?.(normalizedPath)?.moduleID; + // getCurrentValue always resolves a concrete value for a restored node (a remaining stack owner + // carrying a leaf callable or a container object), so the else — the restoreApiPath fallback — + // never runs. `v8 ignore else` drops only that dead arm while keeping the covered if-body counted. + /* v8 ignore else */ if (restoredValue !== undefined && restoredModuleId) { await this.setValueAtPath(this.slothlet.api, pathParts, restoredValue, { mutateExisting: true, @@ -2614,15 +2618,15 @@ export class ApiManager extends ComponentBase { apiPath: normalizedPath }); return true; + } else { + await this.restoreApiPath(normalizedPath, ownershipResult.restoreModuleId); + this.state.operationHistory.push({ type: "remove", apiPath: normalizedPath }); + return true; } - await this.restoreApiPath(normalizedPath, ownershipResult.restoreModuleId); - // Track in operation history for reload replay - this.state.operationHistory.push({ - type: "remove", - apiPath: normalizedPath - }); - return true; } + // Unreachable tail: moduleID is the path's current owner (above), so removePath never yields + // "none" here and the function never falls through — both are defensive only. + /* v8 ignore start */ if (ownershipResult.action === "none" && history.length === 0) { await this.deletePath(this.slothlet.api, pathParts); await this.deletePath(this.slothlet.boundApi, pathParts); @@ -2900,7 +2904,10 @@ export class ApiManager extends ComponentBase { // Path doesn't exist - nothing to remove return false; } - // Ownership "delete"/"restore" actions require collision ownership tracking; tests never trigger these paths. + // Unreachable: this apiPath-only branch (moduleID resolved to null) is entered only when the ownership + // handler is absent — with a handler present, a plain apiPath resolves to its current owner and takes + // the apiPath+moduleID branch above. An absent handler makes removePath yield action "none" (handled + // just above), so "delete"/"restore" never fire here. Defensive parity with the owner-tracked branches. /* v8 ignore start */ if (ownershipResult.action === "delete") { await this.deletePath(this.slothlet.api, parts); From 4e307cdfbfac8781a849ac7609b8255bee8e4c2f Mon Sep 17 00:00:00 2001 From: Shinrai Date: Tue, 25 Aug 2026 08:18:07 -0700 Subject: [PATCH 15/15] fix(api-manager): reject the reserved separator in an apiPath, not only the moduleID (#306 review) A user-supplied clean moduleID let a separator-bearing apiPath slip past the moduleID-only guards, so `add("x__slothlet_sep__y", fn, { moduleID: "clean" })` was accepted and a later `remove("seg__slothlet_sep__y")` split the argument on the reserved separator and removed the wrong module ("seg"). Reject an apiPath containing MODULE_ID_SEPARATOR at add-time: when an explicit moduleID is supplied the offending token is the path itself, so it surfaces as INVALID_CONFIG_API_PATH_INVALID with a new API_PATH_REASON_RESERVED_SEPARATOR reason (added to all 12 locales) rather than the moduleID-framed error, which the auto-generated-id guard still covers for the no-moduleID case. Regression test added. Also fix the pt-br HINT_MODULE_ID_RESERVED_SEPARATOR, which uniquely began with a lowercase article ("o slothlet"); drop it so the hint starts with the brand token like the other 11 locales (the fleet convention), rather than capitalizing it. --- src/lib/handlers/api-manager.mjs | 26 ++++++++++++++++--- src/lib/i18n/languages/de-de.json | 1 + src/lib/i18n/languages/en-gb.json | 1 + src/lib/i18n/languages/en-us.json | 1 + src/lib/i18n/languages/es-es.json | 1 + src/lib/i18n/languages/es-mx.json | 1 + src/lib/i18n/languages/fr-fr.json | 1 + src/lib/i18n/languages/hi-in.json | 1 + src/lib/i18n/languages/ja-jp.json | 1 + src/lib/i18n/languages/ko-kr.json | 1 + src/lib/i18n/languages/pt-br.json | 3 ++- src/lib/i18n/languages/ru-ru.json | 1 + src/lib/i18n/languages/zh-cn.json | 1 + ...pi-manager-colon-module-id.test.vitest.mjs | 15 +++++++++++ 14 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index bcf2ed83..b5f8856b 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -1506,6 +1506,23 @@ export class ApiManager extends ComponentBase { const { apiPath: normalizedPath, parts } = this.normalizeApiPath(apiPath); + // The reserved composite separator is disallowed in the apiPath as well, not only the moduleID: + // remove()/leaves() recover a base moduleID by splitting their argument on the separator, so a mount + // whose apiPath carried it would let a later remove(`segment`) split the path to "seg" and detach + // the wrong module. When an explicit (clean) moduleID is supplied the auto-generated-id guard below + // can't catch it, so reject the apiPath here as a path-validation error. The no-moduleID case falls + // through to that guard instead, where the offending token surfaces as the auto-generated moduleID. + if (typeof restOptions.moduleID === "string" && normalizedPath.includes(MODULE_ID_SEPARATOR)) { + const segIndex = parts.findIndex((p) => p.includes(MODULE_ID_SEPARATOR)); + throw new this.SlothletError("INVALID_CONFIG_API_PATH_INVALID", { + apiPath: normalizedPath, + segment: parts[segIndex], + index: segIndex, + reason: translate("API_PATH_REASON_RESERVED_SEPARATOR"), + validationError: true + }); + } + // Compute effective (versioned) mount path when versionConfig.version is present let effectivePath = normalizedPath; let effectiveParts = parts; @@ -1702,10 +1719,11 @@ export class ApiManager extends ComponentBase { } const moduleID = restOptions.moduleID ? String(restOptions.moduleID) : this.buildDefaultModuleId(normalizedPath, resolvedFolderPath); - // The default moduleID is derived from the apiPath, so an apiPath whose segment carries the reserved - // separator would yield an auto-generated id that carries it too — breaking the "no moduleID contains - // the separator" invariant that composite splitting (remove/metadata) relies on. A user-supplied id - // is already refused above; this catches the auto-generated case at the point the id is finalized. + // No moduleID was supplied, so the id is derived from the apiPath; if that apiPath carries the reserved + // separator the auto-generated id inherits it, breaking the "no moduleID contains the separator" + // invariant that composite splitting (remove/metadata) relies on. A user-supplied id is refused above, + // and a supplied id with a separator-bearing apiPath is refused as an INVALID_CONFIG_API_PATH_INVALID + // after normalizeApiPath; this catches the remaining auto-generated case at the point the id is finalized. if (moduleID.includes(MODULE_ID_SEPARATOR)) { throw new this.SlothletError("MODULE_ID_RESERVED_SEPARATOR", { moduleID, diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 0b69c6c5..662e45c6 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "Pfad-Segment existiert nicht oder kann nicht durchlaufen werden", "API_PATH_REASON_REQUIRED": "apiPath ist für die removeApi Operation erforderlich", "API_PATH_REASON_VERSIONED_ROOT": "Versionierte API-Komponenten können nicht am Root-Pfad eingehängt werden; geben Sie einen punkt-separierten Pfad an, z. B. 'auth' oder 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "enthält das reservierte interne Trennzeichen, das in einem API-Pfad nicht zulässig ist", "INVALID_API_PATH": "Ungültiger API-Pfad: '{apiPath}' existiert nicht in der geladenen API.", "HINT_INVALID_API_PATH": "Stellen Sie sicher, dass der API-Pfad existiert, bevor Sie ihn benutzen. Benutzen Sie einen dot-separierten Pfad (z.B. 'math.add'), der auf ein aktuell geladenes Modul verweist.", "HOOKS_NOT_INITIALIZED": "Zugriff auf Hook-Funktionalität nicht möglich: Der Hook-Manager ist nicht initialisiert.", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index d9a30ce1..ac8368e1 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "path segment does not exist or is not traversable", "API_PATH_REASON_REQUIRED": "apiPath is required for removeApi operation", "API_PATH_REASON_VERSIONED_ROOT": "versioned API components cannot be mounted at the root path; provide a dot-separated path such as 'auth' or 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "contains the reserved internal separator token, which is not permitted in an API path", "INVALID_API_PATH": "Invalid API path: '{apiPath}' does not exist in the loaded API.", "HINT_INVALID_API_PATH": "Ensure the API path exists before using it. Use a dot-separated path (e.g. 'math.add') that refers to a currently loaded module.", "HOOKS_NOT_INITIALIZED": "Cannot access hook functionality: the hook manager is not initialized.", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index 50849d63..b89bf768 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "path segment does not exist or is not traversable", "API_PATH_REASON_REQUIRED": "apiPath is required for removeApi operation", "API_PATH_REASON_VERSIONED_ROOT": "versioned API components cannot be mounted at the root path; provide a dot-separated path such as 'auth' or 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "contains the reserved internal separator token, which is not permitted in an API path", "INVALID_API_PATH": "Invalid API path: '{apiPath}' does not exist in the loaded API.", "HINT_INVALID_API_PATH": "Ensure the API path exists before using it. Use a dot-separated path (e.g. 'math.add') that refers to a currently loaded module.", "HOOKS_NOT_INITIALIZED": "Cannot access hook functionality: the hook manager is not initialized.", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index d316aecd..892aae79 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "el segmento de ruta no existe o no se puede recorrer", "API_PATH_REASON_REQUIRED": "apiPath es obligatorio para la operación removeApi", "API_PATH_REASON_VERSIONED_ROOT": "Los componentes API versionados no pueden montarse en la ruta raíz; proporciona una ruta separada por puntos como 'auth' o 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "contiene el token separador interno reservado, que no se permite en una ruta de API", "INVALID_API_PATH": "Ruta de API inválida: '{apiPath}' no existe en la API cargada.", "HINT_INVALID_API_PATH": "Asegúrate de que la ruta de la API existe antes de usarla. Usa una ruta separada por puntos (ej. 'math.add') que se refiera a un módulo cargado actualmente.", "HOOKS_NOT_INITIALIZED": "No se puede acceder a la funcionalidad de ganchos: el gestor de ganchos no está inicializado.", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index b05ebba4..e5742f4f 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "el segmento de ruta no existe o no se puede recorrer", "API_PATH_REASON_REQUIRED": "apiPath es obligatorio para la operación removeApi", "API_PATH_REASON_VERSIONED_ROOT": "Los componentes API versionados no pueden montarse en la ruta raíz; proporcione una ruta separada por puntos como 'auth' o 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "contiene el token separador interno reservado, que no se permite en una ruta de API", "INVALID_API_PATH": "Ruta de API inválida: '{apiPath}' no existe en la API cargada.", "HINT_INVALID_API_PATH": "Asegúrese de que la ruta de la API existe antes de usarla. Use una ruta separada por puntos (ej. 'math.add') que se refiera a un módulo cargado actualmente.", "HOOKS_NOT_INITIALIZED": "No se puede acceder a la funcionalidad de ganchos: el gestor de ganchos no está inicializado.", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index 072db1c3..ade24916 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "le segment de chemin n'existe pas ou n'est pas traversable", "API_PATH_REASON_REQUIRED": "apiPath est requis pour l'opération removeApi", "API_PATH_REASON_VERSIONED_ROOT": "Les composants API versionnés ne peuvent pas être montés à la racine ; fournissez un chemin séparé par des points, tel que 'auth' ou 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "contient le jeton séparateur interne réservé, qui n'est pas autorisé dans un chemin d'API", "INVALID_API_PATH": "Chemin d'API invalide : '{apiPath}' n'existe pas dans l'API chargée.", "HINT_INVALID_API_PATH": "Assurez-vous que le chemin d'API existe avant de l'utiliser. Utilisez un chemin séparé par des points (ex : 'math.add') qui fait référence à un module actuellement chargé.", "HOOKS_NOT_INITIALIZED": "Impossible d'accéder aux fonctionnalités de hook : le gestionnaire de hook n'est pas initialisé.", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index 041ed167..a7b9af46 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "पथ खंड मौजूद नहीं है या पार करने योग्य नहीं है", "API_PATH_REASON_REQUIRED": "removeApi ऑपरेशन के लिए apiPath आवश्यक है", "API_PATH_REASON_VERSIONED_ROOT": "संस्करणित API घटकों को रूट पथ पर माउंट नहीं किया जा सकता, इसके बजाय 'auth' या 'v1.auth' जैसा डॉट-अलग पथ प्रदान करें", + "API_PATH_REASON_RESERVED_SEPARATOR": "आरक्षित आंतरिक विभाजक टोकन शामिल है, जो API पथ में अनुमत नहीं है", "INVALID_API_PATH": "अमान्य API पथ: लोड की गई API में '{apiPath}' मौजूद नहीं है।", "HINT_INVALID_API_PATH": "उपयोग करने से पहले सुनिश्चित करें कि API पथ मौजूद है। डॉट-सेपरेटेड पथ (उदा. 'math.add') का उपयोग करें जो वर्तमान में लोड किए गए मॉड्यूल को संदर्भित करता है।", "HOOKS_NOT_INITIALIZED": "हुक कार्यक्षमता तक नहीं पहुँचा जा सकता: हुक मैनेजर प्रारंभ नहीं किया गया है।", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index a5a74440..ccf30ab9 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "パスセグメントが存在しないか、探索不可能です", "API_PATH_REASON_REQUIRED": "removeApi 操作には apiPath が必要です", "API_PATH_REASON_VERSIONED_ROOT": "バージョン管理された API コンポーネントはルートパスにマウントできません。'auth' や 'v1.auth' のようなドット区切りのパスを指定してください", + "API_PATH_REASON_RESERVED_SEPARATOR": "予約された内部区切りトークンを含んでおり、API パスでは使用できません", "INVALID_API_PATH": "無効な API パス: '{apiPath}' は読み込まれた API 内に存在しません。", "HINT_INVALID_API_PATH": "API パスを使用する前に存在することを確認してください。現在読み込まれているモジュールを参照するドット区切りのパス (例: 'math.add') を使用してください。", "HOOKS_NOT_INITIALIZED": "フック機能にアクセスできません: フックマネージャーが初期化されていません。", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index 2c652bdb..33fd0075 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "경로 세그먼트가 존재하지 않거나 탐색할 수 없음", "API_PATH_REASON_REQUIRED": "removeApi 작업에 apiPath가 필요함", "API_PATH_REASON_VERSIONED_ROOT": "버전이 지정된 API 컴포넌트는 루트 경로에 마운트할 수 없습니다. 'auth' 또는 'v1.auth'와 같이 점으로 구분된 경로를 제공하세요", + "API_PATH_REASON_RESERVED_SEPARATOR": "예약된 내부 구분자 토큰을 포함하고 있어 API 경로에 사용할 수 없습니다", "INVALID_API_PATH": "유효하지 않은 API 경로: 로드된 API에 '{apiPath}'이(가) 존재하지 않습니다.", "HINT_INVALID_API_PATH": "API 경로가 사용 전에 존재하는지 확인하십시오. 현재 로드된 모듈을 가리키는 점 구분 경로(예: 'math.add')를 사용하십시오.", "HOOKS_NOT_INITIALIZED": "후크 기능에 액세스할 수 없습니다: 후크 관리자가 초기화되지 않았습니다.", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index 29ab6cd3..c8e1b95b 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -64,7 +64,7 @@ "MODULE_RESERVED_EXPORT": "A exportação de módulo '{name}' tem o nome de uma chave reservada do framework e não pode ser carregada.", "HINT_MODULE_RESERVED_EXPORT": "Nomes reservados (_materialize, __impl, ...) são os manipuladores internos do framework — tal exportação só poderia ficar oculta e inacessível. Renomeie a exportação.", "MODULE_ID_RESERVED_SEPARATOR": "O ID do módulo '{moduleID}' contém o separador reservado '{separator}' e não pode ser usado.", - "HINT_MODULE_ID_RESERVED_SEPARATOR": "o slothlet une internamente o ID e o caminho de API de um módulo com este token; um ID de módulo que o contenha corromperia essa chave. Escolha outro ID.", + "HINT_MODULE_ID_RESERVED_SEPARATOR": "slothlet une internamente o ID e o caminho de API de um módulo com este token; um ID de módulo que o contenha corromperia essa chave. Escolha outro ID.", "MODULE_IMPORT_FAILED": "Falha ao importar o módulo '{modulePath}': {error}. Verifique se o arquivo existe e possui sintaxe válida.", "HINT_MODULE_IMPORT_FAILED": "Certifique-se de que o arquivo do módulo existe e pode ser importado. Verifique se há erros de sintaxe ou dependências ausentes.", "CONTEXT_ALREADY_EXISTS": "O contexto para a instância '{instanceID}' já existe. Não é possível inicializar duas vezes.", @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "o segmento do caminho não existe ou não é atravessável", "API_PATH_REASON_REQUIRED": "apiPath é obrigatório para a operação removeApi", "API_PATH_REASON_VERSIONED_ROOT": "Componentes de API versionados não podem ser montados no caminho raiz; forneça um caminho separado por pontos como 'auth' ou 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "contém o token separador interno reservado, que não é permitido em um caminho de API", "INVALID_API_PATH": "Caminho de API inválido: '{apiPath}' não existe na API carregada.", "HINT_INVALID_API_PATH": "Certifique-se de que o caminho da API existe antes de usá-lo. Use um caminho separado por pontos (ex: 'math.add') que se refira a um módulo carregado no momento.", "HOOKS_NOT_INITIALIZED": "Não é possível acessar a funcionalidade de gancho (hook): o gerenciador de ganchos não foi inicializado.", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index 726accf5..8a983140 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "Сегмент пути не существует или не может быть пройден", "API_PATH_REASON_REQUIRED": "apiPath обязателен для операции removeApi", "API_PATH_REASON_VERSIONED_ROOT": "Версионированные API-компоненты нельзя монтировать в корневом пути; укажите путь через точку, например 'auth' или 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "содержит зарезервированный внутренний разделитель, который недопустим в пути API", "INVALID_API_PATH": "Неверный API-путь: '{apiPath}' не существует в загруженной API.", "HINT_INVALID_API_PATH": "Убедитесь, что путь API существует перед использованием. Используйте точечную нотацию, напр. 'math.add'.", "HOOKS_NOT_INITIALIZED": "Невозможно использовать hooks: менеджер hook не инициализирован.", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index 0ed8ce39..39e6bfe8 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -384,6 +384,7 @@ "API_PATH_REASON_NOT_TRAVERSABLE": "路径段不存在或不可遍历", "API_PATH_REASON_REQUIRED": "removeApi 操作需要指定 apiPath", "API_PATH_REASON_VERSIONED_ROOT": "已版本化的 API 组件不能挂载在根路径下;请提供以点分隔的路径,例如 'auth' 或 'v1.auth'", + "API_PATH_REASON_RESERVED_SEPARATOR": "包含保留的内部分隔符标记,不允许出现在 API 路径中", "INVALID_API_PATH": "无效的 API 路径:'{apiPath}' 在已加载的 API 中不存在。", "HINT_INVALID_API_PATH": "在使用前确保 API 路径存在。使用点分写法(例如 'math.add')指向当前已加载的模块。", "HOOKS_NOT_INITIALIZED": "无法访问钩子功能:钩子管理器未初始化。", diff --git a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs index 3e5b9e1e..f669382c 100644 --- a/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs @@ -147,4 +147,19 @@ describe.each(EAGER_CONFIGS)("colon moduleID round-trips — $name", ({ config } await expect(api.slothlet.api.add(badPath, TEST_DIRS.API_TEST_MIXED)).rejects.toMatchObject({ code: "MODULE_ID_RESERVED_SEPARATOR" }); expect(api[badPath]).toBeUndefined(); }); + + it("rejects an apiPath carrying the reserved separator even when a clean moduleID is supplied", async () => { + // The separator is reserved in apiPaths too, not only moduleIDs. An explicit clean moduleID does + // not make a separator-bearing apiPath safe: remove()/leaves() resolution splits the argument on + // the separator, so a later remove(`segment`) would resolve to a "seg" module and detach the + // wrong mount. Refuse the apiPath at add() regardless of whether a moduleID is supplied. + api = await slothlet({ ...config, base: TEST_DIRS.API_TEST }); + const badPath = `seg${MODULE_ID_SEPARATOR}ment`; + // With a clean explicit moduleID the offending token is the apiPath, not the id, so it surfaces as a + // path-validation error rather than MODULE_ID_RESERVED_SEPARATOR (which covers the auto-generated case). + await expect(api.slothlet.api.add(badPath, TEST_DIRS.API_TEST_MIXED, { moduleID: "cleanId" })).rejects.toMatchObject({ + code: "INVALID_CONFIG_API_PATH_INVALID" + }); + expect(api[badPath]).toBeUndefined(); + }); });