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 0b365986..cbe65a34 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 @@ -1284,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, @@ -1312,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, @@ -1336,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; } @@ -1355,6 +1351,7 @@ export class ApiManager extends ComponentBase { moduleID: normalizedModuleId }); } + /* v8 ignore stop */ } /** @@ -1496,8 +1493,36 @@ 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); + // 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; @@ -1694,6 +1719,18 @@ export class ApiManager extends ComponentBase { } const moduleID = restOptions.moduleID ? String(restOptions.moduleID) : this.buildDefaultModuleId(normalizedPath, resolvedFolderPath); + // 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, + 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. @@ -2140,9 +2177,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 @@ -2153,7 +2195,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 @@ -2333,20 +2375,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; @@ -2359,21 +2412,31 @@ 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; 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; + + // 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}_`)) { @@ -2385,6 +2448,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); @@ -2398,10 +2465,12 @@ export class ApiManager extends ComponentBase { } } } else { - // No ownership tracking - use old heuristic (dots = apiPath) + // 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.split(":")[0] : null; + moduleID = isModuleId ? pathOrModuleId.split(MODULE_ID_SEPARATOR)[0] : null; } if (!this.slothlet || !this.slothlet.isLoaded) { throw new this.SlothletError("INVALID_CONFIG_NOT_LOADED", { @@ -2410,6 +2479,91 @@ 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, 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 = ownership?.moduleToPath?.get(moduleID); + if (!ownedPaths || !ownedPaths.has(normalizedScoped)) { + return false; + } + // 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); + 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); + // When this scoped removal empties the module (it owns nothing outside scopedApiPath), block it + // from further registration BEFORE the walk. Tearing down a lazy node materializes it to delete it, + // and that materialization can register previously-unregistered descendants (a lazy submodule's + // leaves — e.g. shop.a.interop's) AFTER `targets` was computed; on a reload replay those late + // registrations leak back and resurrect the removed subtree. Marking the module unregistered up + // front makes ownership.register() reject them, so the removal is complete and stays gone across a + // reload. Only for a full removal — a partial one keeps sibling paths outside scopedApiPath that + // must still be able to materialize, so it is not blocked. + const isFullRemoval = [...ownedPaths].every((p) => p === normalizedScoped || p.startsWith(scopedPrefix)); + if (isFullRemoval) { + ownership?.markUnregistered?.(scopedModuleIDKey); + } + 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; + /* 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 { + // 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 { + // 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); + } + } + } + // The apiPath branch (unlike the moduleID branch) does not call ownership.unregister(), so sweep + // any cache entry the removed nodes orphaned. + this.#sweepOrphanedCaches(); + // 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; + } + if (apiPath && moduleID) { const normalizedPath = this.normalizeApiPath(apiPath).apiPath; const moduleIDKey = String(moduleID); @@ -2461,12 +2615,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, @@ -2486,15 +2648,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); @@ -2772,7 +2934,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); diff --git a/src/lib/handlers/metadata.mjs b/src/lib/handlers/metadata.mjs index ec04aaf4..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,11 +249,15 @@ export class Metadata extends ComponentBase { return; } - // Construct full moduleID as "moduleID:apiPath/with/slashes" + // 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 @@ -258,6 +274,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/ownership.mjs b/src/lib/handlers/ownership.mjs index 48e2cca5..f1b669f3 100644 --- a/src/lib/handlers/ownership.mjs +++ b/src/lib/handlers/ownership.mjs @@ -223,6 +223,30 @@ export class OwnershipManager extends ComponentBase { return { removed, rolledBack }; } + /** + * Block a moduleID from any further path registration without removing its current paths. + * + * @param {string} moduleID - Module to block from re-registration. + * @returns {void} + * @public + * + * @description + * Sets the same async-race guard {@link OwnershipManager#unregister} sets, but standalone: the scoped + * `remove(moduleID, apiPath)` path detaches nodes one at a time via {@link OwnershipManager#removePath} + * and never calls `unregister`. Tearing down a lazy node materializes it, and that materialization can + * register previously-unregistered descendants (a lazy submodule's leaves) AFTER the removal's target + * list was computed — which, on a reload replay, leak back and resurrect the removed subtree. When a + * scoped removal empties a module, call this BEFORE the walk so those late registrations are rejected + * (register() returns null for a module in this set). Cleared on the next {@link OwnershipManager#clear} + * (reload). Only for a full removal — a partial one keeps sibling paths that must still materialize. + * + * @example + * ownership.markUnregistered("plugins-core"); + */ + markUnregistered(moduleID) { + this._unregisteredModules.add(moduleID); + } + /** * @param {string} apiPath - API path to modify. * @param {string|null} [moduleID=null] - Module to remove (defaults to current owner). diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index b4d2b332..b91686dc 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, @@ -2016,18 +2016,19 @@ 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?.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 — 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; } const childSourceFolder = childExistingMetadata?.sourceFolder || parentMetadata?.sourceFolder || null; diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 702a617a..662e45c6 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.", @@ -382,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 00d718cf..ac8368e1 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.", @@ -382,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 2ba93b98..b89bf768 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.", @@ -382,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 45f266b9..892aae79 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.", @@ -382,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 ae1b78d8..e5742f4f 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.", @@ -382,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 73a4a57f..ade24916 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.", @@ -382,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 c1acee29..a7b9af46 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}' के लिए संदर्भ पहले से मौजूद है। दो बार प्रारंभ नहीं किया जा सकता।", @@ -382,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 dc6d515a..ccf30ab9 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回初期化することはできません。", @@ -382,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 24d9d22d..33fd0075 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}'의 컨텍스트가 이미 존재합니다. 두 번 초기화할 수 없습니다.", @@ -382,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 ced94f40..c8e1b95b 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": "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.", @@ -382,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 50375723..8a983140 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}' уже существует. Невозможно инициализировать дважды.", @@ -382,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 9136c9ab..39e6bfe8 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}' 的上下文已存在。不能初始化两次。", @@ -382,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/slothlet.mjs b/src/slothlet.mjs index c2267840..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 */ @@ -1500,7 +1513,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-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..f669382c --- /dev/null +++ b/tests/vitests/suites/api-manager/api-manager-colon-module-id.test.vitest.mjs @@ -0,0 +1,165 @@ +/** + * @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 + * `:` 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 + */ + +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 = [ + { 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); +}); 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..ce082f18 --- /dev/null +++ b/tests/vitests/suites/api-manager/api-manager-remove-scoped.test.vitest.mjs @@ -0,0 +1,178 @@ +/** + * @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 { resolveWrapper } from "#handlers/unified-wrapper"; +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" }); + }); + + 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" }); + // 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(); + + expect(await api.slothlet.api.remove("modA", "svc")).toBe(true); + expect(api.svc).toBeUndefined(); + 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 + }); + + 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, 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 + }); + + 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(); + }); +}); 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"); });