Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions api_tests/api_test_namespace_callable_second/main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @Project: @cldmv/slothlet
* @Filename: /api_tests/api_test_namespace_callable_second/main.mjs
* @Date: 2026-10-02 00:00:00 -07:00 (1790924400)
* @Author: Shinrai <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Shinrai <CLDMV> (Shinrai@users.noreply.github.com)
* @Last modified time: 2026-10-02 00:00:00 -07:00 (1790924400)
* -----
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
*/

/**
* @fileoverview A second single-file module whose default export makes a shared namespace callable, so
* two modules compete for the same namespace function (#533).
* @module api_test_namespace_callable_second.main
*/

/**
* @function main
* @returns {string} `"second:main"`.
*/
export default function main() {
return "second:main";
}

/**
* @function more
* @returns {string} `"second:more"`.
*/
export function more() {
return "second:more";
}
2 changes: 2 additions & 0 deletions docs/API-RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,8 @@ api.thing("x"); // βœ… the leaf itself, mounted directly (nothing to hoist)

**Note**: `warn` is intentionally context-dependent β€” a cross-module conflict keeps the existing owner, an intra-build file/folder collision merges (see O05).

**Note**: a plain namespace becomes callable as soon as any contribution supplies its function, whatever the add order (O09) β€” `typeof api.<path>` turns "function" and the existing children stay. When two modules supply one, `merge` keeps the first and `merge-replace` takes the incoming one; `remove()` resolves it again from the modules that remain. A reference to the namespace taken before that keeps working for reads, writes and enumeration but stays non-callable; read `api.<path>` again to call it. A namespace no module makes callable stays `typeof` "object".

---

## Rule 18: Dynamic API Mutation
Expand Down
29 changes: 29 additions & 0 deletions docs/API-RULES/API-COLLISION-CONDITIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,35 @@ Collision-resolution conditions β€” which value wins when two contributions land
**Trigger**: `!existingIsCallable && valueIsCallable`
**Result**: callable occupies the slot, existing children merged on (merge: existing child wins; merge-replace: incoming wins).

**At runtime** (`api.slothlet.api.add()`, `reload()`, a `remove()` that reverts to a function owner, a lazy materialization that turns out callable): the same holds regardless of add order. When a later module supplies the function for a namespace an earlier module created as a plain namespace, the namespace becomes callable and keeps every child (`src/lib/handlers/unified-wrapper.mjs` `___adoptCallableImpl` / `___upgradeToCallableProxy`). The function slot resolves like any other merged member: a namespace with no function always takes the incoming one; one that already has a function keeps it under `merge` (first writer wins, as in O08) and takes the incoming one under `merge-replace` (incoming wins).

```javascript
await api.slothlet.api.add("plugins", "./plugins-a", { moduleID: "a" }); // plain namespace
const held = api.plugins; // typeof "object"
await api.slothlet.api.add("plugins", "./plugins-b", { moduleID: "b" }); // b's default export flattens onto plugins

typeof api.plugins; // "function"
api.plugins(); // runs b's function
api.plugins.fromA(); // a's children stay

held.fromA(); // a held reference keeps reading, enumerating and writing through
typeof held; // "object" β€” it stays non-callable; read api.plugins again to call it
```

A Proxy's callability is fixed when it is created, so the namespace gets a new proxy on a function target, installed in its parent in place of the old one; the old proxy forwards every operation to the new one (the same forwarding `restart()` gives held references). A reference taken before the change therefore stays non-callable, while `api.<path>` is callable. A namespace no module ever makes callable keeps `typeof` "object".

On `remove()`, the namespace's function is resolved again from the modules that remain, in the order they were added: the first one that supplies a function takes the slot, and a later one replaces it only if it was added under `merge-replace` or `replace` (a merge loser never does). So removing the module whose function won reverts to the function that was there before it β€” under `merge-replace`, the previous function β€” and removing a module whose function lost, or one that only contributed children, leaves the function as it is.

```javascript
// collision.api: "merge-replace"
await api.slothlet.api.add("plugins", "./plugins-a", { moduleID: "a" }); // plain namespace
await api.slothlet.api.add("plugins", "./plugins-b", { moduleID: "b" }); // supplies a function
await api.slothlet.api.add("plugins", "./plugins-c", { moduleID: "c" }); // supplies another: c's wins
await api.slothlet.api.remove("c"); // api.plugins() runs b's function again
```

The change to a callable proxy is one-way: when no function is left (`remove()` of the only module that supplied one, or a `reload()` after which no module exports one), the namespace's children revert as usual but it keeps the callable proxy β€” `typeof` stays "function" and a call throws `INVALID_CONFIG_NOT_A_FUNCTION`, exactly as for a namespace that was callable from the start.

---

## O10: Wrapper / Plain Fall-Through Winners
Expand Down
2 changes: 2 additions & 0 deletions docs/RELOAD.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ console.log(api.myProp); // undefined - runtime mutations cleared on reload

Runtime mutations (`api.myProp = ...`) are intentionally cleared on full reload because the wrapper tree is rebuilt. Mutations to properties that belong to loaded modules (e.g. `api.math.add = customFn`) are also cleared - the reload replaces the impl inside those wrappers.

One case changes the reference at a path: a plain namespace that an `add()`, `reload()` or `remove()` makes callable (a later module's default export flattens onto it). A proxy's callability is fixed when it is created, so `api.<path>` gets a new, callable proxy; a reference taken before keeps reading, writing and enumerating the same namespace but stays non-callable. Re-read `api.<path>` to call it. See [O09](API-RULES/API-COLLISION-CONDITIONS.md#o09-namespace-vs-callable--callable-wins).

---

## ESM / CJS Cache Busting
Expand Down
67 changes: 65 additions & 2 deletions src/lib/handlers/api-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,9 @@ export class ApiManager extends ComponentBase {
}
} else if (collisionMode === "merge") {
/* v8 ignore stop */
// A plain namespace takes the function the incoming module supplies (#533); one that already
// has a function keeps it.
existingWrapper.___adoptCallableImpl(nextWrapper.____slothletInternal.impl);
// Keep existing, only add new keys
// CRITICAL: Use hasOwnProperty instead of 'in' to avoid matching ComponentBase
// prototype getters (e.g., 'config', 'debug') which would prevent child adoption
Expand Down Expand Up @@ -906,6 +909,8 @@ export class ApiManager extends ComponentBase {
// above, and skip/warn/error never reach syncWrapper (mutateApiValue runs only for
// merge/merge-replace). nextChildKeys already excludes `_`/`__` keys (built above), so no
// per-key internal guard is needed. Add new keys and replace existing.
// The incoming module's function fills the namespace's function slot, replacing an existing one (#533).
existingWrapper.___adoptCallableImpl(nextWrapper.____slothletInternal.impl, true);
for (const key of nextChildKeys) {
const childValue = nextWrapper[key];
// Use hasOwnProperty to avoid matching ComponentBase prototype getters
Expand Down Expand Up @@ -2879,6 +2884,59 @@ export class ApiManager extends ComponentBase {
return contributions;
}

/**
* Collect the live wrapper of every rolled-back path and snapshot the function of each that has one,
* before a removal restores into them (#533).
* @param {Array<{apiPath: string}>} rollbacks - The removal's rolled-back paths.
* @returns {Array<[string, object]>} `[apiPath, live raw wrapper]` for each path still in the tree.
* @private
*
* @description
* A module that created a namespace records the namespace's own wrapper as its ownership value, so its
* contribution reads through to the wrapper's current impl. The restore replaces that impl with the
* current owner's contribution β€” for a namespace, often a plain object from a module merged in later β€”
* so those entries are pinned to the function they hold now, for `#restoreNamespaceFunction` to read.
*/
#collectRolledBackNodes(rollbacks) {
const callable = [];
for (const { apiPath } of rollbacks) {
const live = resolveWrapper(this.getValueAtPath(this.slothlet.api, this.normalizeApiPath(apiPath).parts));
if (!live) continue;
const impl = live.____slothletInternal.impl;
if (UnifiedWrapper._isCallableImpl(impl)) this.slothlet.handlers.ownership.pinLiveEntries(apiPath, live, impl, resolveWrapper);
callable.push([apiPath, live]);
}
return callable;
}

/**
* Give a namespace the function its remaining contributors resolve to, after a removal (#533).
* @param {object} live - The live (raw) namespace wrapper.
* @param {string} apiPath - Its api path.
* @returns {void}
* @private
*
* @description
* The function is resolved the way the adds resolved it: in ownership order, the first contributor
* that supplies a function takes the slot, and a later one replaces it only when it was placed under
* `merge-replace` / `replace`; a merge loser never does. With no function left, the namespace keeps
* the impl the restore gave it (a call then throws `INVALID_CONFIG_NOT_A_FUNCTION`).
*/
#restoreNamespaceFunction(live, apiPath) {
// Only a namespace: a rolled-back leaf already holds the value its current owner resolves to.
if (!Object.keys(live).some((key) => !key.startsWith("_"))) return;
const ownership = this.slothlet.handlers.ownership;
let fn = null;
for (const entry of ownership.getPathHistory(apiPath)) {
if (entry.isMergeLoss) continue;
const raw = resolveWrapper(entry.value);
const impl = raw ? raw.____slothletInternal.impl : entry.value;
if (!UnifiedWrapper._isCallableImpl(impl)) continue;
if (fn === null || ownership.getPlacementMode(entry.moduleID, apiPath) !== null) fn = impl;
}
if (fn !== null) live.___adoptCallableImpl(fn, true);
}

/**
* Restore surviving contributions' content into a live wrapper, keeping the live wrapper itself (#531).
* @param {object} live - The live (raw) wrapper at `apiPath`.
Expand Down Expand Up @@ -3382,6 +3440,9 @@ export class ApiManager extends ComponentBase {
// children there are dropped), so the deletions below leave it alone: deletePath prunes an emptied
// ancestor, which took the rolled-back namespace itself off the tree before it could be restored.
pathsToRollback.sort((a, b) => a.apiPath.split(".").length - b.apiPath.split(".").length);
// A callable namespace's function is resolved separately from its children (#533): pin it first,
// since the restores below replace the live impl with the current owner's contribution.
const rolledBackNodes = this.#collectRolledBackNodes(pathsToRollback);
const restoredWrappers = new Set();
const restoreContext = { restored: restoredWrappers, removedPaths: new Set(uniquePaths) };
const restoredRoots = [];
Expand Down Expand Up @@ -3501,6 +3562,8 @@ export class ApiManager extends ComponentBase {
this.state.replaceShadows.delete(moduleIDKey);
}

for (const [apiPath, live] of rolledBackNodes) this.#restoreNamespaceFunction(live, apiPath);

this.state.addHistory = this.state.addHistory.filter((entry) => String(entry.moduleID) !== moduleIDKey);

// Delete cache entry for this moduleID (complete module removal)
Expand Down Expand Up @@ -4121,8 +4184,8 @@ export class ApiManager extends ComponentBase {
* down, so the cycle's changes inside it apply and the other modules' children there stay;
* - everything else is the cycle's own and is rebuilt by the replace.
* The rebuilt children are attributed to `moduleID`, whichever module created the namespace.
* A callable namespace keeps its function when that function is another module's: the one that
* created the namespace (a later add merges its children in but never makes it callable).
* A callable namespace keeps its function when that function is another module's β€” whichever module
* supplied it, the one that created the namespace or a later add that merged it in (#533).
* @param {object} wrapper - The raw namespace wrapper receiving the rebuilt contribution.
* @param {string} apiPath - The namespace's api path.
* @param {*} implForReload - The module's rebuilt contribution at this level.
Expand Down
25 changes: 25 additions & 0 deletions src/lib/handlers/ownership.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,31 @@ export class OwnershipManager extends ComponentBase {
entry.memberExportPaths = origin.memberExportPaths ?? null;
}

/**
* Pin the entries at `apiPath` that record a live wrapper by reference to a snapshot of its impl,
* before the wrapper's impl is replaced in place (#533).
*
* @description
* A module that created a namespace records the namespace's own wrapper as its value, so its
* contribution reads through to whatever impl the wrapper holds now. When a later module's function
* is merged into that namespace, the wrapper's impl becomes that function; without a snapshot, a
* rollback to the creating module would re-apply the function it never supplied.
* @param {string} apiPath - API path of the entries.
* @param {object} wrapper - The raw wrapper whose impl is about to change.
* @param {*} impl - The wrapper's impl before the change.
* @param {Function} resolve - Maps a recorded value to its raw wrapper (or null).
* @returns {void}
* @public
*
* @example
* ownership.pinLiveEntries("plugins", wrapper, previousImpl, resolveWrapper);
*/
pinLiveEntries(apiPath, wrapper, impl, resolve) {
for (const entry of this.pathToModule.get(apiPath) ?? []) {
if (resolve(entry.value) === wrapper) entry.value = impl;
}
}

/**
* Record a module's mount endpoint (the apiPath it was loaded/added at).
* This is the module's ownership root β€” the subtree it is allowed to write
Expand Down
Loading
Loading